DEV Community

Cover image for I Promised My Laptop 60 GiB. It Has 14.
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Promised My Laptop 60 GiB. It Has 14.

The oldest record I have is timestamped 26 September, 15:18:27, and it reads:

2026-09-26 15:18:27 yuva 1: mac-mustafa-macbook-pro-1-1790425105 hazır
Enter fullscreen mode Exit fullscreen mode

That line marks a turning point in the life of a laptop, not a server. From that hour
on, my work machine — the one I write on and whose lid I close at night — also became
the continuous integration server for one organization's nine repositories and five
personal ones. The reasoning was simple: GitHub's Linux minutes were a budget line,
while the 14 cores sitting on my desk looked free.

Nine and a half hours later, at 00:53 in the morning, I paid the bill for that
decision. It wasn't denominated in minutes.

Every measurement below was taken on 27 September between 13:35 and 14:00; the log
counts belong to that moment.

What actually moved

On paper the setup is clean. A Linux virtual machine on top of macOS via
Lima (vz type, Rosetta enabled), rootless
Docker inside it, and inside that, GitHub Actions runner containers that come up once
per job and then disappear. Here is the real allocation:

$ limactl list
NAME  STATUS   VMTYPE  ARCH     CPUS  MEMORY  DISK
ci    Running  vz      aarch64  12    14GiB   60GiB
Enter fullscreen mode Exit fullscreen mode

The whole machine has 24 GiB of memory and 14 cores. So I handed roughly 58 percent of
the memory and 12 of the cores to a virtual machine, and I work with what's left. I
don't write that down as a loss — it's a deliberate trade. But deliberate trades have
arithmetic too, and I hadn't done that arithmetic on day one.

One thing I got right from the start: the virtual machine's mounts setting is null.
Not a single folder from my Mac is visible inside. A CI job cannot see my Desktop, my
Keychain, or my SSH keys. If you're considering turning your own machine into a runner,
this is the non-negotiable item — and the rest isn't just tuning either. GitHub's
secure use documentation
is blunt: self-hosted runners "should almost never be used for public repositories on
GitHub, because any user can open pull requests against the repository and compromise
the environment." The same page urges caution even on private repositories, since
anyone who can fork and open a pull request may compromise the runner environment,
"including gaining access to secrets and the GITHUB_TOKEN." Every repository here is
private, but I don't take the word "almost" lightly.

It's also worth knowing where the wall is solid and where it isn't. Between the Mac and
the virtual machine it's solid: no shared folders. Inside the virtual machine it's
soft — every runner container has the Docker socket mounted:

supervisor.sh:43  -v /run/user/501/docker.sock:/var/run/docker.sock ... --cpus 4 --memory 6g
Enter fullscreen mode Exit fullscreen mode

So a job can start new containers outside its own 6 GiB ceiling, see its neighbours'
ports thanks to --network host, and write into the shared Gradle cache. Those are
risks I accept, because every repository belongs to me. If even one didn't, this setup
wouldn't be enough.

849 runners in twenty-three hours

The runners aren't persistent; they're single-use. For each free slot the supervisor
script fetches a "just-in-time" configuration from GitHub, starts a container, and
deletes it when the job ends. GitHub documents this plainly: with ephemeral runners
"GitHub only assigns one job to a runner," and the service "will then automatically
de-register the runner after it has processed one job."

Here's how that design shows up in the log:

$ grep -c 'hazır' ~/ci-runners/supervisor.log        # organization slots
675
$ grep -c 'hazır' ~/ci-runners/repo-supervisor.log   # repository-level slots
174
Enter fullscreen mode Exit fullscreen mode

The log's first line is from 26 September at 14:21 and its last from the moment I took
the measurement: a window of twenty-three and a half hours. In that window 849 runners
were born and died. The number looks alarming at first, but this is the healthy part: no persistent registration, every job starts in a clean
shell, and the garbage one job leaves behind doesn't reach the next. The part that
concerned me was different: each of those 849 containers wants a share of the same
14 GiB.

