Tutorials  /  Kubernetes

Install GitLab Runner on Kubernetes: Helm Chart, Token Secret, and First Pipeline

Ccentron Redaktion · October 2026 ·31 min read ·Kubernetes, Tutorial

With the official Helm chart gitlab/gitlab-runner, you install GitLab Runner in a dedicated namespace of your Kubernetes cluster. The runner token is stored in a Kubernetes Secret, and every CI job starts as its own pod. By the end, you have a project runner with tightly scoped permissions and a first pipeline whose job ran in the cluster.

This guide uses chart 0.93.0 with GitLab Runner 19.4.0 (as of October 1, 2026). According to the GitLab documentation, the procedure applies to any Kubernetes cluster, for example a centron Managed Kubernetes cluster. Instead of running on shared runners, your jobs then run on nodes you size yourself. This article is the first part of a planned series on CI/CD on Kubernetes. Building container images in the pipeline is the topic of one of the following parts.

What is a GitLab Runner with the Kubernetes executor?

A GitLab Runner with the Kubernetes executor is a service in the cluster that polls GitLab for new CI jobs and, for each job, uses the Kubernetes API to start a separate pod that is removed once the job ends.

To do this, the Helm chart installs a Deployment with a manager pod. This pod actively fetches jobs from the GitLab API. GitLab therefore does not need to reach your cluster, but the cluster does need outbound access to your GitLab instance. For each job, the manager pod creates a job pod through the Kubernetes API. It runs at least two containers:

Container Purpose
build runs your job's script in the specified image
helper clones the repository, provides cache and artifacts, and uploads artifacts to GitLab after the job
Service containers only if the job or the runner configuration defines services, such as a database for tests
graph LR
  R["Runner manager pod in namespace ci-runner"] -->|"polls for jobs (outbound)"| G["GitLab instance"]
  R -->|"creates job pod"| K["Kubernetes API"]
  K --> J["Job pod with containers build and helper"]
  J -->|"job log and artifacts"| G

Prerequisites

  • Kubernetes cluster running version 1.33.1 or 1.32.4. According to the centron docs, these are the versions you can currently choose for centron Managed Kubernetes, with 1.33.1 recommended (retrieved October 1, 2026). According to centron, the selection in the creation dialog is authoritative.
  • kubectl no more than one minor version away from the cluster, so 1.32 to 1.34 for a 1.33 cluster. kubectl must be connected to the cluster. With centron, you download the kubeconfig in the cluster view via "Download Config File", set the file permissions with chmod 600, and point to it with export KUBECONFIG=.... The steps are described under Connect to the cluster. If you don't have kubectl yet, the tutorial Install and use kubectl to manage Kubernetes clusters helps.
  • Helm 3. The GitLab documentation for Runner 19.4.0 describes the commands for Helm 3. According to the Helm project, Helm 3 receives security updates only until February 10, 2027. The current major version is Helm 4.
  • GitLab 19.4 (self-managed) or gitlab.com, and a project in which you have the Maintainer role. gitlab.com is updated continuously, and there GitLab recommends the latest runner version. According to GitLab, the runner's major.minor version should match the GitLab version. Older or newer runners may work, but some features may then be unavailable or not work correctly. If your instance runs a different version, you choose a matching chart version in the installation section.
  • Outbound HTTPS access from the cluster to your GitLab instance and to the registries of the images you use. According to the chart's values.yaml, the runner image comes from registry.gitlab.com, and the job image alpine:3.24 comes from Docker Hub. The tag 3.24 points to Alpine 3.24.2 (Docker Hub, as of September 18, 2026).

To clarify responsibilities on centron Managed Kubernetes: according to the centron docs, centron operates the control plane, the cluster network, and the Cluster Autoscaler. You are responsible for namespaces, RBAC rules, Secrets, and any software you install yourself, such as GitLab Runner.

K8

Matching infrastructure at centron

