From Shared Minutes to a Laptop Under the Desk
How a GitLab CI cost problem moved through three different answers — shared runners, autoscaled AWS runners, and a home-laptop runner — and why that last one only works because of a tag nobody notices until they forget it.
The short version: this site's pipeline now runs on a docker-executor GitLab runner on a laptop at home, not on GitLab.com's shared runners — and it only got there because the shared-runner bill for a different project stopped being funny.
Where the problem started
SMAIL — a serverless email platform I built for goodintechnology.com — grew an Android client. Between Android builds, automated tests, linting, and QA pipelines, that one project started chewing through GitLab shared-runner minutes fast, at the same time I had PoolCzar, SouredDough, and goodintechnology.com itself all building in parallel. Buying more shared-runner minutes was becoming a recurring line item, not a one-time top-up.
The AWS detour
The first fix was dedicated GitLab runners on an AWS Auto Scaling Group, with CI configuration routing the heaviest jobs — Android builds especially — to larger EC2 capacity only when a job actually needed it. It worked, and it wasn't even SMAIL-specific: when SouredDough separately ran out of its own shared-runner credits, I pointed its pipeline at the same EC2-backed runners and unblocked it without standing up anything new.
It also created a second cost curve. High-memory EC2 instances aren't free just because they're mine instead of GitLab's, and paying for large-memory capacity that sits idle between builds is its own kind of waste — just a more private one.
Landing on local hardware
For the biggest, most memory-hungry builds, the answer turned out to be
a laptop I already owned. No idle-capacity cost, no per-minute bill —
just electricity and the machine's own uptime. That's the runner behind
this site's pipeline today: registered once at the GitLab group level
(gts3351717), so every project under that group can use it without a
separate registration per repo.
Group-level registration is also where the part worth writing down
starts. A shared laptop-backed runner is available to every project in
the group the moment it's registered — including projects that never
asked for it and jobs I didn't mean to route there. GitLab's answer to
that is tags: a runner can be restricted to only pick up jobs carrying
one of its tags, and separately configured to refuse untagged jobs
altogether. This runner has exactly one tag (home) and "run untagged
jobs" turned off, so a project only lands on it if its .gitlab-ci.yml
opts in explicitly:
build-nextjs:
image: node:20-alpine
stage: build
tags: ["home"]
script:
- npm ci
- npm run build
Without that line, the job simply isn't eligible for this runner — it falls back to whatever else is available (GitLab.com's shared runners, in this project's case). Nothing routes there by default, which is the point: a runner that lives on hardware I might close the lid on shouldn't be a silent dependency for pipelines that never opted into depending on it.
What this doesn't solve
It's still one machine. If the laptop is asleep, closed, or off the
network, every home-tagged job across every project in the group
queues until it isn't — there's no failover runner behind it the way an
Auto Scaling Group provides one automatically. That's a real trade-off
against the AWS setup, not a hidden one, and it's the reason this
runner is a good fit for a personal-project group's non-urgent CI and a
bad fit for anything that needs to deploy on a schedule I don't
control.
The actual lesson
None of these three setups — shared, AWS-hosted, laptop-hosted — was wrong when I picked it; each matched a workload and a budget that existed at the time. Shared runners were right until multiple projects built in parallel. AWS runners were right until the idle high-memory cost stopped paying for itself. A laptop is right for now, tagged narrowly enough that adopting it for a new project is a one-line, reversible decision instead of an inherited one.