Self Hosted Runner
Operational guide for moving Hackorda's CI/CD off GitHub-hosted runners onto a self-hosted runner — fixed-cost compute instead of per-minute billing.
When to do this
GitHub-hosted runners bill per minute (Linux $0.006/min after the 2,000-minute free tier). That's fine for low, spiky usage. Switch to a self-hosted runner when usage becomes high and sustained — the inflection point is roughly when monthly overage would exceed the cost of a small always-on VM.
| Monthly CI usage | GitHub-hosted cost | Self-hosted (one VM) |
|---|---|---|
| ≤ 2,000 min | $0 (free tier) | not worth it |
| ~5,000 min | ~$18/mo, variable | ~$24/mo fixed — break-even |
| ~20,000 min (10×) | ~$110/mo, variable, ceiling-prone | ~$24–48/mo fixed, no ceiling |
Self-hosted runners currently incur no per-minute charge (a charge was announced for 2026 then postponed — re-check before relying on it long-term). They're also faster: caches live on local disk, no cold runner spin-up.
Security model
A self-hosted runner executes whatever a workflow tells it to. Rules:
- Private repo only. Never attach a self-hosted runner to a public repo — any fork PR could run arbitrary code on your box. Hackorda's repo is private, so all PRs are trusted.
- Do NOT run the runner on the production droplet. A compromised or buggy build must not have a foothold next to prod. Use a separate, dedicated build VM.
- Give the runner box only the access it needs: Docker, outbound network, and the deploy SSH key (already a GitHub secret — the workflow injects it; nothing extra to store on the runner).
Provisioning the build VM
A DigitalOcean droplet is the natural fit (you already deploy there).
- Size: 4 GB / 2 vCPU (
s-2vcpu-4gb,$24/mo) is comfortable for the Next.js Docker build. 8 GB ($48/mo) if builds feel slow or you want headroom for parallel jobs. - OS: Ubuntu 24.04 LTS.
- Region: same as the prod droplet (faster image pull on deploy).
Install prerequisites on the box:
# Docker (the deploy job builds + pushes an image)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER" # re-login after this
# Node 22 (the check/build jobs)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs gitRegistering the runner
- GitHub → repo Settings → Actions → Runners → New self-hosted
runner → Linux x64. GitHub shows a
./config.shcommand with a short-lived registration token. - On the build VM, run the download +
./config.shblock GitHub gave you. When prompted for labels, add a custom one — e.g.hackorda-build— so workflows can target this runner explicitly. - Install it as a service so it survives reboots and runs unattended:
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status # verify it's listeningThe runner now appears under Settings → Actions → Runners as Idle.
Wiring the workflow
In .github/workflows/workflows.yml, change runs-on for the jobs you
want self-hosted. Recommended split:
deploy→ self-hosted. It's the heaviest job (full Docker image build) and the biggest minute consumer. This is the highest-leverage move.check→ leave onubuntu-latest. It's light (lint + typecheck), the 2,000 free minutes cover it easily, and GitHub-hosted keeps PR feedback fast and parallel.build(PR-only full build) → either; move it self-hosted too if PR builds are eating the budget.
deploy:
runs-on: [self-hosted, hackorda-build]
# ...rest unchangedOne self-hosted runner processes jobs serially. For a small team
that's fine. If PR check/build and a deploy start queueing behind
each other, either add a second runner (repeat the registration steps)
or move only deploy self-hosted and leave the rest GitHub-hosted.
Maintenance
- Updates: the runner agent auto-updates by default. Keep the OS
patched (
unattended-upgrades). - Disk: Docker layer cache grows. Add a weekly
docker system prune -af --filter "until=168h"cron so the disk doesn't fill. - If the runner is offline: jobs targeting
self-hostedqueue (they don't fail) until it's back, or until the job timeout. Monitor the runner's status; consider a second runner for redundancy if deploys become business-critical. - Decommissioning:
sudo ./svc.sh stop && sudo ./svc.sh uninstall, then remove the runner in GitHub Settings before destroying the VM.
Cost summary
| Item | Cost |
|---|---|
Build VM (DO s-2vcpu-4gb) | ~$24/mo fixed |
GitHub-hosted minutes still used (light check job) | within the free 2,000/mo |
| Per-minute CI overage | $0 |
A predictable ~$24/mo flat line with no minutes ceiling, replacing a variable bill that hard-stops the whole pipeline when the spending limit is hit.