Running GitHub Actions Runner on your local MacBook Pro

Running GitHub Actions Runner on your 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 method of virtualizing our MacBook Pro using Lima and utilizing it as a self-hosted runner. We will introduce the implementation and the ideas behind it.
2026.08.06

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 manage. So we began compensating for the role that reviews used to play with things like harnesses, tests, and ADRs. As tests increase, so does the CI time triggered by PRs.

What I'm trying to say is, 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 grew, this burden piled on just as fast — it was over in an instant.

Since this situation would leave us unable to run CI through the end of the month, I explored various options and ultimately landed on 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 and 12 DB integration tests)

What CI runs is fairly heavy. In a single CI run, it rebuilds the DB, seeds 50,000 records, builds two Next instances, and launches a browser.
Writing it out like this, you could win a prize for the most obvious essay conclusion: "Well, of course it's burning through usage quotas."

  • Biome, TypeScript type checking, boundary value checks, unit tests
  • Next.js production builds (for both apps), Hono bundle check
  • Migration and seed execution in a postgres container
    • About 80 integration tests, scale tests
  • e2e split into 2 shards per app, run against a dev server

Separate from this configuration, GitHub's CodeQL feature was also running alongside 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 fully managed.

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

However, since a new environment spins up for each job, Playwright browsers, .next/cache, and npm cache all start cold every time. This means a single run would likely take around 30 minutes. So if you run it 20 times a day, you could end up paying around $200 USD per month. Lambda Compute is cheap, but it doesn't support the Docker daemon, so that was a dead end...

Launching a Runner on ECS per Run

DeNA published a resource called Scalable Self-hosted Runners with Amazon ECS. I considered using this as a base to spin up ECS tasks for each CI run.

The issues raised in that resource — "minimum 30 seconds before a runner is available" and "Capacity Provider scale-out takes several minutes" — were critical, so they filled in working hours with resident runners.
However, having them resident meant wasted time when unused, and it also seemed a bit tricky when work got busy.

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

  • npm ci performs file operations on the order of 100,000 files.
    • EFS latency per operation is more than 10x that of local SSD, making cache slower, not faster
  • 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

Through this process, I was able to conclude that a resident host with warm local disk matched the requirements.

Setting Up an Instance on Amazon Lightsail

Once I determined I needed a resident host, EC2 was the first thing that came to mind — but AWS has a service with a fixed monthly fee and unlimited use. That's right: Lightsail. I figured a $24 instance (4 GB / 2 vCPU / 80 GB) would be enough to run one job at a time sequentially. I thought it seemed convenient.

I quickly registered it as a Runner and tried running CI. Builds and checks that normally finish quickly took over 10 minutes... Something seemed wrong, so I investigated — and found that Lightsail was bursting. It burst during the initialization phase at startup, then pinned to 0 and stayed there while CI ran.

BurstCapacityPercentage # about 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 Lightsail's not-so-surprising pitfall — "yeah, that makes sense when you think about it" — and abandoned this configuration too. I also considered spinning up an EC2 instance, but the cost of running it only during work hours would still add up, and since my working hours aren't fixed, it was hard to size it properly, so I dropped that too.

The Solution: Using MacBook Pro

I kept racking my brain over where else to source computing resources. Push from my PC to GitHub, trigger CI from there... and then I realized: I have computing resources right in front of me. My own MacBook Pro — M2 Pro / 10 cores / 16 GB — should work fine as a CI runner. And it's 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 won't be caught. Also, the services: block is Linux Runner-only, and I use it in my CI.

So I went with a setup using Lima to run an Ubuntu VM and run the runner inside it. Using --plain means no host filesystem is mounted at all, so even if the workflow gets root inside the VM, it can't reach the Mac side. Deleting it is just limactl delete -f ci.

limactl create --name=ci --plain \
  --cpus=6 \
  --memory=8 \
  --disk=60 \
  template://ubuntu-24.04

After that, bootstrapping it gets it running as a runner.

Minor Issues and Solutions

Port Conflicts

When running two e2e tests in parallel, one of them would fail. port is already allocated — port numbers were colliding. There are two places where ports are used, so I rewrote the e2e setup to let them be chosen freely.

  • Postgres lets Docker choose the published port
  • Next.js is free to choose its own port

apt Conflicts

Even Bruno Mars would be surprised. This too was 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 the fix.

Handling More Developers

If everyone runs a runner with the same label, GitHub distributes work to whichever runner is free. That means your fan spins up for someone else's PR. The fundamental problem, as I saw it, was that if the PC is asleep CI gets stuck, and computing resources become unpredictably unavailable — so I set it up to route 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 jobs 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 deploy time doesn't matter much, I'm considering partially adopting one of the rejected approaches from earlier. Since I'm currently the only developer on this project, this won't be a problem for a while.

Cost and Speed Estimates

Here's a comparison table. In terms of both cost and speed, running locally on my PC looks like the right call for now.

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 — burstable instances and CI don't mix well. If you choose based on monthly cost alone, you might end up with a box that's 4.6x slower.
And when CI isn't running, debt quietly accumulates. After Actions stopped, I left it for a few days — and all that backlog showed up 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 usage quotas, it's well worth trying your local machine as a runner first.

Share this article