GitLab Runner in Kubernetes

Published: 2026-02-24

Running GitLab CI jobs inside Kubernetes means every job gets a fresh pod, resources are capped per job, and no runner-host cleanup is needed. This covers the Helm deployment, Docker-in-Docker executor setup, resource templates, caching, and common troubleshooting.


HelmRelease

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: gitlab-runner
  namespace: gitlab-runner
spec:
  chart:
    spec:
      chart: gitlab-runner
      version: "0.63.*"
      sourceRef:
        kind: HelmRepository
        name: gitlab
        namespace: flux-system
  values:
    gitlabUrl: "https://gitlab.example.com"
    runnerRegistrationToken: "${RUNNER_TOKEN}"
    concurrent: 10
    rbac:
      create: true
      clusterWideAccess: false
    runners:
      tags: "k8s,dev"
      locked: false
      config: |
        [[runners]]
          [runners.kubernetes]
            namespace = "gitlab-runner"
            image = "alpine:3.19"
            privileged = true
            [[runners.kubernetes.volumes.empty_dir]]
              name = "docker-certs"
              mount_path = "/certs/client"
              medium = "Memory"

privileged: true is required for Docker-in-Docker. For environments where DinD is not needed, set privileged: false and use kaniko or buildah instead — both build images without privileged containers.

concurrent: 10 limits the total parallel jobs across all runner pods. If builds queue up, raise this number but also raise node capacity.


Registration token via ESO

The runner registration token is a project/group token from GitLab. Store it in Vault and pull it via External Secrets Operator:

yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: gitlab-runner-token
  namespace: gitlab-runner
spec:
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: gitlab-runner-secret
    template:
      data:
        runner-registration-token: "{{ .token }}"
  data:
    - secretKey: token
      remoteRef:
        key: secret/infra/gitlab-runner
        property: registration_token

Then reference this Secret in the HelmRelease:

yamlvalues:
  existingRunnerSecret: gitlab-runner-secret

Never embed the registration token in the values file or GitLab CI variables as plain text.


Per-job resource limits

The runner config supports pod templates to cap resource usage per job:

toml[[runners]]
  [runners.kubernetes]
    [runners.kubernetes.pod_annotations]
      "cluster-autoscaler.kubernetes.io/safe-to-evict" = "false"
    [[runners.kubernetes.pod_labels]]
      name = "gitlab-runner-job"
    [runners.kubernetes.build_container_resources]
      [runners.kubernetes.build_container_resources.requests]
        cpu = "500m"
        memory = "512Mi"
      [runners.kubernetes.build_container_resources.limits]
        cpu = "2"
        memory = "2Gi"
    [runners.kubernetes.helper_container_resources]
      [runners.kubernetes.helper_container_resources.requests]
        cpu = "100m"
        memory = "128Mi"
      [runners.kubernetes.helper_container_resources.limits]
        cpu = "500m"
        memory = "256Mi"

Setting requests without limits leads to OOM kills on memory-constrained nodes. Setting limits too low causes slow Docker builds. cpu: "2" is a reasonable cap for builds that run docker build.

The safe-to-evict: "false" annotation prevents the cluster autoscaler from evicting running job pods during scale-down, which would cause job failures.


Docker-in-Docker service

Jobs that need Docker must declare the DinD service:

yaml# .gitlab-ci.yml
build:
  image: docker:24
  services:
    - name: docker:24-dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: /certs
    DOCKER_TLS_VERIFY: 1
    DOCKER_CERT_PATH: /certs/client
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

The docker-certs emptyDir volume (declared in the runner config) is shared between the dind service and the build container, providing mutual TLS without --privileged on the service container.

Alternatives to DinD

Tool Privileged Speed Notes
Docker-in-Docker Yes Fast Requires privileged mode
Kaniko No Medium Slower, but fully unprivileged
Buildah No Medium Rootless builds
img No Slow Pure Go, no daemon

For security-sensitive environments, kaniko is the recommended alternative.


Cache

For pip/npm/yarn caches, use a local MinIO or S3-compatible bucket:

toml[[runners]]
  [runners.cache]
    Type = "s3"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "minio.infra.svc.cluster.local:9000"
      BucketName = "gitlab-runner-cache"
      Insecure = true

In .gitlab-ci.yml:

yamlcache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .npm/
    - vendor/

variables:
  npm_config_cache: "$CI_PROJECT_DIR/.npm"

Without a cache, every CI job re-downloads all dependencies. With S3 cache, repeated jobs complete 3–5× faster.


Runner on multiple namespaces

If multiple teams share a runner, scope job pods to the right namespace using namespace per runner:

toml[[runners]]
  name = "dev-runner"
  [runners.kubernetes]
    namespace = "dev"
    
[[runners]]
  name = "staging-runner"
  [runners.kubernetes]
    namespace = "staging"

Each runner registration appears in GitLab's CI/CD settings separately and can be assigned to specific projects or groups.


Debugging

bash# Check runner pod logs
kubectl logs -n gitlab-runner deploy/gitlab-runner -f

# List running job pods
kubectl get pods -n gitlab-runner --field-selector=status.phase=Running

# Exec into a job pod (while it's running)
kubectl exec -n gitlab-runner <job-pod-name> -c build -- /bin/sh

# Check runner registration status
kubectl exec -n gitlab-runner deploy/gitlab-runner -- \
  gitlab-runner list

Common issues:

  • Job pods stuck in Pending: check resource requests vs available node capacity
  • Docker socket not found: ensure DOCKER_HOST is set to tcp://docker:2376, not the unix socket
  • TLS handshake failed: the docker-certs volume must be mounted on the same path in both the build and dind containers