Git Runners on a Budget VPS Without Sprawl

TL;DR / Summary: A single modest VPS can host self-hosted CI runners for a small team if you cap concurrency, isolate jobs, and treat the box as cattle with snapshots. The win is predictable build minutes without renting a constellation of idle machines.

Why a dedicated runner VPS still makes sense

Shared SaaS runners are easy until minutes spike or you need private network access to internal packages. A virtual private server sitting next to your staging network can clone, build, and deploy without opening half the company to the public internet.

The failure mode is sprawl: one runner becomes four “temporary” boxes that nobody patches. Start with one VPS and graduate only when the queue proves it.

Size for the heaviest honest job

List your three heaviest pipelines. Note peak RAM and CPU. Pick a VPS that covers the heaviest job with about 30% headroom, not the sum of all jobs at once.

If two heavy jobs must run together daily, either raise the box one tier or serialize them in the workflow. Parallelism is a product decision with a power bill.

  • Node/frontend builds: often 2–4 vCPU, 4–8 GB
  • Container image builds: favor fast disk over extra idle RAM
  • Native compiles: more CPU, consider ccache on a persistent volume

Disk is the quiet bottleneck

Docker layers and language caches fill disks overnight. Start with generous SSD and a scheduled prune, or you will debug “no space” as a flaky test.

One OS user per runner flavor

Install the runner agent under a locked-down user. If you must support a second toolchain that needs broader privileges, prefer a second user over root. Root runners turn a malicious dependency into a bad week.

Keep secrets in the CI platform’s store, not in world-readable dotenv files on disk.

Concurrency caps that protect interactive neighbors

If the same VPS also hosts a tiny staging site, set the runner concurrency to 1. CI noise that starves staging creates false incident reports.

Pure runner boxes can allow 2 concurrent jobs once metrics say CPU idle time remains healthy during the overlap window.

Throughput without headroom is just a slower outage.

Ephemeral workspaces, persistent caches

Wipe workspace directories after jobs. Persist only named caches you understand: dependency caches, buildkit state, compiler caches. Unbounded caches become a second database you never backed up.

Label cache directories with the stack name so a future teardown does not delete the wrong treasure.

Network posture for private packages

Prefer pulling private modules over a reverse proxy or VPN mesh you already trust. Do not sprinkle long-lived cloud keys on the runner if short-lived OIDC tokens are available from your provider.

Allowlist outbound domains where practical. It will not stop every supply-chain attack, but it blocks the laziest exfiltration paths.

Snapshots before runner version bumps

Runner agents update. Take a provider snapshot before big version jumps. A bad update on Friday should be a restore, not a rebuild-from-memory exercise.

Document the exact image, runner version, and bootstrap script in a single README on the box and in git.

Observability without a second Prometheus planet

Ship load average, disk percent, and failed job counts to whatever chat you already use. You do not need a full observability suite to know the disk hit 95%.

When jobs queue longer than a human coffee break, that is your signal to scale vertically or split queues — not to ignore the chart.

Comparing plans and regions for that next vertical step is easier when you keep a sober shortlist on virtual servers and VPS options rather than clicking mystery coupons.

Warm caches versus cleanroom jobs

Some pipelines must run clean for supply-chain assurance; others benefit from warm dependency caches. Label runner tags warm and clean even on one VPS by using separate runner registrations with different work directories. Mixing both modes in one workspace invites “it passed locally on the runner” mysteries.

Document which jobs may use warm caches. Security reviews should not discover that fact accidentally.

Time zones and quiet hours

Schedule heavy image builds in off-peak hours for the humans who SSH in for emergencies. A runner that saturates CPU at 10 a.m. local makes every “quick hotfix” feel cursed.

If your team is distributed, pick quiet hours relative to the on-call region, not the cloud region’s marketing brochure.

Artifact retention on the runner disk

CI artifacts left on the runner “for debugging” become a second object store. Push artifacts to your real storage backend and delete local copies after upload. Set a nightly find-and-delete for workspaces older than 48 hours as a seatbelt.

Exceptions for ongoing incidents should expire with a calendar reminder, not eternal mercy.

If a job must keep a local core dump for analysis, park it under a dated incident folder with an owner name in the path. Nameless dumps are how disks fill with mysteries nobody claims.

Teardown criteria written in advance

Write the conditions that retire the VPS: team moves fully to SaaS minutes, or build needs exceed one machine’s honest capacity. Predetermined exits prevent zombie servers.

A budget runner VPS is a tool with a lifecycle. Treat it that way and it stays cheaper than the sprawl it was meant to prevent.

Disk pressure from cached dependencies

Budget runners fill disks with language caches faster than with git objects. Cap cache directories, prune weekly, and alert at seventy percent full—not at ninety when builds already fail mid-job.

Separate build workspaces per repo when two projects fight over the same global cache. One noisy monorepo should not evict another team’s warm layers.