From container to cluster: managed Kubernetes from centron with control plane and traffic included. Explore managed Kubernetes →

Create a project runner in GitLab

You create the runner in GitLab first, because that is when the runner authentication token is generated, which the runner in your cluster later uses to authenticate with GitLab.

  1. Open your project in GitLab and select Settings > CI/CD in the left sidebar.
  2. Expand the Runners section and select Create project runner.
  3. Select Linux as the operating system.
  4. In the Tags field, enter the tag kubernetes. Leave Run untagged turned off. The runner then only picks up jobs that explicitly request this tag.
  5. Optional: Enter a description in the Runner description field, for example k8s-ci-runner.
  6. Select Create runner.

GitLab then shows you the runner authentication token. It starts with glrt-. Copy it right away, because GitLab only displays it briefly. You don't need the remaining installation instructions on the page, because the Helm chart takes care of registration later.

From now on, you set tags, "Run untagged", the lock for other projects ("locked"), protected branches ("protected"), and the maximum job timeout only in GitLab. According to the GitLab documentation, these values have no effect in values.yaml when you use an authentication token. If you set them anyway, the startup script of chart 0.93.0 silently discards tags, "Run untagged", locked, and protected. A runners.maximumTimeout value, on the other hand, makes registration abort with the message Runner configuration ... is reserved.

Two variants for context: If the runner should be available to all projects in a group, you create a group runner under Build > Runners > Create group runner. This requires the Owner role in the group; the rest of the procedure stays the same. According to GitLab, the older method using a registration token is considered legacy and is not recommended. Since GitLab 17.0, administrators and group owners can disable it.

Create the namespace and token Secret

You create a dedicated namespace for the runner and store the token there in a Kubernetes Secret instead of writing it in plain text into values.yaml.

Console
$ kubectl create namespace ci-runner
$ read -rs RUNNER_TOKEN
$ kubectl create secret generic runner-auth \
    --namespace ci-runner \
    --from-literal=runner-registration-token="" \
    --from-literal=runner-token="$RUNNER_TOKEN"
$ unset RUNNER_TOKEN

What the commands do:

  • read -rs RUNNER_TOKEN waits for your input. Paste the token and press Enter. The input is not shown on screen, and because the token is not part of the command itself, it doesn't end up in your shell history either.
  • The Secret runner-auth needs exactly two keys. The chart reads both from the Secret and passes runner-token to the runner as its token at startup. According to the GitLab documentation, runner-registration-token stays an empty string for compatibility reasons. If your secret management tool doesn't allow an empty value, you can enter any text. It is ignored as long as runner-token is set.
  • unset RUNNER_TOKEN removes the token from your shell again.

If you create the Secret as a YAML manifest with Base64 values instead, make sure there is no trailing newline on the token, so use echo -n when encoding. Otherwise, the runner reports the error invalid header field for "Private-Token".

A Kubernetes Secret is only Base64-encoded, not encrypted. Anyone allowed to read Secrets in the ci-runner namespace can read the token. That is why the runner gets its own namespace in which nothing runs except the runner and its job pods.

What permissions does GitLab Runner need in the cluster?

GitLab Runner needs permissions only in its own namespace, on pods and their subresources attach, exec, and log, and on Secrets, Services, service accounts, and events, but no cluster-wide permissions.

The simplest approach would be to set only rbac.create: true in the chart. Without your own rules under rbac.rules, however, the chart then creates a Role with all verbs on all resources of the core API group in the namespace. The runner could then, for example, read and modify every Secret and every ConfigMap in the namespace. Instead, the executor documentation lists the required permissions. For this guide, this subset is enough:

