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 withexport 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.minorversion 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 fromregistry.gitlab.com, and the job imagealpine:3.24comes from Docker Hub. The tag3.24points 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.
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.
- Open your project in GitLab and select Settings > CI/CD in the left sidebar.
- Expand the Runners section and select Create project runner.
- Select Linux as the operating system.
- 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. - Optional: Enter a description in the Runner description field, for example
k8s-ci-runner. - 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.
$ 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_TOKENWhat the commands do:
read -rs RUNNER_TOKENwaits 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-authneeds exactly two keys. The chart reads both from the Secret and passesrunner-tokento the runner as its token at startup. According to the GitLab documentation,runner-registration-tokenstays 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 asrunner-tokenis set. unset RUNNER_TOKENremoves 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.
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, sohttps://gitlab.comor, for example,https://gitlab.example.comfor 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.createandrbac.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 setrbac.createwithout this key, the chart issues a warning during installation.runners.secret: Name of the Secret containing the token. This keeps the token out ofvalues.yaml. You therefore leave out therunnerTokenkey, andrunnerRegistrationTokenbelongs to the legacy method.runners.config: The actual runner configuration in TOML format. The chart evaluates the text as a Helm template. Thenamespaceline uses the release namespace, so the job pods run inci-runner.image: Default image for jobs that don't specify animagethemselves. A pinned tag instead ofalpinewithout a tag keeps it traceable what a job ran with.cpu_request,memory_request,cpu_limit,memory_limit: Requests and limits for thebuildcontainer.helper_cpu_requestandhelper_memory_requestapply to thehelpercontainer. 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.
$ helm repo add gitlab https://charts.gitlab.io
$ helm repo update gitlab
$ helm search repo -l gitlab/gitlab-runnerThe 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:
$ helm install --namespace ci-runner gitlab-runner \
-f runner-values.yaml \
gitlab/gitlab-runner \
--version 0.93.0gitlab-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.
$ kubectl get pods --namespace ci-runner
$ kubectl logs deployment/gitlab-runner --namespace ci-runnerHow to recognize success:
- Pod: A pod of the
gitlab-runnerDeployment is running in the namespace with statusRunning. 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 containingforbiddenorreservedpoint you to the troubleshooting section below. - GitLab: Under Settings > CI/CD > Runners, your runner is listed among the assigned project runners. The status
onlinemeans the runner has contacted GitLab within the last two hours.never_contactedmeans 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:
cluster-check:
tags:
- kubernetes
image: alpine:3.24
script:
- cat /etc/os-release
- uname -atags 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:
$ kubectl get pods --namespace ci-runner --watchCommit 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 withkubectl delete secret runner-auth --namespace ci-runner, recreate it as shown above with the correct token, and then restart the runner, for example withkubectl rollout restart deployment/gitlab-runner --namespace ci-runner. - Keys missing in the Secret. If
runner-tokenorrunner-registration-tokenis missing, Kubernetes cannot mount the Secret. The pod then stays inContainerCreating, andkubectl describe podshows aFailedMountmessage underEventswith the notereferences non-existent secret key. Check the key names in the Secretrunner-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 reservedmeans the registration sends settings that, with an authentication token, only GitLab may define. With chart 0.93.0, this is typicallyrunners.maximumTimeoutinvalues.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
tagsat all and "Run untagged" is off. Compare the tags in.gitlab-ci.ymlwith 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
$ kubectl get pods --namespace ci-runner --field-selector status.phase=Pending
$ kubectl describe pod "<pod-name>" --namespace ci-runnerReplace <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
$ 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
- Pause the runner in GitLab and wait until no jobs are running.
- Remove the Helm release:
$ helm uninstall gitlab-runner --namespace ci-runner- Delete the namespace. This also removes the Secret
runner-authwith the token and any leftover job pods:
$ kubectl delete namespace ci-runner- 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 ofrunners.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.
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.