A few weeks after I joined the company, I pressed the button to run our regression suite and got a message I didn't expect. It said we were out of GitHub Actions budget.
Today our CI runs on Magic Containers, our container platform, instead of GitHub-hosted runners. Four toolchains run there, .NET, Node, Rust and Python, across different repos and teams. For the Magic Containers project alone, our measured CI usage works out to about $407 a month on GitHub-hosted runners versus $60 on Magic Containers.
This is how we got there, how it works, and what I'd tell you before you try it yourself.
Two yellow flags
Before that budget message appeared, there were other things that made me reconsider our approach. My freshly built regression framework on GitHub Actions worked for the most part until one day, it stopped.
Tests that had passed an hour earlier started failing, and no new feature explained it. That was the first yellow flag. GitHub's runners were in the US at the time, and some of them couldn't reliably reach our CDN endpoints. The failures had nothing to do with our code. For someone in QA, that's the worst kind of failure, because you stop trusting a red result.
The second flag was speed. Regression took around 28 to 30 minutes per thousand test cases. I tried running it in parallel, and it barely helped.
We already had the servers
My first thought was maybe we can get some hardware. I asked around with the idea of buying a server and putting it in the office. Someone even offered their old laptop for the pilot project, which would have worked right up until it went offline on a weekend with nobody around.
Then it struck me (yes, only then) that we already run servers all over the world. We’ve got Magic Containers, why not run CI there?
I built my first runner, and the results were good straight away. I could scale it up and down however I wanted. A few weeks later the budget message appeared, and the decision made itself. I did the math on what we were spending on CI minutes, and it was a lot of money for nothing. I've always thought that money you don't spend is money you've earned.
The day it stuck
For a while this was mostly my side project, and I kept improving it by making runners heal themselves when a container crashed, so a failure on our side would never block anyone. Then a colleague posted that we were hitting our GitHub limits again. And it was our time to shine! So I said it was a simple switch, we can move to our runners, and if anything went wrong, we could switch back in seconds.
We switched, and after that nobody needed convincing. Regression has now run on these runners for over a year, and other teams have followed.
The numbers
CI for the Magic Containers project, the control plane and QA suite together, measured from 1 to 19 October and scaled to a month:
| Minutes a month | Per month | Per year | |
|---|---|---|---|
| GitHub-hosted runners | 67,905 | $407 | $4,884 |
| Magic Containers | 45,270 | $60 | $720 |
| Change | −85% |
There are two reasons for this:
- Our machines are bigger: GitHub's standard runner has 2 cores and 7 GB of memory. Ours have 8 cores and 32 GiB, so the same jobs take about 1.5x less time.
- We pay for what jobs use: Magic Containers bills CPU by the second of CPU time and memory in 64 MB blocks per hour. So 8 cores and 34 GB is the most a job can use, not what we pay for. GitHub Team charges $0.006 a minute for a Linux runner once the 3,000 included minutes run out.
The important thing here is also that Magic Containers figures are list price, so what you would pay to run the same apps. Whatever still runs on GitHub comes to about 2,280 minutes a month, inside the 3,000 the plan includes. So we pay GitHub nothing for CI now.
Will your workflow run?
When I offered other teams these runners, nobody asked about cost first. They asked whether their existing workflows would still work. Most workflows lean on ready-made actions pulled in with uses:, like actions/checkout, and whether one works depends on how it's built.
There are three kinds of action:
- JavaScript actions run on Node, which is already on the runner. These work.
- Composite actions are a list of shell steps. These work.
- Docker container actions need a Docker daemon, and a sandbox doesn't have one. These don't start.
You can tell which kind you have from the action's action.yml. This one won't run:
runs:
using: 'docker'
image: 'Dockerfile'
This one will:
runs:
using: 'node20'
main: 'dist/index.js'
If you depend on a Docker action, you have three options. Find a JavaScript equivalent, inline the command the action wraps, or leave that one job on a GitHub runner. The ones we hit were wrappers around a single CLI call, so I inlined them.
What stays on GitHub
Building a container image needs a daemon, and there's no way around that, so those jobs still run on GitHub's machines. Image builds are a small share of total CI time, which is why the leftovers fit inside the free allowance.
- GitHub: anything that needs a daemon, so image builds and registry pushes
- Your own machines: compile, test, lint, package
One repository variable moves the line, and you never edit a workflow file:
jobs:
build:
runs-on: ${{ fromJSON(vars.CI_RUNNER || '["ubuntu-latest"]') }}
Set CI_RUNNER to your runner labels and the repo moves across. Clear it and everything goes back to GitHub. That's also how you roll back if something breaks.
Setting up a runner
A runner image is a base image, your toolchain, the Actions runner binary, and a start script:
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl jq git tar gzip && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends dotnet-sdk-8.0
ARG RUNNER_VERSION=2.337.0
WORKDIR /opt/actions-runner
RUN curl -fsSL "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" | tar -xz
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
Registration tokens are single use and expire in about an hour, so the start script asks GitHub for a fresh one every time the container boots:
#!/usr/bin/env bash
set -euo pipefail
repo="${GH_RUNNER_URL#https://github.com/}"
token=$(curl -fsSL -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${repo}/actions/runners/registration-token" | jq -r .token)
./config.sh --url "${GH_RUNNER_URL}" --token "${token}" \
--name "${RUNNER_NAME}" --labels "${RUNNER_LABELS}" --unattended --replace
exec ./run.sh
Two things to know before you run it:
- The runner refuses to start as root unless you set
RUNNER_ALLOW_RUNASROOT=1. - Without
--replace, a restarted container can't reclaim its own name, so dead registrations pile up.
Then create an app on Magic Containers, point it at the image, set four variables, and pick your regions:
{
"name": "ci-runner-01",
"imageName": "my-runner-image",
"environmentVariables": [
{ "name": "GH_RUNNER_URL", "value": "https://github.com/<org>/<repo>" },
{ "name": "GH_PAT", "value": "<token with Administration: read+write>" },
{ "name": "RUNNER_LABELS", "value": "self-hosted,ci,dotnet" },
{ "name": "RUNNER_NAME", "value": "ci-runner-01" }
]
}
The token never runs a job. Its only job is to mint the registration token at boot, which is why a restarted container signs itself back in with nobody watching. If this is going to live for a while, a GitHub App is a better owner of that permission than someone's personal token.
Finally, ask for your labels in the workflow:
jobs:
tests:
runs-on:
labels: [ 'ci', 'dotnet' ]
That's the whole setup. There's no agent to install and nothing else to run alongside it, and your code and secrets stay on infrastructure you control.
Test databases as sidecars
Our integration tests used to start their own dependencies with Testcontainers:
var redis = new RedisBuilder().WithImage("redis:alpine").Build();
await redis.StartAsync();
That needs a Docker daemon too, so it stopped working in CI. The test factory now checks for an endpoint first, and only starts a container if there isn't one:
var ciEndpoint = Environment.GetEnvironmentVariable("CI_REDIS_ENDPOINT");
if (!string.IsNullOrEmpty(ciEndpoint))
return new RedisTestInstance(ciEndpoint);
// nothing set, so we're on a laptop: start a container as before
Locally nothing changed. In CI, Redis and etcd run as extra containers in the same app as the runner, and the runner gets CI_REDIS_ENDPOINT=localhost:6379.
This is just an edit to the test suite. Everywhere a test built a container, it now reads config instead. And one shared instance behaves differently from a fresh container per test, so you need a rule for keeping tests from stepping on each other.
Why localhost works
Containers in one app share a single network namespace, the same way a Kubernetes pod does. They have separate filesystems and processes, but one network stack, so one loopback interface and one set of ports. When the sidecar binds 6379, your test finds it at 127.0.0.1:6379. There's no service discovery, no DNS name, and no network hop. Two things follow:
- Because ports are shared inside the app, two sidecars can't both bind 6379, so move one of them.
- And ports aren't shared outside it, so nothing you bind is reachable elsewhere unless you publish it.
What I got wrong
Runners kept dying on restart
At first I put a registration token straight into the container. Those expire, so the first time a container restarted, the runner went offline and stayed offline. When we tried to roll runners out across the organization, they kept dropping. The start script above is the fix: a long-lived credential that only mints a fresh registration token at boot.
Ephemeral storage is free, up to a hard limit
Every container comes with 10 GB of ephemeral disk at no extra cost. If a job fits in that and nothing has to survive after it finishes, you don’t need a volume at all. But if a container goes over 10 GB, it’s killed and recreated, with no log telling you why. That’s by design, and Kubernetes treats ephemeral storage the same way, but it catches people out.
Persistent volumes are the one part of the bill that works differently: they’re billed by provisioned size, however much of it you use. So attach one only for what has to outlive the job, like a dependency cache, and size it to what you need.
Pre-baked images go stale
Our runner images ship with their toolchains already installed, which is part of why they're fast. The trade-off is that they don't refresh themselves the way GitHub's runners do, so they need rebuilding to stay current.
The fleet sits idle 93% of the time
Then I looked at what those machines were actually doing. They're busy 7% of the time and idle for the other 93%.
That's quite typical because CI arrives in bursts. You’ll have nothing overnight, then forty jobs the moment someone merges. Now, on a regular VM fleet, if you size the fleet for the burst, you’ll pay through the quiet hours, but if you size it for the average, people will queue.
On Magic Containers, the dilemma mostly goes away. You pay for the CPU and memory a container actually uses, not for what it has allocated, so a runner sitting idle waiting for work costs close to nothing. You start paying when a job actually runs on it. That’s why sizing the fleet for the burst is the cheap option here.
A machine per job would cut the bill further, but I couldn’t do it at first. Containers took tens of seconds to become ready, and a runner that arrives half a minute late is worse than one that was already there. Because an idle runner costs so little, keeping the fleet up was an easy compromise.
A runner per job
Start-up is down to seconds now and that came out of performance work on Magic Containers over the past months rather than anything CI-specific.
So a runner no longer has to exist before the job does. What I'm building now watches the job queue, starts one machine per job with the right image for that job's language, and deletes it when the job finishes. The standing fleet goes away, and the only thing left running is the watcher itself, one small container.
| Per month | Per year | |
|---|---|---|
| GitHub-hosted runners | $407 | $4,884 |
| Our fleet today | $60 | $720 |
| Per-job runners + watcher | $9 | $108 |
About $4 of that $9 is the CI itself and the rest is the estimated cost of the watcher. The runners above are live today. The watcher isn't built yet, so the bottom row is a projection based on our measured CI usage, not an actual bill.
It should also stop a kind of flaky test. Today, consecutive jobs share the same sidecar databases, and state leaks between runs. A fresh app per job gives each one a clean database without anyone arranging it.
Should you do this?
If most of your CI minutes are billed rather than covered by your plan, probably. On a VM fleet, volume decides it: a standing fleet only beats per-minute pricing if you keep it busy enough. On Magic Containers that bar is much lower, because you pay for what jobs use, not for runners waiting for work. Per-job runners will lower it further.
Comments require cookies. to view and post.