I watched the speed of that cycle live while writing this. I stepped inside a container
to take a measurement; five minutes later Docker told me "no such object" for the same
name. Seven seconds after that it existed again — same name, different runner. Inside
my laptop, without my noticing, there's a factory being assembled and dismantled
several times a minute.

At one point I thought I was contradicting myself: if everything is single-use, why
don't builds re-download their dependencies every time? They don't, because I left
persistent volumes behind:

$ docker volume ls -q -f name=ci-
ci-android-sdk-slot1
ci-android-sdk-slot2
ci-gradle
Enter fullscreen mode Exit fullscreen mode

The container is disposable, the cache is permanent. That isn't an inconsistency: the
thing that should be throwaway is the environment, the thing worth keeping is the
downloaded bytes. The price shows up on disk, and I'll get to that at the end.

The decision at 00:53

The log lets me follow the life of a single slot. The third ARM slot, arm-slot3,
opened its first runner at 16:41:55 on 26 September and its last at 00:53:07 on
27 September. It was born 120 times in between. Then it never appeared again.

The timestamp on the launchd configuration file explains why:

2026-09-27 00:53:35  com.itwise.ci-runners.plist      (CI_ARM_SLOTS: 3 → 2)
2026-09-27 01:00:39  supervisor.sh
Enter fullscreen mode Exit fullscreen mode

That slot's last runner started at 00:53:07, and twenty-eight seconds later the
configuration changed. I like how talkative file timestamps are; they don't let you
romanticize your own decisions after the fact.

Here's what was happening that night: a Flutter build kept falling over. The run that
started at 00:40, thirteen minutes before the decision, ended with a TimeoutError.
Forty minutes after I closed the slot, the same job ran again and the log spoke more
plainly:

2026-09-26T22:32:13Z The message received from the daemon indicates that the daemon has disappeared.
2026-09-26T22:32:13Z Daemon pid: 2156
2026-09-26T22:39:06Z Gradle task assembleRelease failed with exit code 1
Enter fullscreen mode Exit fullscreen mode

Gradle's background process vanishes without leaving an error message. In a
memory-capped container that's the classic signature of the cgroup killing it — the
killer doesn't write a polite warning.

To be honest, at 00:53 I had nothing this clear. I had one failing job and a hunch, and
I closed the slot. I only saw the reason the next day, opening the logs and doing the
math. The math turned out to be embarrassingly simple.

A promise of 60 GiB

Every runner container opens with these limits:

docker run -d --rm --cpus 4 --memory 6g ...
Enter fullscreen mode Exit fullscreen mode

How many slots are there? Two x86 and two ARM on the organization side. Six more on
the repository side, across five repositories. Ten concurrent jobs in total, each with
a 6 GiB ceiling: 60 GiB. The entire virtual machine has 14 GiB. In other words, the
memory I promised is more than four times what I actually have — 4.3 times, precisely.

Diagram

Overcommitment isn't inherently bad; every shared system does it. It works everywhere
from airline seats to database connection pools, because not everyone claims their
share at once. What's bad is overcommitting without any give. That's exactly what I
had done, and two measurements rubbed my face in it.

First, there is no swap in the virtual machine:

$ cat /proc/swaps        # (nothing but the header line)
$ grep SwapTotal /proc/meminfo
SwapTotal:             0 kB
Enter fullscreen mode Exit fullscreen mode

Second — and this is the one that bites — the container's cgroup settings. I stepped
into a running runner and looked:

memory.max      = 6442450944      # exactly 6 GiB
memory.high     = max             # i.e. unset
memory.swap.max = 6442450944
cpu.max         = 400000 100000   # 4 cores
Enter fullscreen mode Exit fullscreen mode

Those four lines show where Docker's --memory 6g flag lands on the kernel side. And
what matters is which file it did not land in. The Linux cgroup v2 documentation
defines two separate thresholds. memory.high says that if usage goes over the high
boundary "the processes of the cgroup are throttled and put under heavy reclaim
pressure," and it closes with a clear sentence: "Going over the high limit never
invokes the OOM killer." memory.max is different: "If a cgroup's memory usage reaches
this limit and can't be reduced, the OOM killer is invoked in the cgroup."

