−50% on all plans · starting at €2.48/mo · Blog·Docs·Sales

Building a CI/CD pipeline on a VPS

GitLab Runner, GitHub Actions self-hosted, and why running your own CI saves both money and minutes.

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:

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

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:

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:

  1. 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).
  2. 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.
  3. 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.


Related articles

Try FranceVPS today

14-day money-back guarantee. No card required to explore. Sovereign French infrastructure.