GitLab Runner Setup

Install gitlab-runner, register it against a project or group, pick an executor that matches what you actually run, and write a config.toml that will not surprise you six months later.

If you only remember six things
  • Pick the executor before you install. shell for one host, docker for mixed workloads, kubernetes for 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 = false unless you truly mean it.
  • concurrent is per-process, limit is 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

ExecutorUse whenAvoid when
shellYou 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.
dockerMost 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-machineYou already run it and have not had time to migrate.Anything new. Deprecated upstream — use the fleeting plugins or Kubernetes instead.
kubernetesYou 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 / parallelsNiche (signing on a locked-down Mac mini, old macOS builds).General workloads. They exist; they are not your default.
Rule: One executor per runner process. If you need two executors on the same host, install the runner once and register it twice — each registration adds a [[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.

Tip: Generate the auth token in GitLab UI (Admin → CI/CD → Runners → New instance/group/project runner) so each runner has its own revocable credential. Do not share a registration token across your fleet.

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
Common confusion: 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.

TagMeaning
dockerRunner has a docker executor
k8sRunner is the kubernetes executor
shell-prodShell executor on the production bastion — deploys only
linux / windows / macosHost OS
gpuHas an NVIDIA GPU attached
large>8 vCPU, for heavy builds
deployProtected 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
Set 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:

  1. 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.
  2. Fleeting plugins (AWS, GCP, Azure). Replacement for docker-machine. The runner talks to a fleeting plugin that provisions and tears down instances on demand.
  3. 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.

DimensionBuild runnerDeploy runner
Executordocker / kubernetesshell on a hardened bastion
Tagsdocker, linuxshell-prod, deploy
ProtectionUnprotectedref_protected
ScopeGroup or instance, sharedSingle project or trusted group only
SecretsNoneOIDC federation to Vault / AWS (see GitLab Secrets and OIDC)
Who can registerPlatform team, widelyOnly 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

See also: GitLab CI/CD, CI for Ansible, GitLab Secrets and OIDC.