Docker's own documentation points to the same place: when the hard limit is exceeded,
"the kernel kills processes in a container."

So my setup had no slowdown layer at all. memory.high is unset and swap is zero. The
kernel does try reclaim first, of course — it drops page cache and salvages what it
can; the documentation says as much, the OOM killer arrives only when usage "can't be
reduced." But once there are no pages left to drop and no swap to fall back on, one
move remains. At that point the system doesn't warn me; it kills the process and leaves
a gap like the one in that Gradle log.

The memory.swap.max line is misleading too: the container is given a 6 GiB swap
budget. Docker states plainly that this budget only applies "if the host has swap
memory configured." On the host it's zero, so that line means nothing.

Ten slots, one network

Memory isn't the only shared resource. I open the slots with --network host, so they
all use the virtual machine's network directly:

$ docker inspect ci-arm-slot1 --format '{{.HostConfig.NetworkMode}}'
host
Enter fullscreen mode Exit fullscreen mode

That was a deliberate call, because on GitHub's own machines jobs reach their services
over 127.0.0.1; I wanted the same behavior so workflows wouldn't act differently in
two places. The price: ten slots share one port space. When I looked at the listening
endpoints during my measurements, I saw this:

127.0.0.1:20100
127.0.0.1:20101
...
127.0.0.1:20110
Enter fullscreen mode Exit fullscreen mode

Those are test services brought up by the jobs running at that moment, all on random
ports. Good news — because if you write a fixed port like ports: ["5432:5432"] in a
workflow, the second job falls over with "port already allocated." On a cloud runner
that failure is impossible; every job has its own machine. When you move to your own
machine, you hand back part of the isolation with your own hands, and you'd better
know which part.

The second sharing trap is in labels. When GitHub routes work it looks for "an online
and idle runner that matches the job's runs-on labels and groups." I have two
separate slot families, and organization variables carry the distinction:

CI_RUNNER     = ["self-hosted","itwise-mac","x64"]
CI_RUNNER_ARM = ["self-hosted","itwise-mac","arm64"]
Enter fullscreen mode Exit fullscreen mode

Drop the architecture label from the list and matching still happens — just in the
wrong slot. A system that silently works wrong costs more than one that loudly stops;
I made that argument in another context when I wrote about
a server that has never been hacked, and it
holds here too.

I left both escape hatches in place deliberately: the label list lives in a variable.
If I empty that variable, everything goes back to GitHub's machines. That's my
one-keystroke retreat when I travel.

The machine's right to sleep

The resource side is solvable with arithmetic. The cost I didn't expect was the other
one.

A CI server doesn't sleep. So my laptop had to stop sleeping too:

$ pmset -g custom | grep -A6 'AC Power' | grep -E '^ (sleep|displaysleep|disksleep)'
 displaysleep         30
 sleep                0
 disksleep            10
$ pmset -g custom | grep -A6 'Battery Power' | grep -E '^ sleep'
 sleep                1
Enter fullscreen mode Exit fullscreen mode

Idle sleep is off while plugged in — the machine has been up for ten days and five
hours. These are the same settings described in Apple's
sleep and wake documentation,
except in my case they serve a single purpose: don't make queued jobs wait for me. In the
battery profile sleep stayed enabled, so CI stops when the machine is unplugged.
That's not an oversight but a choice: I don't want my laptop heating up and burning its
battery on a Docker build while I'm traveling.

Asked about the active state, pmset -g answered with
sleep 0 (sleep prevented by powerd, Claude). The parentheses stopped me. When I asked
who was preventing sleep, the list came back like this:

pid 94593(Claude): NoIdleSleepAssertion named: "Electron"  08:52:39
pid 348(powerd):   PreventUserIdleSystemSleep  "Prevent sleep while display is on"
Enter fullscreen mode Exit fullscreen mode

For eight hours and fifty-two minutes an application has been keeping the machine
awake, and that application isn't CI. When I declared my own machine "production," I
hadn't noticed that everything else running on it gets promoted to production
seriousness too. Sleep is no longer one process's decision; it's a joint one.

