Skip to content

Self-Hosted GitLab Runners: Install, Caching, and Scaling

Run GitLab CI jobs on your own Linux hosts: install and register gitlab-runner, pick an executor, tune config.toml, share caches over S3, and autoscale on demand.

●By 11 min read

GitLab's hosted runners are convenient until they are not: the job needs to reach a database on a private network, builds are slow because every job starts with a cold cache, or the compute-minutes bill keeps climbing. A self-hosted runner fixes all three. It is a single Go binary that polls GitLab for jobs and runs them on hardware you control.

This guide covers the parts that matter once you run runners for a team: installing and registering with the current token workflow, choosing an executor, the config.toml settings that control concurrency, making caches survive across machines, and scaling out without hand-managing build VMs.

Install and Register a Runner

GitLab publishes packages for Debian/Ubuntu and RHEL/Fedora. The install creates a gitlab-runner system user and a systemd service that starts immediately:

# Debian / Ubuntu

$ curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash

$ sudo apt install gitlab-runner

# RHEL / Fedora / Rocky / Alma

$ curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash

$ sudo dnf install gitlab-runner

$ systemctl status gitlab-runner --no-pager

● gitlab-runner.service - GitLab Runner

Loaded: loaded (/etc/systemd/system/gitlab-runner.service; enabled; preset: enabled)

Active: active (running) since Fri 2026-10-02 09:12:44 UTC; 5s ago

Since GitLab 16, runners are created in the UI first (Settings > CI/CD > Runners > New project runner, or at group or instance level), where you set tags, protection and the description. GitLab then shows a runner authentication token starting with glrt-, and registration on the host only links the two:

$ sudo gitlab-runner register \

--non-interactive \

--url "https://gitlab.example.com" \

--token "glrt-REDACTED" \

--executor "docker" \

--docker-image "alpine:3.20" \

--description "build-01"

Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!

$ sudo gitlab-runner list

$ sudo gitlab-runner verify

The old registration token is going away β€” the registration-token flow (--registration-token with --tag-list on the command line) is deprecated. With a glrt- token, tags, run-untagged and locked are managed in the UI, not with register flags.

Config lives in one file β€” runners registered with sudo write to /etc/gitlab-runner/config.toml. The service watches it and reloads on change, so most edits need no restart.

Choose an Executor

The executor decides where a job's script runs. It is the most important choice you make, because it sets both the isolation between jobs and how much state leaks from one job to the next.

ExecutorWhere jobs runGood forWatch out for
shellDirectly on the host as the gitlab-runner userDeploy jobs that need host tools, quick setupsNo isolation; jobs share files and installed tools
dockerA fresh container per job on one hostMost builds and testsprivileged = true gives jobs root on the host
docker-autoscalerContainers on cloud instances created on demandBursty workloads, cost controlNeeds a cloud autoscaling group and a fleeting plugin
kubernetesA pod per job in a clusterTeams already running KubernetesPod resource requests and cache setup take tuning
instanceDirectly on autoscaled VMs, one job per VMJobs that need a full machineSlower start; VM image management
sshOn a remote host over SSHLegacy targetsNo container isolation, limited features

Default to docker β€” each job starts clean from the image named in the job, so builds are reproducible and one job cannot break the next. Generate a solid build image with the Dockerfile generator and pin it in .gitlab-ci.yml.

Never share a privileged runner β€” Docker-in-Docker needs privileged = true, and a privileged container can mount the host filesystem. Keep such runners project-specific and protected, or build images with rootless tools like BuildKit rootless, Buildah or Kaniko instead.

docker+machine is end-of-life β€” the old Docker Machine autoscaler is deprecated in favour of docker-autoscaler and instance executors built on GitLab's fleeting plugins. Plan new setups on the new executors.

Tune config.toml: Concurrency and Isolation

Three numbers control how many jobs run at once, and confusing them is the most common reason a runner sits idle while pipelines queue:

# /etc/gitlab-runner/config.toml

concurrent = 8 # global cap: total jobs across ALL [[runners]] entries in this file