Resource Verbs Purpose
pods create, delete, get, list, watch create and remove job pods. list and watch are needed by the informer that the runner uses from version 17.9 onward to detect changes to the job pod faster
pods/attach create, delete, get, patch run commands via attach. This is the default, because the feature flag FF_USE_LEGACY_KUBERNETES_EXECUTION_STRATEGY is set to false by default
pods/exec create, delete, get, patch required without condition according to the permissions table
pods/log get, list required for the attach strategy according to the permissions table
secrets create, delete, get, update required without condition according to the permissions table
serviceaccounts get required without condition according to the permissions table
services create, get required without condition according to the permissions table
events list write Kubernetes warnings about the job pod to the job log (print_pod_warning_events, on by default)

If list or watch on pods is missing, the runner writes a warning to the log and keeps tracking the job pod the older way. You only need watch on events if you turn on the feature flag FF_PRINT_POD_EVENTS. Other permissions from the table, such as on namespaces, ConfigMaps, or PersistentVolumeClaims, belong to optional features like a separate namespace per job and are not needed here. The full table is in the Kubernetes executor documentation for Runner 19.4.0.

Leave the rbac.clusterWideAccess switch at its default value false. The chart then creates a Role in the namespace and no ClusterRole. This means the runner can create job pods only in this namespace, which is exactly where the namespace line in the runner configuration sends them.

Even with tight permissions, an attack surface remains, because the runner is supposed to start pods with whatever image a job requests. According to GitLab, anyone with the Developer role in the project can compromise the runner's environment through a pipeline, whether intentionally or not. So only give write access to the pipeline to people you trust to run code in your cluster. According to GitLab, forks do not get project runners automatically. With allowed_images in the runner configuration, you can additionally restrict the allowed images.

Write values.yaml

In the file runner-values.yaml, you define which GitLab instance the runner connects to, which permissions it gets in the namespace, and which resources the job pods start with.

yaml
gitlabUrl: "<gitlab-url>"
concurrent: 4
rbac:
  create: true
  rules:
    - apiGroups: [""]
      resources: ["pods"]
      verbs: ["create", "delete", "get", "list", "watch"]
    - apiGroups: [""]
      resources: ["pods/attach", "pods/exec"]
      verbs: ["create", "delete", "get", "patch"]
    - apiGroups: [""]
      resources: ["pods/log"]
      verbs: ["get", "list"]
    - apiGroups: [""]
      resources: ["secrets"]
      verbs: ["create", "delete", "get", "update"]
    - apiGroups: [""]
      resources: ["serviceaccounts"]
      verbs: ["get"]
    - apiGroups: [""]
      resources: ["services"]
      verbs: ["create", "get"]
    - apiGroups: [""]
      resources: ["events"]
      verbs: ["list"]
serviceAccount:
  create: true
runners:
  secret: runner-auth
  config: |
    [[runners]]
      [runners.kubernetes]
        namespace = "{{ default .Release.Namespace .Values.runners.jobNamespace }}"
        image = "alpine:3.24"
        cpu_request = "500m"
        memory_request = "512Mi"
        cpu_limit = "1"
        memory_limit = "1Gi"
        helper_cpu_request = "100m"
        helper_memory_request = "128Mi"

The keys in detail:

  • gitlabUrl: Replace <gitlab-url> with the full URL of your GitLab instance including the protocol, so https://gitlab.com or, for example, https://gitlab.example.com for self-managed.
  • concurrent: The maximum number of jobs the runner executes at the same time. The chart default is 10. More on this in the section on concurrent jobs.
  • rbac.create and rbac.rules: The chart creates a Role with exactly these rules and a RoleBinding. The rules match the table in the previous section.
  • serviceAccount.create: The chart creates a dedicated ServiceAccount to which the Role is bound. If you set rbac.create without this key, the chart issues a warning during installation.
  • runners.secret: Name of the Secret containing the token. This keeps the token out of values.yaml. You therefore leave out the runnerToken key, and runnerRegistrationToken belongs to the legacy method.
  • runners.config: The actual runner configuration in TOML format. The chart evaluates the text as a Helm template. The namespace line uses the release namespace, so the job pods run in ci-runner.
  • image: Default image for jobs that don't specify an image themselves. A pinned tag instead of alpine without a tag keeps it traceable what a job ran with.
  • cpu_request, memory_request, cpu_limit, memory_limit: Requests and limits for the build container. helper_cpu_request and helper_memory_request apply to the helper container. The values are examples; adjust them to your jobs.

