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_HOSTis set totcp://docker:2376, not the unix socket - TLS handshake failed: the
docker-certsvolume must be mounted on the same path in both the build and dind containers