
Running GitHub Actions Runner on your local MacBook Pro
This page has been translated by machine translation. View original
The world runs on cause and effect. AI agents started handling implementation, and the amount of code written per day increased. As a result, code reviews became harder to do. So we began supplementing the role that reviews used to play with harnesses, tests, ADRs, and so on. As tests increase, so does the CI time triggered by PRs.
In other words, what I'm trying to say is that when AI started writing code, the problem wasn't implementation — it was CI. Static analysis and type checking are manageable, but e2e tests take 5–10 minutes each even when run in parallel. Since the implementation volume increased, this weight piled on just as quickly.
At this rate, we'd be in trouble if CI couldn't run by the end of the month, so after considering various options, I ended up using my own MacBook Pro as a CI runner. Here's a summary of how that went.
About the Environment
I can't go into too much detail, but it's a monorepo with two Next.js apps and one Hono worker. One of the apps currently looks like this:
| TS / TSX files | 303 |
| Heavy test files | 47 (including 21 E2E specs and 12 DB integration tests) |
What CI runs is fairly heavy. In a single CI run, we rebuild the DB, seed it with 50,000 records, build two Next apps, and launch a browser.
Writing it out like this, it's no surprise the usage quota gets burned through fast — this could win a prize in an essay contest.
- Biome, TypeScript type checking, boundary value checks, unit tests
- Next.js production builds (×2), Hono bundle check
- Migration and seed execution in a postgres container
- ~80 integration tests, scale tests
- e2e split into 2 shards per app, running on a dev server
Separately from this setup, GitHub's CodeQL feature was also running in parallel on every push. It's only 2 minutes per run, but it all adds up.
Self-hosted Runner Options That Were Rejected
First, I considered the options that came to mind off the top of my head.
AWS CodeBuild Managed GitHub Runner
AWS CodeBuild supports GitHub Runners in a fully managed way.
However, since a new environment spins up for each job, Playwright browsers, .next/cache, and npm cache all start cold every time. As a result, each run was estimated to take around 30 minutes. So if we ran CI 20 times a day, it could cost around $200/month. Lambda Compute is cheap, but it doesn't support the Docker daemon, so that was a no-go...
Spinning Up a Runner On-Demand with ECS
DeNA published a resource called Scalable Self-hosted Runners with Amazon ECS. I considered using this as a basis for spinning up ECS tasks on each CI run.
First, the issues raised in the resource — "minimum 30 seconds until the runner is available" and "Capacity Provider scale-out takes several minutes" — were fatal, so during working hours I kept runners running as residents.
However, keeping them resident felt wasteful when not in use, and it also seemed a bit awkward when work piled up.
Also, a weakness of this approach is the inability to retain cache. Mounting EFS might seem like a solution, but:
npm ciperforms file operations on the order of 100,000 files.- EFS latency per operation is more than 10× that of local SSD, making the cache slower than no cache
- Elastic Throughput charges per GB for reads. 1–2 GB per job × 4 jobs × 20 runs/day = ~4–5 TB/month, which alone could exceed $100/month
- Docker layer cache can't be stored on EFS
In the end, this was enough to conclude that the requirements called for a resident host with a warm local disk.
Spinning Up an Instance with Amazon Lightsail
When you need a resident host, EC2 is the first thing that comes to mind, but AWS has a service with unlimited usage at a fixed monthly price. That's right — Lightsail. A $24 instance (4 GB / 2 vCPU / 80 GB) seemed like it would be sufficient to run one job at a time serially. I thought it sounded convenient too.
I quickly registered it as a Runner and tried running CI. Builds and checks that normally finish quickly took more than 10 minutes... Something seemed off, so I investigated and found that Lightsail was bursting. It burst during the initialization phase at startup and then got pinned at 0 while CI was running.
BurstCapacityPercentage # ~10 minutes after startup
03:07 0.06 %
03:12 0.0 %
03:17 0.0000174 %
03:22 0.000103 %
03:27 0.0000687 %
I fell into the not-so-surprising pitfall of Lightsail — in hindsight, of course that's what happens — and gave up on this setup. I also considered spinning up an EC2 instance, but the cost would be fairly significant even if only running during work hours, and since my work hours aren't fixed, it was hard to pin down the numbers, so I dropped that option too.
The Solution: Using MacBook Pro
I racked my brain over where else to get computing resources from. You push from your PC to GitHub, CI runs from there... and then I realized: the computing resources are right here. My own MacBook Pro — M2 Pro / 10 cores / 16 GB — should work as a CI runner. And it has better specs than any of the machines I was trying to provision, for free.
One macOS constraint is that APFS is case-insensitive, which means you won't catch import path capitalization errors. Also, the services: block is Linux Runner-only, and I use it in my CI.
So I went with a setup using Lima to spin up an Ubuntu VM and run the Runner inside it. Using --plain means no host filesystem is mounted at all, so even if the workflow gains root inside the VM, it can't reach the Mac side. Deleting it is also just one command: limactl delete -f ci.
limactl create --name=ci --plain \
--cpus=6 \
--memory=8 \
--disk=60 \
template://ubuntu-24.04
After that, bootstrapping it gets the Runner up and running.
Minor Issues and Solutions
Port Conflicts
When running 2 e2e tests in parallel, one of them failed. port is already allocated — port numbers were colliding. There are two places using ports, so I rewrote the e2e setup to let them be chosen freely.
- Let Docker choose the Postgres publish port
- Let Next.js choose its port freely
apt Conflicts
Even Bruno Mars would be surprised. This was also caused by running 2 jobs in parallel on a single machine. npx playwright install --with-deps chromium calls apt-get internally. Since two shards were running simultaneously on the same machine, they collided here.
E: Could not get lock /var/lib/apt/lists/lock. It is held by process 16254 (apt-get)
Switching to flock and running the same experiment had both exit with code 0, so I adopted that approach to work around the issue.
Handling Additional Developers
If everyone sets up a Runner with the same label, GitHub distributes jobs to whichever Runner is free. That means your fan spins for someone else's PR. I figured the real problems were: the PC might be asleep when CI needs to run, and computing resources could become unavailable at unpredictable times. So I set it up to route jobs to the PR author's machine.
runs-on: [self-hosted, linux, "owner-${{ github.actor }}"]
Each person's Runner identifies itself with two labels: owner-<GitHub login> and playwright. CI goes to the author; deploy and cron use a shared label for anyone to pick up. The latter is hard to handle cleanly, so for now it's a "whoever's available" situation — but if deployment latency is acceptable, I'm considering partially adopting one of the previously rejected options. Since I'm currently the only developer on this project, it won't be a problem for a while, so this is how I'm operating for now.
Cost and Speed Estimates
Here's a summary in table form. In terms of both cost and speed, running locally on a PC seems like the right call for a while.
| GitHub-hosted | Lightsail $24 | Mac + Lima VM | |
|---|---|---|---|
check |
143 s | 665 s | 60–85 s |
db-tests |
249 s | Failed | 213 s |
| e2e / shard | 254 s | — | 175 s |
| Monthly cost | $0.16/run (after quota) | $24 | $0 |
| Monthly cost at 70 runs/day | ~$336 | $24 | $0 |
Conclusion
Fixed pricing and sustained performance are two different things, and burstable instances don't seem to pair well with CI. If you choose based on monthly cost alone, you end up with a machine that's 4.6× slower.
And while CI isn't running, debt quietly accumulates. I left Actions stopped for several days, and the backlog from that period surfaced on the first day I started it back up.
In the end, the most cost-effective option wasn't new infrastructure — it was the MacBook Pro I use for development every day.
If CI is getting heavy and you're struggling with GitHub-hosted Runner quota limits, it's well worth trying your local machine as a Runner first.