Don't set the privileged switch. Privileged containers are needed, for example, for image builds with Docker-in-Docker. According to GitLab, a job can use them to gain full root access to the host. That is why image builds are the topic of a following part of this series.

Install GitLab Runner with Helm

With Helm, you add the GitLab chart repository, check the available chart versions, and install the runner with a pinned version into the namespace ci-runner.

Console
$ helm repo add gitlab https://charts.gitlab.io
$ helm repo update gitlab
$ helm search repo -l gitlab/gitlab-runner

The chart and the runner have separate version numbers. That is why the list shows two columns: CHART VERSION is the version of the Helm chart, APP VERSION the version of the runner. For this guide, look for the row with chart 0.93.0 and app version 19.4.0. If your GitLab instance runs a different version, choose the chart version whose app version matches the major.minor of your GitLab version.

If you want to see beforehand which manifests Helm generates, append --dry-run --debug to the following command. Helm then changes nothing in the cluster. This is how you install:

Console
$ helm install --namespace ci-runner gitlab-runner \
    -f runner-values.yaml \
    gitlab/gitlab-runner \
    --version 0.93.0

gitlab-runner is the name of the release. You use this name later to update and remove the installation. Because the release name starts with the chart name, the Deployment is also called gitlab-runner. After installation, Helm prints the chart's notes. They include the GitLab URL the runner registers against. If gitlabUrl is missing, the chart warns you at this point.

Check that the runner is online

You can tell whether the installation worked in three places, namely the pod status in the cluster, the runner log, and the runner status in GitLab.

Console
$ kubectl get pods --namespace ci-runner
$ kubectl logs deployment/gitlab-runner --namespace ci-runner

How to recognize success:

  • Pod: A pod of the gitlab-runner Deployment is running in the namespace with status Running. It can take a good minute until it counts as ready, because the chart's readiness probe starts only after 60 seconds by default.
  • Log: The chart's startup script registers the runner with the token and writes lines like Registration attempt 1 of 30. It tries up to 30 times, 5 seconds apart. After that, the runner starts and polls GitLab for jobs. Messages containing forbidden or reserved point you to the troubleshooting section below.
  • GitLab: Under Settings > CI/CD > Runners, your runner is listed among the assigned project runners. The status online means the runner has contacted GitLab within the last two hours. never_contacted means it has never checked in.

GitLab does not show the pod name in the runner's detail view. According to GitLab, this is a known issue with the new registration workflow when using the Helm chart (issue 423523).

Run your first pipeline on the cluster

A minimal .gitlab-ci.yml with a single job is enough to check whether GitLab hands the job to your runner and the runner starts a pod for it in the cluster.

Create the file .gitlab-ci.yml in the root directory of your repository with this content:

yaml
cluster-check:
  tags:
    - kubernetes
  image: alpine:3.24
  script:
    - cat /etc/os-release
    - uname -a

tags selects the runner. A runner only picks up a job if it has every tag the job requests. Tags are case-sensitive. image sets the image for the build container, script the commands. Because no stage is specified, the job runs in the default stage test.

Before committing, open a second terminal and watch the namespace:

Console
$ kubectl get pods --namespace ci-runner --watch

Commit and push the file. GitLab starts the pipeline, which you can find under Build > Pipelines. In the terminal, a new pod for the job appears next to the runner pod. It runs as long as the job runs and disappears afterwards. In the job log in GitLab, you see the output of both commands, including the Alpine version from /etc/os-release. Press Ctrl+C to stop --watch.

How many jobs run at the same time?

The number of jobs running at the same time is capped by the concurrent value in values.yaml, while their resource requests and the free capacity of the nodes decide whether the job pods find room.

