Running GitHub Actions Runner on a local MacBook Pro

Running GitHub Actions Runner on a local MacBook Pro

AI increasing the amount of code has made CI e2e test execution time a headache. After considering building a runner on AWS, we ultimately arrived at a solution of virtualizing our on-hand MacBook Pro with Lima and utilizing it as a self-hosted runner. We will introduce that implementation and the ingenuity behind it.
2026.08.06

This page has been translated by machine translation. View original

The world operates 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 conduct. So, the role that reviews once played began to be supplemented by harnesses, tests, ADRs, and similar practices. As tests increase, so does the CI runtime triggered by PRs.

In other words, what I want 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 volume of implementation has grown, this burden compounds quickly, and it became a problem almost instantly.

Since continuing this way meant we'd run out of CI capacity before the end of the month, I considered various options and ultimately 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 containing 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 · 12 DB integration)

What CI runs is fairly heavy. In a single CI run, it rebuilds the DB, seeds 50,000 records, builds two Next.js instances, and launches a browser.
Writing it out like this, it's no surprise the usage quota gets burned through fast — worthy of a gold prize in a "statement of the obvious" competition.

  • Biome · TypeScript type checking · boundary value checks · unit tests
  • Next.js production build (×2) · Hono bundle check
  • Running migration and seed on a postgres container
    • ~80 integration tests · scale tests
  • e2e split into 2 shards per app, running on dev server

Separately from this definition, GitHub's CodeQL feature was also running in parallel on every push. It's 2 minutes per run, but small things add 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.

https://docs.aws.amazon.com/codebuild/latest/userguide/action-runner-overview.html

However, since a new environment is spun up for each job, the Playwright browser, .next/cache, and npm cache all start cold every time. This means each run is estimated to take around 30 minutes. So if you run CI 20 times a day, that could cost around $200/month. Lambda Compute is cheap, but it doesn't support the Docker daemon, so it wasn't usable...

Launching a Runner on ECS Each Time

DeNA published a resource titled Amazon ECS で作るスケーラブルなセルフホストランナー. I considered using this as a basis for spinning up an ECS task for each CI run.

First, the challenges mentioned in the material — "minimum 30 seconds before the runner is available" and "Capacity Provider scale-out takes several minutes" — were critical issues, so I had considered keeping resident runners during working hours.
However, having resident runners felt wasteful when idle, and seemed somewhat inconvenient when work got busy.

Another weakness of this approach is the inability to retain cache. Mounting EFS might seem like a solution, but:

  • npm ci involves file operations on the order of 100,000 files.
    • EFS latency per operation is over 10× higher than local SSD, making 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 would exceed $100/month
  • Docker layer cache can't be stored on EFS

From this analysis, I was able to conclude that a resident host with warm local disk fit the requirements.

Launching an Instance on Amazon Lightsail

When you need a resident host, EC2 is the first thing that comes to mind, but AWS also offers a service with a fixed monthly fee for unlimited use. That's Lightsail. A $24 instance (4 GB / 2 vCPU / 80 GB) seemed sufficient to run one job at a time sequentially. I thought it sounded convenient too.

I registered it as a runner right away and ran CI on it. Builds and checks that normally finish quickly took over 10 minutes... Something seemed off, and upon investigation, I found that Lightsail was bursting. It bursted during the initialization phase at startup and then flatlined at 0 while CI was running.

BurstCapacityPercentage # about 10 minutes after launch
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 — "well, of course, now that you mention it" — and abandoned this setup too. I also considered launching an EC2 instance, but the cost would be considerable even if only running during work hours, and since work hours aren't fixed, it was hard to determine the right sizing, so I dropped that as well.

Solution: Using MacBook Pro

I racked my brain over where else to source computing resources. Push from PC to GitHub, trigger CI from there... and then I realized: there's a computing resource right in front of me. My own MacBook Pro — M2 Pro / 10 cores / 16 GB — should work as a CI runner. And it has better specs than anything I'd been trying to set up, for free.

One macOS constraint is that APFS is case-insensitive, which means import path casing mistakes go undetected. Also, the services: block is Linux-runner-only, and my CI uses this block.

So I went with a setup using Lima to run an Ubuntu VM and running the runner inside it. Using --plain means no host filesystem is mounted, so even if a workflow gains root inside the VM, it can't reach the Mac side. Deleting it is also a single 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 e2e tests with 2 parallel workers, one of them failed. port is already allocated — port numbers were conflicting. There are two places where ports are used, so I rewrote the e2e setup to allow them to be chosen freely.

  • Let Docker choose the publish port for Postgres
  • Let Next.js freely choose its port

apt Conflicts

Even Bruno Mars would be surprised. This was also caused by running two jobs in parallel on one machine. npx playwright install --with-deps chromium calls apt-get internally. Since two shards run simultaneously on the same machine, they were colliding here.

E: Could not get lock /var/lib/apt/lists/lock. It is held by process 16254 (apt-get)

Replacing it with flock and running the same test resulted in both exiting with code 0, so I adopted that approach as a workaround.

Handling More Developers

If everyone sets up a runner with the same label, GitHub distributes jobs to whichever runner is available. That means your fan spins for someone else's PR. I thought the core problems were: if the PC is asleep, CI just waits; and computing resources can 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-<GitHubLogin> and playwright. CI goes to the author; deploy and cron use a shared label for whoever picks it up. The latter is hard to solve cleanly, so it's currently "whoever picks it up," but if build time for deploys isn't critical, I'm considering partially adopting one of the previously rejected approaches. Since I'm currently the only developer on this project, this arrangement isn't a problem for the time being.

Cost and Speed Estimates

Here's a summary in table form. In terms of cost and speed, running on a local PC looks like the best option 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 different things, and burstable instances don't seem to pair well with CI. If you choose based on monthly cost alone, you might end up with a box that's 4.6× slower.
And while CI isn't running, debt quietly accumulates. After Actions stopped, I left it for a few days, and that backlog was exposed 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 already use for development every day.

If your CI is getting heavy and you're struggling with GitHub-hosted Runner quota limits, it's absolutely worth trying your local machine as a runner first.

Share this article