GitLab CI/CD is a powerful integrated pipeline system, and self-hosted runners give you unlimited build minutes, faster execution, and access to private resources. This guide covers installing, configuring, and optimizing GitLab runners on your VPS.
Installing GitLab Runner
# Add GitLab Runner repository
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt install gitlab-runner
# Or install from binary
sudo curl -L --output /usr/local/bin/gitlab-runner "https://s3.dualstack.us-east-1.amazonaws.com/gitlab-runner-downloads/latest/binaries/gitlab-runner-linux-amd64"
sudo chmod +x /usr/local/bin/gitlab-runner
sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
sudo gitlab-runner start
Registering a Runner
# Get registration token from GitLab:
# Project > Settings > CI/CD > Runners > Expand
# Register with Docker executor (recommended)
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--token "YOUR_REGISTRATION_TOKEN" \
--executor "docker" \
--docker-image "alpine:latest" \
--description "VPS Docker Runner" \
--tag-list "docker,linux,vps" \
--docker-privileged=false \
--docker-volumes "/cache"
Runner Configuration
# /etc/gitlab-runner/config.toml
concurrent = 4 # Max concurrent jobs
check_interval = 3
[[runners]]
name = "VPS Docker Runner"
url = "https://gitlab.com/"
token = "TOKEN"
executor = "docker"
[runners.docker]
image = "alpine:latest"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
shm_size = 0
pull_policy = ["if-not-present"] # Don't pull if image exists locally
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
BucketName = "gitlab-cache"
Insecure = false
Example .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
test:
stage: test
image: node:20-alpine
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
script:
- npm ci
- npm test
tags:
- docker
build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $DOCKER_IMAGE .
- docker push $DOCKER_IMAGE
tags:
- docker
only:
- main
deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
script:
- ssh -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_HOST "docker pull $DOCKER_IMAGE && docker-compose up -d"
tags:
- docker
only:
- main
environment:
name: production
url: https://example.com
Performance Optimization
# Pre-pull common images to avoid download time
docker pull node:20-alpine
docker pull python:3.12-slim
docker pull golang:1.22-alpine
docker pull docker:24
# Use Docker layer caching
# Mount Docker socket for Docker-in-Docker without privileged mode
volumes = ["/var/run/docker.sock:/var/run/docker.sock"]
# Enable distributed cache
# Use S3-compatible storage (MinIO self-hosted) for shared cache between runners
Security
# Never run privileged containers in shared environments
privileged = false
# Use Docker socket binding instead of DinD when possible
# Restrict runner to specific projects using tags
# Protect CI/CD variables
# Settings > CI/CD > Variables > Protected/Masked
# Run runner as non-root
sudo gitlab-runner install --user=gitlab-runner
Monitoring
# Check runner status
sudo gitlab-runner status
sudo gitlab-runner verify
# View job logs
sudo journalctl -u gitlab-runner -f
# Prometheus metrics (enable in config.toml)
listen_address = ":9252"
# Metrics at http://localhost:9252/metrics
Summary
Self-hosted GitLab runners eliminate the per-minute cost of shared runners and provide faster builds with local Docker image caching. The Docker executor provides clean, isolated build environments for each job. Configure caching carefully, pre-pull common images, and set appropriate concurrency limits based on your VPS resources. For teams using GitLab, a dedicated runner VPS typically pays for itself by eliminating shared runner wait times and CI minute charges.