GitLab Runner in a Container on a Dedicated VM: The Right Way to Run CI/CD

· 10 min read
gitlab-for-your-team

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

AspectBare-Metal InstallContainerized Runner
InstallationPackage repo + apt installDocker run (one command)
Job isolationShared host filesystemEach job in its own container
Dependency conflictsAccumulate over timeImpossible - fresh container per job
Runner upgradesapt upgrade, pray nothing breaksdocker pull + restart container
Host contaminationJobs can modify the host OSHost stays clean
RollbackDifficult, manualChange the image tag
Multi-version supportManual version managers (nvm, pyenv)Just use different Docker images
ReproducibilityDrift over time (“works on runner”)Identical environment every run
Resource cleanupManual, jobs leave artifacts behindContainers auto-removed after jobs
SecurityJobs run with runner user privilegesJobs isolated in containers
The Real Problem with Bare-Metal

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:

ComponentDetails
VM OSDebian 13 (Trixie)
VM Specs4 vCPUs, 8GB RAM, 80GB SSD
VM IP10.10.1.22
GitLab InstanceSelf-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
Log Out and Back In

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
💡 Pin Your Runner Version

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"
PromptWhat to enter
GitLab instance URLYour GitLab server URL (use internal IP if self-hosted)
Registration tokenFrom GitLab > Settings > CI/CD > Runners
Runner nameA descriptive name, e.g. docker-runner-01
Executordocker
Default Docker imageFallback image for jobs, e.g. node:22-alpine
💡 Use Internal IP for Self-Hosted GitLab

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 VM
  • check_interval = 3 - Poll GitLab every 3 seconds
  • clone_url - Internal IP for git clones. Big speed win
  • pull_policy = ["if-not-present"] - Skip pulling images already cached locally
  • extra_hosts - Map GitLab domain to internal IP inside job containers
  • volumes = ["/cache"] - Enable job caching

Restart to apply:

sudo docker restart gitlab-runner

Step 7: Verify the Runner in GitLab

  1. Go to Settings > CI/CD > Runners
  2. 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:

  1. Tag mismatch - Job tags don’t match runner tags
  2. Runner paused - Check runner status in GitLab UI
  3. 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.

What's Next

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.