Skip to content

guide

Install GitLab Runner with the Docker executor

One server, one runner process, several jobs in clean containers. This guide covers the install, the newer token-based registration, and the config.toml settings that decide whether the box stays healthy after a month of builds.

Last updated

Who this is for

Anyone running GitLab CI, whether on gitlab.com or a self-managed instance, who wants jobs on a machine with persistent NVMe storage and fixed monthly cost. It suits private projects and trusted contributors. It is a poor fit for public projects accepting merge requests from forks unless you add the isolation described below.

Start from the Debian 12 or Ubuntu 24.04 image with a sudo user. The commands are identical on both.

Step 1: install Docker first

The Docker executor talks to a local Docker Engine. Install it from Docker's own repository, as set out in the Docker install guide, and check it works:

sudo docker run --rm hello-world

Add your log rotation settings to /etc/docker/daemon.json now. CI containers are short-lived, but the runner's helper containers and any long builds still write logs.

Step 2: add GitLab's repository and install

GitLab publishes a script that adds its signed apt repository. Download it, read it, then run it:

curl -fsSLo /tmp/runner-repo.sh \
  https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh
less /tmp/runner-repo.sh
sudo bash /tmp/runner-repo.sh
sudo apt install -y gitlab-runner
gitlab-runner --version

The package creates a gitlab-runner system user and an enabled systemd service. With the Docker executor, jobs run inside containers rather than as that user, so it does not need to be in the docker group; the service itself runs as root and talks to the socket directly.

How does registration work with glrt- tokens?

You create the runner in the GitLab UI first, choosing its tags and scope there, and GitLab shows a one-time authentication token starting with glrt-. You then pass that token and the server URL to gitlab-runner register. The older shared registration tokens are deprecated, work only if an admin re-enables them, and are scheduled for removal in GitLab 20.0.

Where to create it depends on scope:

  • Project runner: project, Settings, CI/CD, expand Runners, Create project runner.
  • Group runner: group, Build, Runners, Create group runner.
  • Instance runner (self-managed only): Admin, CI/CD, Runners, Create instance runner.

Set tags such as london and docker, tick "Run untagged jobs" only if you want this box to take everything, then create it and copy the token. Register on the server:

sudo gitlab-runner register --non-interactive \
  --url https://gitlab.example.com \
  --token glrt-xxxxxxxxxxxxxxxxxxxx \
  --executor docker \
  --docker-image debian:12-slim \
  --description lon-glr-01

Use https://gitlab.com as the URL if you are on the hosted service. The runner shows as online in the same UI screen within a few seconds. The --docker-image value is only the default for jobs that don't name an image:.

Step 3: tune config.toml

Registration writes /etc/gitlab-runner/config.toml. The defaults allow one job at a time and put no limits on containers. A version sized for a four-vCPU, 12 GB server looks like this:

concurrent = 3
check_interval = 3

[[runners]]
  name = "lon-glr-01"
  url = "https://gitlab.example.com"
  token = "glrt-xxxxxxxxxxxxxxxxxxxx"
  executor = "docker"
  limit = 3
  request_concurrency = 2
  [runners.docker]
    image = "debian:12-slim"
    privileged = false
    cpus = "2"
    memory = "3g"
    memory_swap = "3g"
    shm_size = 268435456
    pull_policy = ["if-not-present", "always"]
    volumes = ["/cache"]
    disable_cache = false

What each line is doing:

  • concurrent is the global ceiling across every [[runners]] block in the file. limit caps one block. With a single block, keep them equal.
  • cpus and memory cap each job container. Three jobs at 3 GB leaves about 3 GB for the host, Docker and page cache. Setting memory_swap equal to memory stops a job swapping itself into a crawl.
  • pull_policy tries the local image first and falls back to pulling. That keeps builds fast and avoids registry rate limits, but means a moved tag is not picked up until you pull or prune.
  • volumes = ["/cache"] gives each project a Docker volume for the cache: keyword in your .gitlab-ci.yml. It lives on local NVMe and persists between jobs.

The runner notices edits to config.toml and reloads most settings on its own. Restart it anyway after changing executor settings: sudo systemctl restart gitlab-runner.

Using the cache from a pipeline

build:
  image: node:24
  tags: [london, docker]
  cache:
    key:
      files: [package-lock.json]
    paths: [.npm/]
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run build

The cache key changes only when the lockfile does, so most jobs restore a warm .npm directory from the local volume in under a second. No S3-style distributed cache is needed while all jobs land on one server.

How do I stop the disk filling up?

Prune on a schedule. Every job leaves stopped helper containers, cache volumes accumulate per project and branch, and pulled images are never removed by the runner itself. GitLab ships a cleanup script for this, and a weekly run combined with an image prune keeps a typical server well under its disk size.

sudo /usr/share/gitlab-runner/clear-docker-cache prune-volumes
sudo docker image prune -af --filter "until=240h"

Wrap both in a systemd timer for Sunday night, when nothing is queued. The first command removes only containers and volumes the runner created, so it is safe on a box that also runs other containers. Check progress with docker system df.

Security notes

  • Leave privileged = false unless a trusted project needs Docker-in-Docker. If it does, register a second runner with its own tag and the privileged flag, and only let protected branches use it.
  • Tick "Protected" when creating a runner that holds deploy credentials. It will then only pick up jobs from protected branches and tags.
  • Keep CI variables in GitLab, masked and protected, not in files on the host.
  • The runner polls the GitLab server outbound over HTTPS. No inbound ports are required, so use the portal firewall to allow only SSH from known addresses.

Self-managed GitLab on another server

If your GitLab instance also runs on FastNode, keep the runner on a separate server. A heavy build should never starve the web UI or Sidekiq of memory. Both sit in London, so clones and artefact uploads between them are short trips over the public network on unmetered 1 Gbps ports. There is no private network between servers, so leave TLS on and point the runner at the public HTTPS URL. The self-hosted Git page covers sizing the GitLab side.

Sizing

A runner doing tests for one or two projects fits Boost with concurrent = 1. Docker image builds want Turbo and its 80 GB disk. For three parallel jobs with real memory limits, Nitro at $15 a month (billed in US dollars) gives 12 GB of RAM to share out. If you outgrow it, email support to move up a plan.

Questions

Does this work with gitlab.com as well as a self-managed instance?

Yes. The only difference is the URL you pass to register. For gitlab.com use https://gitlab.com; for your own server use its address. Self-managed GitLab needs version 16.0 or later for the glrt- token workflow.

Where do runner tags go now?

With authentication tokens, tags, the protected flag and whether untagged jobs run are set when you create the runner in the GitLab UI. The register command ignores them, and you can change them later in the same screen.

Should I mount the Docker socket into jobs?

Only for trusted projects. Mounting /var/run/docker.sock or running privileged containers for Docker-in-Docker gives a job control of the host. For image builds from less trusted code, use a daemonless builder such as Kaniko or Buildah.

How do I see why a job failed to start?

Run journalctl -u gitlab-runner -f while retrying the job. Pull errors, permission problems and config parse errors all show there. gitlab-runner verify confirms the token is still accepted by the server.