GitLab Runner Setup
- Pick the executor before you install.
shellfor one host,dockerfor mixed workloads,kubernetesfor fleets. - Never run a deploy job on a shared runner. Ever. Deploy runners are separate, tagged, and
protected. - Tag every runner. Untagged runners silently pick up every untagged job —
run_untagged = falseunless you truly mean it. concurrentis per-process,limitis per-runner block. Confusing these wastes hours.- Upgrade the runner binary at least as often as GitLab; a too-old runner just stops picking up jobs.
- Rotate the registration token and the runner auth token. They are credentials; treat them that way.
Executors: what actually runs the job
| Executor | Use when | Avoid when |
|---|---|---|
shell | You control the host and jobs need direct access to tools installed there (Ansible, Terraform with local state files, a kernel build). | Jobs need isolation between each other. One job can poison the next via /tmp, environment, or half-installed packages. |
docker | Most CI. Each job gets a fresh container, caching works, images are the dependency list. | The job needs kernel modules, /dev/kvm, or privileged raw-disk access you cannot safely give a container. |
docker-machine | You already run it and have not had time to migrate. | Anything new. Deprecated upstream — use the fleeting plugins or Kubernetes instead. |
kubernetes | You already run Kubernetes. One pod per job, bring-your-own image, horizontal scale for free. | No cluster, or your jobs need sustained local disk (use shell on a beefy host). |
ssh / virtualbox / parallels | Niche (signing on a locked-down Mac mini, old macOS builds). | General workloads. They exist; they are not your default. |
[[runners]] block with its own executor.
Installing the runner binary
RHEL / Rocky / Alma (dnf)
curl -fsSL "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
sudo dnf install -y gitlab-runner
sudo systemctl enable --now gitlab-runner
gitlab-runner --version
Debian / Ubuntu (apt)
curl -fsSL "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install -y gitlab-runner
sudo systemctl enable --now gitlab-runner
Container
# Config lives on the host so state survives container restarts.
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:latest
Mounting the host docker socket is only acceptable when every job that runs on this host is trusted. If shared across teams, pick Kubernetes or use rootless DinD (see Gotchas).
Kubernetes (Helm)
helm repo add gitlab https://charts.gitlab.io
helm upgrade --install runner gitlab/gitlab-runner \
-n gitlab-runner --create-namespace \
-f values.yaml
# values.yaml — minimal, production-ready sketch
gitlabUrl: https://gitlab.example.com/
runnerRegistrationToken: "REDACTED" # prefer an authenticated token
concurrent: 20
checkInterval: 10
rbac:
create: true
runners:
config: |
[[runners]]
name = "k8s-default"
executor = "kubernetes"
[runners.kubernetes]
namespace = "{{.Release.Namespace}}"
image = "alpine:3.19"
cpu_request = "200m"
memory_request = "256Mi"
cpu_limit = "2"
memory_limit = "4Gi"
service_account = "gitlab-runner"
helper_image = "registry.gitlab.com/gitlab-org/gitlab-runner/gitlab-runner-helper:alpine-x86_64-latest"
Registering a runner
Registration links a runner process to a GitLab instance, project, or group. GitLab 16+ emits an authentication token (starts with glrt-) per-runner; older GitLab and many docs still mention the registration token (one-shot). The flow is the same.
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.example.com/" \
--token "glrt-xxxxxxxxxxxxxxxxxxxx" \
--executor "docker" \
--description "linux-docker-01" \
--tag-list "docker,linux,build" \
--run-untagged="false" \
--locked="false" \
--access-level="not_protected" \
--docker-image "alpine:3.19" \
--docker-privileged="false"
Interactive is fine for the first runner on a machine you are learning. Automate with --non-interactive once the registration is in Ansible.
Where the token ends up
After successful registration you get a new [[runners]] block in /etc/gitlab-runner/config.toml with a token = line. That token is a credential — protect the file:
sudo chown root:gitlab-runner /etc/gitlab-runner/config.toml
sudo chmod 0640 /etc/gitlab-runner/config.toml
config.toml, section by section
Global settings
concurrent = 10 # max jobs *across all runners on this process*
check_interval = 3 # seconds between GitLab polls
log_level = "info"
log_format = "json" # easier to ship to Loki / Elastic
shutdown_timeout = 120 # grace period for running jobs on SIGTERM
[session_server]
listen_address = "0.0.0.0:8093" # for interactive web terminal
session_timeout = 1800
concurrent is global per runner process. limit (below, per-[[runners]]) caps just that runner. If concurrent = 10 and three runner blocks each have limit = 10, you get 10 jobs total, not 30.
A [[runners]] block, annotated
[[runners]]
name = "linux-docker-01"
url = "https://gitlab.example.com/"
token = "glrt-xxxxxxxxxxxxxxxxxxxx"
executor = "docker"
limit = 8 # max concurrent jobs on THIS runner
request_concurrency = 2 # pending-job fetches in flight
environment = [ # envs exported to every job
"DOCKER_DRIVER=overlay2",
"FF_USE_FASTZIP=true"
]
pre_build_script = """
set -eu
echo "Runner $CI_RUNNER_DESCRIPTION picked up $CI_JOB_ID"
"""
[runners.custom_build_dir]
enabled = true
[runners.cache]
Type = "s3"
Shared = true
Path = "runner-cache"
[runners.cache.s3]
ServerAddress = "s3.internal:9000"
AccessKey = "redacted"
SecretKey = "redacted"
BucketName = "gitlab-runner-cache"
Insecure = false
[runners.docker]
image = "alpine:3.19"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
shm_size = 0
tls_verify = false
network_mode = "bridge"
pull_policy = ["always", "if-not-present"]
volumes = ["/cache"]
wait_for_services_timeout = 30
Kubernetes executor block
[runners.kubernetes]
namespace = "gitlab-runner"
image = "alpine:3.19"
helper_image = "registry.gitlab.com/gitlab-org/gitlab-runner/gitlab-runner-helper:alpine-x86_64-latest"
privileged = false
poll_timeout = 600
cpu_request = "200m"
memory_request = "256Mi"
cpu_limit = "2"
memory_limit = "4Gi"
service_account = "gitlab-runner"
pull_policy = ["always"]
[[runners.kubernetes.volumes.empty_dir]]
name = "build-cache"
mount_path = "/cache"
medium = "Memory"
[runners.kubernetes.node_selector]
"ci/pool" = "ephemeral"
[[runners.kubernetes.host_aliases]]
ip = "10.10.0.7"
hostnames = ["registry.internal"]
Shell executor block
[runners.shell]
# The binary runs jobs as the `gitlab-runner` user by default.
# Nothing much to configure here; the host's state is the state.
Runner tags and targeting
Tags are labels the job uses to pick a runner. A runner picks up a job only if its tag set covers the job's tags:. Pick a short, consistent vocabulary and write it down.
| Tag | Meaning |
|---|---|
docker | Runner has a docker executor |
k8s | Runner is the kubernetes executor |
shell-prod | Shell executor on the production bastion — deploys only |
linux / windows / macos | Host OS |
gpu | Has an NVIDIA GPU attached |
large | >8 vCPU, for heavy builds |
deploy | Protected runner that may touch infra |
Then in your .gitlab-ci.yml:
build:
image: gradle:8-jdk21
tags: [docker, linux]
script: gradle assemble
deploy-prod:
tags: [shell-prod, deploy]
environment: { name: production, url: https://app.example.com }
script: ansible-playbook site.yml -i inventories/prod --tags deploy
run_untagged = false on every runner unless you run a tiny instance with a single runner. Untagged runners are greedy and will pick up jobs you never intended them to run, including deploy jobs someone forgot to tag.
Protected vs unprotected runners
A protected runner will only pick up jobs on protected branches or tags. This is the primary mechanism that stops someone from pushing to a feature branch and having their code run on a production deploy runner.
# At registration
gitlab-runner register ... --access-level="ref_protected"
# Or flip an existing runner in the UI:
# Admin → CI/CD → Runners → → Edit → "Protected"
Combine with protected branches (main, release/*) and protected environments:
deploy-prod:
stage: deploy
tags: [shell-prod, deploy]
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
Autoscaling notes
Three approaches, ranked by maturity in 2026:
- Kubernetes executor on an autoscaling cluster (cluster-autoscaler or Karpenter). One pod per job; cluster scales up under queue pressure. Simplest if you already run Kubernetes.
- Fleeting plugins (AWS, GCP, Azure). Replacement for docker-machine. The runner talks to a fleeting plugin that provisions and tears down instances on demand.
- docker-machine (deprecated). Still works; still documented; still not getting new features. Plan the migration.
# Fleeting on AWS — sketch, not a full config
[[runners]]
name = "aws-fleet-small"
executor = "docker-autoscaler"
[runners.autoscaler]
plugin = "aws"
capacity_per_instance = 2
max_use_count = 20
max_instances = 10
[runners.autoscaler.plugin_config]
name = "gitlab-runner-asg"
profile = "ci"
region = "eu-west-1"
[[runners.autoscaler.policy]]
idle_count = 1
idle_time = "20m0s"
Trusted deploy runners and isolation
A deploy runner is a different runner from your build runners. It lives on a separate host (or at least a separate namespace), it has credentials the build runners do not, and it has tags and protection the build runners do not.
| Dimension | Build runner | Deploy runner |
|---|---|---|
| Executor | docker / kubernetes | shell on a hardened bastion |
| Tags | docker, linux | shell-prod, deploy |
| Protection | Unprotected | ref_protected |
| Scope | Group or instance, shared | Single project or trusted group only |
| Secrets | None | OIDC federation to Vault / AWS (see GitLab Secrets and OIDC) |
| Who can register | Platform team, widely | Only the infra group; registration audited |
If you run deploys on shared runners, you have effectively granted every contributor (and their compromised container images) the ability to run as whoever your deploy credentials authenticate as. Don't.
Gotchas that bite in practice
Shell executor: umask
Jobs inherit gitlab-runner's umask. A restrictive umask on the host (0077) can cause artifacts to be unreadable by other system users or by a following step. Set it explicitly:
# /etc/systemd/system/gitlab-runner.service.d/override.conf
[Service]
UMask=0022
Docker-in-docker with TLS
Since docker 19.03, the docker:dind service generates TLS certs by default. You must mount the shared cert dir and point the client at it:
variables:
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: "tcp://docker:2376"
DOCKER_TLS_VERIFY: 1
DOCKER_CERT_PATH: "/certs/client"
services:
- docker:27-dind
If you see Cannot connect to the Docker daemon at tcp://docker:2375, it is almost always this: the service is listening on 2376 with TLS, and your job is talking to 2375.
Kubernetes pods and IPv6 DNS
Some CoreDNS setups return AAAA records for names that only resolve via IPv4 outside the cluster. git clone or apt-get in a job can hang on AAAA. Fix either the DNS config or force IPv4 for the runner pods:
[runners.kubernetes]
# Ensure pods resolve like the node, not like an in-cluster override:
dns_policy = "Default"
Cache bucket permissions
S3/MinIO cache fails silently on missing s3:PutObject — the job runs, the cache never lands. Test by checking the bucket after the first run. If it is empty, you have a permission problem, not a cache-key problem.
Rotating the runner token
sudo gitlab-runner reset-token --name "linux-docker-01"
sudo systemctl restart gitlab-runner
Verification
- [ ]
sudo gitlab-runner verifyreturnsis alivefor every block - [ ] Admin → CI/CD → Runners shows the runner with the expected tags and "last contact" within
check_interval × 3 - [ ] A test pipeline with matching
tags:is picked up within seconds - [ ] A test pipeline with no matching tags is not picked up (and stalls in "pending")
- [ ] The deploy runner is refused by an unprotected-branch pipeline
- [ ]
config.tomlis0640root:gitlab-runner and not in any repo - [ ]
journalctl -u gitlab-runneris quiet under idle load
See also: GitLab CI/CD, CI for Ansible, GitLab Secrets and OIDC.