Dedicated VM for your GitLab Runner. Install directly or run in a container?
Both work. Containers avoid dependency conflicts, upgrade headaches, and environment drift.
This guide sets up GitLab Runner as a Docker container on Debian 13 (Trixie) and compares it with bare-metal.
Bare-Metal vs. Containerized Runner: Why It Matters
Two approaches. One is clearly better.
Approach 1: Install GitLab Runner Directly on the VM
Install the gitlab-runner package, register it, jobs run on the host.
# The bare-metal approach
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
What happens over time:
- CI needs Node.js 18, then 20, then 22 — all on one host
- One project needs Python 3.11, another needs 3.12
- A global npm package conflicts with another project
- A system library upgrade breaks three pipelines
- Runner upgrades require coordination with running jobs
- The host becomes a snowflake nobody dares touch
Approach 2: GitLab Runner as a Container (with Docker Executor)
Runner lives in a container. Each CI job gets its own isolated container. Only Docker touches the host.
# The containerized approach
docker run -d --name gitlab-runner --restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:v18.9.0
What happens over time:
- Each job gets a fresh, isolated environment
- Node.js 18 and 22 run side-by-side, no conflicts
- A bad job can’t pollute the host or other jobs
- Runner upgrades are a single
docker pull+ container restart - The host stays clean and reproducible
Side-by-Side Comparison
| Aspect | Bare-Metal Install | Containerized Runner |
|---|---|---|
| Installation | Package repo + apt install | Docker run (one command) |
| Job isolation | Shared host filesystem | Each job in its own container |
| Dependency conflicts | Accumulate over time | Impossible - fresh container per job |
| Runner upgrades | apt upgrade, pray nothing breaks | docker pull + restart container |
| Host contamination | Jobs can modify the host OS | Host stays clean |
| Rollback | Difficult, manual | Change the image tag |
| Multi-version support | Manual version managers (nvm, pyenv) | Just use different Docker images |
| Reproducibility | Drift over time (“works on runner”) | Identical environment every run |
| Resource cleanup | Manual, jobs leave artifacts behind | Containers auto-removed after jobs |
| Security | Jobs run with runner user privileges | Jobs isolated in containers |
Bare-metal works fine on day one. By day 30, the host has random packages, conflicting versions, and leftover artifacts. By day 90, nobody knows what’s installed and nobody wants to touch it.
VM gives you resource control. Container gives you isolation. Together: best of both worlds.
Prerequisites
You need:
- Debian 13 (Trixie) VM with root/sudo access
- Network connectivity to your GitLab instance
- A GitLab project or group for runner registration
- A runner registration token (GitLab > Settings > CI/CD > Runners)
Our setup:
| Component | Details |
|---|---|
| VM OS | Debian 13 (Trixie) |
| VM Specs | 4 vCPUs, 8GB RAM, 80GB SSD |
| VM IP | 10.10.1.22 |
| GitLab Instance | Self-hosted at 10.10.1.21 |
Step 1: Prepare the Debian 13 Host
Update the system.
sudo apt update && sudo apt upgrade -y
Install essentials:
sudo apt install -y ca-certificates curl gnupg lsb-release
Step 2: Install Docker Engine
Add Docker’s GPG key and repository:
# Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add the repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Install Docker:
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Verify:
sudo systemctl status docker
sudo docker run --rm hello-world
Optional — add your user to the docker group:
sudo usermod -aG docker $USER
After adding your user to the docker group, you need to log out and back in for the group change to take effect.
Step 3: Create the Runner Configuration Directory
Config lives outside the container so it survives restarts and upgrades.
sudo mkdir -p /srv/gitlab-runner/config
Step 4: Start GitLab Runner as a Container
sudo docker run -d \
--name gitlab-runner \
--restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:v18.9.0
Always use a specific version tag (e.g., v18.9.0) instead of latest. The runner version should match your GitLab instance’s major.minor version. Check Docker Hub tags for available versions. Avoid bleeding - it’s the nightly unstable build.
Flag breakdown:
-d- Run detached--name gitlab-runner- Name the container--restart always- Auto-restart on crash or reboot-v /srv/gitlab-runner/config:/etc/gitlab-runner- Persist config-v /var/run/docker.sock:/var/run/docker.sock- Let the runner spawn sibling containers for CI jobs
Confirm it’s running:
sudo docker ps --filter name=gitlab-runner
Expected output:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bc28bc296cb7 gitlab/gitlab-runner:v18.9.0 "/usr/bin/dumb-init …" 41 seconds ago Up 37 seconds gitlab-runner
Step 5: Register the Runner
sudo docker exec -it gitlab-runner gitlab-runner register
Interactive flow:
Runtime platform arch=amd64 os=linux pid=18 revision=07e534ba version=18.9.0
Running in system-mode.
Enter the GitLab instance URL (for example, https://gitlab.com/):
http://10.10.1.21
Enter the registration token:
glrt-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Verifying runner... is valid runner=e1P1g1DVP
Enter a name for the runner. This is stored only in the local config.toml file:
[bc28bc296cb7]: docker-runner-01
Enter an executor: custom, shell, parallels, virtualbox, docker, instance, ssh, docker-windows, docker+machine, kubernetes, docker-autoscaler:
docker
Enter the default Docker image (for example, ruby:3.3):
node:22-alpine
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!
Configuration (with the authentication token) was saved in "/etc/gitlab-runner/config.toml"
| Prompt | What to enter |
|---|---|
| GitLab instance URL | Your GitLab server URL (use internal IP if self-hosted) |
| Registration token | From GitLab > Settings > CI/CD > Runners |
| Runner name | A descriptive name, e.g. docker-runner-01 |
| Executor | docker |
| Default Docker image | Fallback image for jobs, e.g. node:22-alpine |
If your GitLab is on the same network, use the internal IP (http://10.10.1.21/) instead of the public domain. This avoids routing through reverse proxies and dramatically speeds up git clone operations. See our Runner Performance Optimization post for more details.
Non-interactive alternative:
sudo docker exec gitlab-runner gitlab-runner register \
--non-interactive \
--url "http://10.10.1.21/" \
--token "glrt-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
--description "docker-runner-01" \
--executor "docker" \
--docker-image "node:22-alpine"
Step 6: Tune the Runner Configuration
Config is at /srv/gitlab-runner/config/config.toml on the host.
sudo nano /srv/gitlab-runner/config/config.toml
A well-tuned config:
concurrent = 4
check_interval = 3
[[runners]]
name = "docker-runner-01"
url = "http://192.168.1.10/"
clone_url = "http://192.168.1.10/"
token = "your-runner-token"
executor = "docker"
[runners.docker]
image = "node:22-alpine"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
shm_size = 0
pull_policy = ["if-not-present"]
volumes = ["/cache"]
extra_hosts = ["gitlab.example.com:192.168.1.10"]
[runners.cache]
Type = "s3"
Shared = true
Key settings:
concurrent = 4- Max parallel jobs. Size to your VMcheck_interval = 3- Poll GitLab every 3 secondsclone_url- Internal IP for git clones. Big speed winpull_policy = ["if-not-present"]- Skip pulling images already cached locallyextra_hosts- Map GitLab domain to internal IP inside job containersvolumes = ["/cache"]- Enable job caching
Restart to apply:
sudo docker restart gitlab-runner
Step 7: Verify the Runner in GitLab
- Go to Settings > CI/CD > Runners
- Your runner should show a green circle under “Available specific runners”
Test with a simple .gitlab-ci.yml:
test-runner:
tags:
- docker
script:
- echo "Runner is working!"
- node --version
- cat /etc/os-release
Managing the Runner
Viewing Runner Logs
# Live logs
sudo docker logs -f gitlab-runner
# Last 50 lines
sudo docker logs --tail 50 gitlab-runner
Upgrading the Runner
This is where containers shine.
Bare-metal upgrade (scary):
sudo apt update
sudo apt install gitlab-runner
# Hope nothing breaks
# Hope running jobs finish gracefully
# Hope config is still compatible
Container upgrade (safe):
# Stop the old container
sudo docker stop gitlab-runner
sudo docker rm gitlab-runner
# Pull the new version
sudo docker pull gitlab/gitlab-runner:v18.10.0
# Start with the same config
sudo docker run -d \
--name gitlab-runner \
--restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:v18.10.0
Config stays in /srv/gitlab-runner/config/. Bad upgrade? Switch back to the old tag.
Stopping and Starting
# Gracefully stop (finishes running jobs)
sudo docker stop gitlab-runner
# Start again
sudo docker start gitlab-runner
# Restart
sudo docker restart gitlab-runner
Cleaning Up Docker Resources
Job containers and images pile up. Schedule a cleanup:
# Remove stopped containers, unused images, and build cache
sudo docker system prune -af --filter "until=72h"
Daily cron:
echo "0 3 * * * root docker system prune -af --filter 'until=72h'" | sudo tee /etc/cron.d/docker-cleanup
Troubleshooting
Runner shows as offline in GitLab
# Check if the container is running
sudo docker ps --filter name=gitlab-runner
# Check runner logs for connection errors
sudo docker logs --tail 20 gitlab-runner
# Verify network connectivity to GitLab
sudo docker exec gitlab-runner ping -c 3 192.168.1.10
Jobs stuck in “Pending”
Common causes:
- Tag mismatch - Job tags don’t match runner tags
- Runner paused - Check runner status in GitLab UI
- Concurrent limit reached - All job slots full
# Check how many jobs are currently running
sudo docker exec gitlab-runner gitlab-runner list
Permission denied on Docker socket
If jobs fail with Cannot connect to the Docker daemon:
# Check socket permissions
ls -la /var/run/docker.sock
# The runner container needs access to the socket
# This is handled by the -v /var/run/docker.sock mount
Slow git clone operations
Set clone_url to the internal IP:
[[runners]]
url = "http://192.168.1.10/"
clone_url = "http://192.168.1.10/"
See GitLab Runner Performance Optimization for more tips.
Summary
VM + containerized runner gives you:
- Clean host - Only Docker on the VM, no dependency mess
- Isolated jobs - Fresh container per CI job
- Easy upgrades - Pull new image, restart, done
- Easy rollback - Point to the previous image tag
- Reproducibility - Same environment every run
- Resource control - Dedicated CPU, RAM, and disk from the VM
Bare-metal works for quick experiments. For real workloads, 10 minutes of Docker setup pays for itself in a week.
Once your runner is up and running, check out GitLab Runner Performance Optimization to squeeze maximum speed out of your pipelines with caching, direct connections, and Docker executor tuning.