concurrent is an upper limit for all jobs of this runner combined. The chart default is 10; in this guide, the value is set to 4. How often the runner polls GitLab for new jobs is controlled by checkInterval, which defaults to 3 seconds.

Each job pod requests the combined requests of build and helper. With the example values, that is 600m CPU and 640Mi of memory per job. With 4 concurrent jobs, the runner therefore reserves up to 2.4 CPU and 2560Mi (2.5 GiB) of memory. If a job pod can't find a node with enough free capacity, it stays in status Pending.

On centron Managed Kubernetes, you can turn on the Cluster Autoscaler per node pool for this. According to the centron docs, if pods cannot be scheduled due to lack of capacity, it provisions additional nodes. It removes nodes that stay underutilized. It decides based on requests, not on actual utilization. Without requests in the runner configuration, it therefore cannot correctly estimate what your jobs need. The node pool maximum puts an upper limit on costs. How to set the minimum and maximum is described under Enable autoscaling.

According to the centron docs, a new node takes a few minutes. By default, the runner waits 180 seconds to reach the job pod (poll_timeout). If you expect jobs to wait for new nodes more often, increase the value under [runners.kubernetes], for example to poll_timeout = 600.

Troubleshooting

Runner stays at never_contacted

First read the log with kubectl logs deployment/gitlab-runner --namespace ci-runner. Typical causes:

  • Wrong token or not copied completely. The token must start with glrt-. Delete the Secret with kubectl delete secret runner-auth --namespace ci-runner, recreate it as shown above with the correct token, and then restart the runner, for example with kubectl rollout restart deployment/gitlab-runner --namespace ci-runner.
  • Keys missing in the Secret. If runner-token or runner-registration-token is missing, Kubernetes cannot mount the Secret. The pod then stays in ContainerCreating, and kubectl describe pod shows a FailedMount message under Events with the note references non-existent secret key. Check the key names in the Secret runner-auth.
  • Newline at the end of the token. The message invalid header field for "Private-Token" indicates a token with a trailing \n.
  • Reserved values in values.yaml. Runner configuration ... is reserved means the registration sends settings that, with an authentication token, only GitLab may define. With chart 0.93.0, this is typically runners.maximumTimeout in values.yaml. Remove the value and set the timeout in GitLab.
  • Wrong gitlabUrl or no network path. Check the URL including the protocol and whether the cluster can reach the GitLab instance over HTTPS. If your self-managed instance uses a self-signed certificate, pass it to the chart as a Secret via certsSecretName.

Job does not start and waits for a runner

  • The job requests a tag the runner doesn't have, or it has no tags at all and "Run untagged" is off. Compare the tags in .gitlab-ci.yml with those in the runner settings.
  • The runner is paused. Resume it under Settings > CI/CD > Runners with Resume.
  • The pipeline runs in a fork. Project runners are not enabled there automatically.

forbidden in the job log or runner log

A message like secrets is forbidden: User "system:serviceaccount:..." cannot create resource "secrets" indicates a missing rule in the Role. Compare rbac.rules with the table above, fix values.yaml, and apply it with helm upgrade (see below). If you turn on feature flags or optional features, check the permissions table in the executor documentation to see which additional permissions they require.

Job pod stays in status Pending

Console
$ kubectl get pods --namespace ci-runner --field-selector status.phase=Pending
$ kubectl describe pod "<pod-name>" --namespace ci-runner

Replace <pod-name> with the name from the first output. The Events section shows the reason. A message like Insufficient cpu means that no node has enough free capacity. With the autoscaler active, a node should then be added. If that doesn't happen, the node pool maximum has been reached, according to the centron docs. Also check whether a single job pod requests more than a node has in total. In that case, the only fix is to lower the requests or choose larger nodes.

Update and remove the runner