check_interval = 3 # seconds between polls for new jobs

[[runners]]

name = "build-01"

url = "https://gitlab.example.com"

token = "glrt-REDACTED"

executor = "docker"

limit = 6 # max jobs for this runner entry (0 = no limit)

request_concurrency = 2 # parallel job requests to GitLab

output_limit = 16384 # job log size cap in KB

[runners.docker]

image = "alpine:3.20"

privileged = false

pull_policy = ["if-not-present"]

volumes = ["/cache"]

cpus = "2"

memory = "4g"

shm_size = 268435456

SettingScopeEffect
concurrentWhole processHard ceiling on running jobs. Defaults to 1, so a new runner runs one job at a time
limitOne [[runners]] entryCaps jobs for that entry; useful when one host serves several projects
request_concurrencyOne [[runners]] entryHow many job requests it makes in parallel; raise it if jobs start slowly under load
cpus / memoryPer job containerStops one heavy job from starving the others on the same host

Size concurrent to the host β€” a rough start is one concurrent job per two vCPUs for compile-heavy work, then adjust from load average and job duration. Too many parallel builds on one disk is slower than fewer.

pull_policy matters for speed and trust β€” if-not-present reuses images already on the host, which is fast but means a job can run an image some other job pulled. On shared runners, use always, or allowed_pull_policies to restrict what jobs may request.

Clean up Docker storage β€” the docker executor leaves cache volumes and images behind. The package ships /usr/share/gitlab-runner/clear-docker-cache; run it weekly from a systemd timer before the disk fills.

sponsored

Caching That Survives Across Machines

GitLab has two mechanisms that look alike. Artifacts pass build outputs to later stages and are stored by GitLab. Cache keeps dependencies between pipelines and is stored by the runner. By default that cache is local to one runner host, so with two runners every job has a coin-flip chance of a cold cache.

# .gitlab-ci.yml

variables:

npm_config_cache: "$CI_PROJECT_DIR/.npm"

default:

image: node:22-bookworm-slim

install:

stage: build

cache:

key:

files:

- package-lock.json

paths:

- .npm/

policy: pull-push

script:

- npm ci --prefer-offline

test:

stage: test

cache:

key:

files:

- package-lock.json

paths:

- .npm/

policy: pull # read-only: never re-upload from test jobs

script:

- npm ci --prefer-offline

- npm test

For a cache that every runner shares, point them all at the same S3-compatible bucket. MinIO or any S3 API works, which keeps the cache inside your network:

[[runners]]

# ...

[runners.cache]

Type = "s3"

Shared = true # share between runners using the same bucket

[runners.cache.s3]

ServerAddress = "minio.internal:9000"

BucketName = "gitlab-runner-cache"

AccessKey = "runner-cache"

SecretKey = "REDACTED"

Insecure = true # plain HTTP; set false behind TLS

cacheartifacts
PurposeSpeed up later pipelinesPass results to later jobs, keep downloads
Stored byRunner (local disk or S3)GitLab
Guaranteed presentNo, a miss is normalYes, until they expire
Typical contents.npm/, .cache/pip, ~/.m2 inside the project dirdist/, test reports, binaries

Cache paths must be inside the project β€” GitLab only caches paths under CI_PROJECT_DIR, which is why the example redirects npm's cache there instead of caching ~/.npm.

Key on the lockfile β€” key:files hashes up to two files into the cache key, so the cache changes exactly when dependencies change. Add fallback_keys so a new branch starts from the default branch's cache instead of empty.

Use pull in fan-out jobs β€” if ten parallel test jobs all upload the same cache with pull-push, you pay ten uploads for one useful write. Let one job write and the rest read.

Scaling Out

One busy runner host is fine until the queue grows. There are three ways to add capacity, in order of operational effort:

More static hosts β€” register the same runner token on several machines, or create separate runners per host with distinct tags. Combine with the shared S3 cache so it does not matter which host picks up a job.

