Self-hosted CI on a VPS is one of those infrastructure investments that quietly pays for itself. GitHub Actions runners cost real money at scale ($0.008/minute on private repos for Linux runners), and the same workload runs faster on a properly sized VPS. This post covers two viable approaches: GitHub Actions self-hosted runners, and GitLab Runner with self-hosted GitLab.
When to self-host CI
Self-hosted CI makes sense when:
- You're spending $50+/month on hosted CI minutes
- Your builds need access to private resources (internal package registries, VPN-only services)
- You have specific hardware needs (GPU for ML tests, ARM64 for cross-compilation)
- You care about build artifacts staying within your infrastructure (compliance/sovereignty)
- You want builds to be faster — VPS-hosted runners are typically 1.5-3× faster than shared cloud runners for compute-bound tasks
It doesn't make sense for: tiny projects (free tier hosted CI is fine), teams without operational capacity to maintain it, or workloads that need ephemeral runners scaled to thousands of concurrent jobs.
Approach 1: GitHub Actions self-hosted runners
If you're already on GitHub, this is the lower-friction option. You keep using GitHub for the workflow definitions and triggers; only the execution moves to your VPS.
Provision a Cloud VPS Advanced (€7.48/mo: 2 GB / 1 vCPU / 60 GB) for light CI, or Professional (€14.48/mo: 4 GB / 2 vCPU / 120 GB) for heavier workloads. SSH in:
# Create dedicated user
sudo adduser --system --group --home /opt/github-runner github-runner
sudo mkdir -p /opt/github-runner
cd /opt/github-runner
# Download runner
sudo -u github-runner curl -o actions-runner-linux-x64-2.319.1.tar.gz -L https://github.com/actions/runner/releases/download/v2.319.1/actions-runner-linux-x64-2.319.1.tar.gz
sudo -u github-runner tar xzf actions-runner-linux-x64-2.319.1.tar.gz
Get a registration token from GitHub: repo → Settings → Actions → Runners → New self-hosted runner. Then on the VPS:
sudo -u github-runner ./config.sh --url https://github.com/your-org/your-repo --token YOUR_TOKEN
sudo ./svc.sh install github-runner
sudo ./svc.sh start
The runner registers with GitHub and starts polling for jobs. Workflows that specify runs-on: self-hosted will execute on your VPS.
Security considerations
Self-hosted runners execute code from any contributor whose PR triggers a workflow. For public repos, this is a serious security issue — restrict runners to specific events:
# In your workflow YAML
on:
pull_request:
types: [closed] # Only run on merged PRs, not all PRs
Or, more robust: use private runners for private repos only. For public repos, use GitHub-hosted runners and accept the cost.
Optimizations
- Persistent caches: npm, pip, cargo caches between builds save significant time. Mount
/opt/github-runner/cacheas a persistent directory used by your workflows. - Multiple runners on one VPS: register the same VPS multiple times with different labels. A 4-vCPU VPS can run 3-4 concurrent jobs comfortably.
- Docker-based isolation: wrap each job in a fresh container so artifacts from one job don't leak into another.
Approach 2: Self-hosted GitLab + GitLab Runner
If you'd rather not depend on GitHub at all, GitLab CE is open-source and self-hostable. The complete stack — git hosting, CI, container registry, package registry, basic project management — runs on a single VPS.
Sizing: GitLab CE wants 4 GB RAM minimum, 8 GB recommended. Plan for Cloud VPS Professional (€14.48/mo) or higher.
# Install
curl -s https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.deb.sh | sudo bash
sudo EXTERNAL_URL="https://gitlab.example.com" apt install gitlab-ce
# Get the auto-generated root password
sudo cat /etc/gitlab/initial_root_password
This installs everything: gitlab-rails, postgres, redis, nginx, sidekiq, and registers them as systemd services. First boot takes 5-10 minutes as it initializes the database.
Install GitLab Runner separately (can be on the same or different VPS):
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt install gitlab-runner
sudo gitlab-runner register
Register interactively, providing your GitLab URL and a registration token (from GitLab Admin → Runners). Choose docker as the executor — Docker-based runners give you clean isolation and arbitrary base images per job.
A typical .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
DOCKER_DRIVER: overlay2
test:
stage: test
image: node:20
cache:
paths:
- node_modules/
script:
- npm ci
- npm test
build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
only:
- main
deploy:
stage: deploy
image: alpine
script:
- apk add openssh-client
- ssh deploy@prod.example.com "docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA && docker compose up -d"
only:
- main
The cost comparison
For a team running ~3000 CI minutes/month:
- GitHub-hosted runners (Linux): $24/mo at $0.008/minute
- FranceVPS Professional self-hosted runner: €14.48/mo, runs unlimited minutes
The break-even is around 1800 minutes/month. Below that, GitHub-hosted is fine. Above it, self-hosted is cheaper. At 10K minutes/month, self-hosted is dramatically cheaper.
Beyond cost, the speed difference matters. Our customers commonly report 30-50% faster builds on self-hosted runners simply because: dedicated cores (no shared CPU), persistent caches between builds (no warm-up cost), faster network to internal package registries.
Where self-hosted CI struggles
Three pain points to be aware of:
- Scaling up: if you suddenly need 50 concurrent jobs, a single VPS can't deliver. Either accept queue time or add VPS instances dynamically (Terraform-driven autoscaling is possible but adds complexity).
- Job isolation: Docker-based runners help, but a malicious job can still find ways to escape. For untrusted code (open-source contributions), use ephemeral VMs not just containers.
- OS heterogeneity: if you need to test on Windows or macOS too, you're back to hosted runners (or significantly more complex self-hosted setups).
For most internal CI workloads, none of these are blockers. The math favors self-hosted, the security concerns are manageable with discipline, and the speed wins are real.