Before every update and before removal, you pause the runner in GitLab and wait until running jobs have finished. According to the GitLab documentation, this avoids authorization errors when jobs complete. You can pause it under Settings > CI/CD > Runners in the Assigned project runners area with Pause.

Update the runner

Console
$ helm repo update gitlab
$ helm search repo -l gitlab/gitlab-runner
$ helm upgrade --namespace ci-runner \
    -f runner-values.yaml \
    gitlab-runner gitlab/gitlab-runner \
    --version "<chart-version>"

Replace <chart-version> with the new chart version from the list. Again, choose it so that the app version matches your GitLab version. Pass values.yaml with every upgrade. Without -f, Helm does reuse the values of the last release, but if you change individual values only with --set, all your other custom values fall back to the chart defaults. The same command with an unchanged version applies changed values, such as new permissions or requests. During the restart, Kubernetes gives the old runner pod up to 3,600 seconds by chart default to finish running jobs. After that, resume the runner in GitLab with Resume.

Remove the runner completely

  1. Pause the runner in GitLab and wait until no jobs are running.
  2. Remove the Helm release:
Console
$ helm uninstall gitlab-runner --namespace ci-runner
  1. Delete the namespace. This also removes the Secret runner-auth with the token and any leftover job pods:
Console
$ kubectl delete namespace ci-runner
  1. Delete the runner in GitLab under Settings > CI/CD > Runners in the Assigned project runners area with Remove runner and confirm with Remove.

Step 4 is necessary even though the chart calls unregister when the pod terminates (unregisterRunners: true is the default). According to the chart, for tokens with the prefix glrt-, this only removes the runner manager, not the runner itself.

What remains: your file runner-values.yaml, the gitlab entry in your local Helm repository list, and the .gitlab-ci.yml in the project. Without a runner, jobs with the tag kubernetes no longer find anyone to pick them up. Remove the job or change the tags if you want to keep using the pipeline.

Next steps

  • Build container images: A following part of the series covers building images in the pipeline without allowing the runner to use privileged containers.
  • Connect a private registry: Jobs that pull images from a private registry need a pull secret. For centron Managed Kubernetes, the docs describe this under Connect to the container registry. In the runner configuration, you reference it with image_pull_secrets.
  • Set up a cache: Without a distributed cache, every job pod starts without a dependency cache. The chart can integrate an S3-compatible cache. The credentials live in a Secret that you specify under runners.cache.secretName; the settings themselves go in the [runners.cache] section of runners.config. The options are listed in the values.yaml of chart 0.93.0.
  • Deploy with GitOps: How a pipeline brings changes into the cluster through Git is shown in the tutorial GitOps-based CI/CD on Kubernetes.
  • Overview: How CI/CD pipelines work together on centron infrastructure is described on the CI/CD pipelines page.
Jetzt 200 € Guthaben sichern

Testen Sie Ihr Setup auf ccloud³

Registrieren Sie sich in der ccloud³ und erhalten Sie 200 € Startguthaben für Ihr Projekt – z. B. für eine PostgreSQL-VM mit automatischen Backups.

centron Redaktion Technische Redaktion

Das Redaktionsteam von centron schreibt Anleitungen aus dem Betriebsalltag: getestet auf unserer eigenen Plattform, betrieben im Rechenzentrum in Hallstadt bei Bamberg.

Kategorie Kubernetes
Teilen
Noch offene Fragen?

Our team will help you with your specific setup - in German or English, by people who run the platform themselves.

War dieses Tutorial hilfreich?

Your answer is stored anonymously and helps us improve our tutorials.

Kommentare

No comments yet - be the first to ask a question about this tutorial.

Sign in to comment

Comments are open to centron customers. Sign in to your account to ask a question about this tutorial.

Jetzt kostenlos anfangen

Melden Sie sich an und erhalten Sie in den ersten 60 Tagen ein Guthaben von 200 € bei centron.

Dieses Werbeangebot gilt nur für neue Konten. Angebot ausschließlich für Gewerbetreibende.