docker-autoscaler β€” a small manager host creates cloud instances through an autoscaling group (AWS, Google Cloud, Azure) and runs Docker jobs on them. Idle capacity is kept warm by policy and scaled to zero off-hours.

Kubernetes executor β€” install the gitlab/gitlab-runner Helm chart and each job becomes a pod. Scaling is the cluster autoscaler's job, which is a good fit if you already operate a cluster.

# docker-autoscaler: manager config for an AWS autoscaling group

$ sudo gitlab-runner fleeting install # downloads the plugin named in config.toml

[[runners]]

name = "autoscale-aws"

url = "https://gitlab.example.com"

token = "glrt-REDACTED"

executor = "docker-autoscaler"

[runners.docker]

image = "alpine:3.20"

[runners.autoscaler]

plugin = "aws"

capacity_per_instance = 1

max_use_count = 1 # one job per instance, then recycle it

max_instances = 20

[runners.autoscaler.plugin_config]

name = "gitlab-runner-asg"

[[runners.autoscaler.policy]]

idle_count = 2

idle_time = "20m0s"

# Kubernetes executor via Helm

$ helm repo add gitlab https://charts.gitlab.io

$ helm install gitlab-runner gitlab/gitlab-runner \

--namespace gitlab-runner --create-namespace \

--set gitlabUrl=https://gitlab.example.com \

--set runnerToken=glrt-REDACTED \

--set rbac.create=true

Scale on metrics, not guesses β€” add listen_address = ":9252" at the top of config.toml to expose Prometheus metrics. gitlab_runner_jobs shows running jobs per runner; compared with concurrent, it tells you whether you are short of capacity or just have slow jobs.

max_use_count = 1 is the safe default β€” a fresh instance per job means nothing from one job can leak into the next, at the cost of boot time. Raise it only for trusted projects.

Infrastructure as code β€” keep the autoscaling group, launch template and bucket in Terraform; the Terraform generator gives a starting point. For the Helm route, the Kubernetes generator helps with namespace quotas.

Keep Runners Secure and Current

A runner executes whatever code is pushed to the projects it serves, with the credentials it is given. Treat it like a build server with production access, because it often is one. Our Docker for Linux admins guide covers the container side of that hardening.

Protect deploy runners β€” mark runners that hold deployment credentials as protected, so they only pick up jobs from protected branches and tags. A merge request from a fork then cannot run on them.

Upgrade with GitLab β€” keep the runner's major.minor version close to your GitLab server's. Pause the runner in the UI and let running jobs finish, then apt upgrade or dnf upgrade gitlab-runner and run gitlab-runner verify.

Frequently Asked Questions

Why does my GitLab runner only run one job at a time?

The global concurrent setting at the top of /etc/gitlab-runner/config.toml defaults to 1. Raise it to the total number of jobs the host should run in parallel, and check that the limit value in each [[runners]] section is 0 or high enough. The runner reloads config.toml automatically.

What is the difference between cache and artifacts in GitLab CI?

Artifacts are job outputs stored by GitLab and reliably passed to later jobs in the pipeline. Cache is a best-effort store for dependencies, kept by the runner on local disk or in S3, used to speed up future pipelines. Never rely on cache for correctness: a cache miss must still produce a working build.

How do I share a cache between multiple GitLab runners?

Configure a distributed cache in each runner's config.toml with [runners.cache] Type = "s3" and Shared = true, all pointing at the same bucket. Any S3-compatible store works, including a self-hosted MinIO, so the cache can stay on your own network.

Which executor should I use for a self-hosted GitLab runner?

Use the docker executor for most builds and tests: each job gets a clean container from a pinned image. Use shell only for jobs that must touch the host, kubernetes if you already run a cluster, and docker-autoscaler when demand is bursty and you want cloud instances created on demand.

Is it safe to run Docker-in-Docker on a GitLab runner?

It requires privileged = true, which gives the job effective root on the runner host. That is acceptable only on a runner dedicated to trusted projects. For shared runners, build images with rootless BuildKit, Buildah or Kaniko, which do not need a privileged container.

sponsored

Related Tools

See also