Where the boundary went

Here's the machine's state while I write this: load average 11.10 across fourteen
cores. On the organization side, all four runners are busy — the six
repository-level slots don't appear in that list, they're counted separately. 7 GiB of
the virtual machine's 14 GiB is in use, and on the macOS side 42 percent of memory is
free.

There's one more number, and it's the best evidence for my argument: 11,392,428 pages
have been written to disk since boot. At four kilobytes per page that's 43.5 GiB.
There is no swap in the virtual machine, yet macOS itself has paged out 43 GiB in ten
days. Memory pressure doesn't disappear; only the payer changes.

In the docker ps output, next to my blog's runner, sit test services belonging to
other projects: a PostgreSQL 17.5, a ClickHouse 25.3. They're temporary containers
brought up for tests in repositories I own, and they'll be gone in ten minutes. But
right now, this minute, they live in my laptop's memory.

On disk the story is more interesting. The cleanup cycle normally runs every two hours.
But between 03:25 and 04:20 the log holds fifty-eight cleanup lines — roughly one every
twenty seconds:

2026-09-27 03:26:36 temizlik (disk %70): /dev/vda1  58G  40G  18G  70% /
2026-09-27 03:26:58 temizlik (disk %71): /dev/vda1  58G  40G  18G  71% /
2026-09-27 03:27:19 temizlik (disk %71): /dev/vda1  58G  41G  18G  71% /
Enter fullscreen mode Exit fullscreen mode

The script sees the threshold breached and cleans, the jobs refill at the same rate, the
script cleans again. Forty-five minutes of equilibrium hovering at 70 percent. The
night's peak reached 84 percent. The disk only really emptied towards morning, as the
job flow thinned out; the line where I see 40 percent is timestamped 12:50 — daytime,
while I was awake.

The first time I read this I was going to describe it as "a machine that cleans itself
at night." On the second reading the story fell apart: the machine didn't clean itself
at night, it raced itself.

Then there's documentation falling behind. The comment line in my repository list still
says "VM 8 CPU/10 GB." The reality is 12 CPUs and 14 GiB. The configuration grew twice
in two days; the line describing it stayed where it was. This is where you can see
that the fastest-rotting thing in your setup isn't the code, it's the rationale you
wrote next to the code.

For anyone about to make their own machine a runner

The checklist I took away from these two days — none of which I discussed when I
compared the cost of self-hosted runners
earlier:

  • Sum the ceiling, then divide. Slot count × per-slot memory limit: how many times over the virtual machine's memory does that go? Don't add a slot before you know the answer. Mine was four times over.
  • Leave some give. If swap is zero and memory.high is unset, pressure reaches you as death, not as slowness. On the Docker side the equivalent is a soft threshold like --memory-reservation 4g, used alongside the hard --memory. Don't add a slot without knowing which of the two applies.
  • Don't share your home folder. mounts: null. Your keys shouldn't sit where CI can see them.
  • Separate battery from mains behavior. Awake on power, asleep on battery is a reasonable middle ground. Jobs queue while the machine is off; can you live with that?
  • Don't add public repositories. No exceptions.
  • When you change a setting, change the comment next to it. I didn't.

Not free, unmeasured

I'm not reversing the decision. I'm not paying for minutes, builds are fast, the setup
works, and not one of those 849 runners left a persistent registration behind. But I've
stopped using the word "free."

A cloud bill is a real number and it's shown to you every month. When you move to your
own machine that number doesn't disappear, it just leaves the currency: 58 percent of
memory, 12 of the cores, ten days of uninterrupted wakefulness, 43.5 GiB paged out to
disk, and a decision made at 00:53. None of these go into a table, but together they're
a price.

Maybe that's the real lesson: moving infrastructure home doesn't make it free, it makes
it unmeasured. Once the invoicing stops, you have to take over the measuring
yourself — otherwise the first bill gets issued to you at midnight, by the OOM killer,
on top of a dead build.

Official Sources

Top comments (0)