GitLab CI

This guide explains how to access a Cloudfleet Kubernetes Engine (CFKE) cluster from GitLab CI/CD jobs using OpenID Connect (OIDC) workload identity federation. The job mints a short-lived OIDC ID token at runtime, the cluster trusts that token directly, and kubectl authenticates as the job itself. No API keys, kubeconfigs, or service-account secrets need to be stored as GitLab variables.

How it works

The CFKE control plane is configured to trust GitLab CI’s CI/CD identity provider out of the box. When a GitLab job declares an id_tokens block with the cluster’s API URL as the audience, the cluster’s kube-apiserver accepts the resulting JWT directly as a Kubernetes user.

  1. The GitLab job declares id_tokens with aud: https://api.cloudfleet.ai/v1/clusters/<cluster-id>. GitLab sets that token as an environment variable for the job.
  2. The runner uses the token as a bearer token when calling the cluster’s API server.
  3. The API server verifies the signature against GitLab’s JWKS at the issuer URL (https://gitlab.com by default, or your self-hosted GitLab URL), validates the audience, and authenticates the request as the user gitlab: + the token’s sub claim (for example, gitlab:project_path:my-group/my-app:ref_type:branch:ref:main).
  4. RBAC (and any ValidatingAdmissionPolicy you configure) decides what that user is allowed to do.

The token lifetime is short (GitLab’s default is five minutes). Long-running jobs that span that window will see authentication failures on later kubectl calls. There are no secrets or kubeconfigs to rotate.

For self-hosted GitLab instances, the cluster’s trusted issuer URL must match the GitLab installation. CFKE supports overriding the default https://gitlab.com issuer. Contact Cloudfleet support if you need this configured.

Prerequisites

  • A running CFKE cluster. If you do not have one yet, follow the getting started guide.
  • The cluster ID (a UUID) and region (for example, europe-central-1a). You can read both from the Cloudfleet console or with cloudfleet clusters list.
  • A GitLab project where you can edit .gitlab-ci.yml and configure CI/CD variables.
  • Administrator access to the cluster (or someone who has it) so RBAC can be bound to the GitLab identity.

Quick start

The snippet below installs kubectl, builds a kubeconfig from the OIDC token, and runs a kubectl command. The cluster API server presents a publicly trusted TLS certificate, so no CA bundle needs to be fetched.

.cfke:
  image: debian:13-slim
  variables:
    DEBIAN_FRONTEND: noninteractive
    KUBECTL_VERSION: v1.34
    KUBECONFIG: ${CI_PROJECT_DIR}/kubeconfig
    CFKE_CLUSTER_ID: 95cc1ef4-2122-4b51-97d9-b35b531c3c45
    CFKE_REGION: europe-central-1a
    CFKE_API_SERVER: https://${CFKE_CLUSTER_ID}.${CFKE_REGION}.cfke.io
    CFKE_API_AUD: https://api.cloudfleet.ai/v1/clusters/${CFKE_CLUSTER_ID}
  id_tokens:
    CFKE_TOKEN:
      aud: ${CFKE_API_AUD}
  before_script:
    - apt-get update -qq && apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg
    - install -d /etc/apt/keyrings
    - curl -fsSL "https://pkgs.k8s.io/core:/stable:/${KUBECTL_VERSION}/deb/Release.key" | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    - echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${KUBECTL_VERSION}/deb/ /" > /etc/apt/sources.list.d/kubernetes.list
    - apt-get update -qq && apt-get install -y --no-install-recommends kubectl
    - kubectl config set-cluster cfke --server="${CFKE_API_SERVER}"
    - kubectl config set-credentials gitlab --token="${CFKE_TOKEN}"
    - kubectl config set-context ctx --cluster=cfke --user=gitlab
    - kubectl config use-context ctx

deploy:
  extends: .cfke
  script:
    - kubectl auth whoami
    - kubectl get nodes

The .cfke hidden job is reusable. Any job that extends: .cfke runs before_script and inherits a working KUBECONFIG, so its script block can call kubectl directly.

The first run will succeed at authentication but fail at authorization, because no RBAC has been bound to the job’s identity yet. The error message will print the exact User the cluster authenticated. The next section explains how to grant permissions to that user.

Project-level CI/CD variables

The cluster ID and region are not sensitive — they identify the cluster but cannot be used to authenticate against it. Configure them as GitLab CI/CD variables so they can be reused across .gitlab-ci.yml and overridden per environment:

  1. In your GitLab project, go to Settings > CI/CD > Variables.
  2. Add two variables:
    • CFKE_CLUSTER_ID — the cluster UUID
    • CFKE_REGION — the cluster region

Then remove the hard-coded values from the snippet above. The defaults exposed by GitLab via id_tokens and your project variables are enough.

Granting permissions with RBAC

Authentication and authorization are independent. A job that has been authenticated still has zero permissions until the cluster admin creates a RoleBinding or ClusterRoleBinding that names the job’s identity.

Identifying the job’s user

CFKE constructs the Kubernetes username by prefixing the GitLab OIDC token’s sub claim with gitlab:. The shape of the sub depends on how the job was triggered. The most common forms:

TriggerRBAC subject
Pipeline on a branchgitlab:project_path:GROUP/PROJECT:ref_type:branch:ref:BRANCH
Pipeline on a taggitlab:project_path:GROUP/PROJECT:ref_type:tag:ref:TAG
Merge request pipelinegitlab:project_path:GROUP/PROJECT:ref_type:branch:ref:SOURCE_BRANCH
Job using a deployment environmentgitlab:project_path:GROUP/PROJECT:environment:ENV

GitLab’s ID token documentation lists the full set of claims and how the sub is composed. If you are unsure what subject your job produces, run kubectl auth whoami from the job once and read the value from the logs.

Cluster-wide read access

Use a ClusterRoleBinding when the job needs to read resources across all namespaces:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: gitlab-myapp-view
subjects:
  - kind: User
    name: "gitlab:project_path:my-group/my-app:ref_type:branch:ref:main"
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: view              # built-in read-only role
  apiGroup: rbac.authorization.k8s.io

Namespace-scoped deploy access

Most deployments only need write access to a single namespace. A namespaced RoleBinding keeps the blast radius small:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: gitlab-myapp-deployer
  namespace: production
subjects:
  - kind: User
    name: "gitlab:project_path:my-group/my-app:ref_type:branch:ref:main"
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: edit              # built-in read/write role
  apiGroup: rbac.authorization.k8s.io

Granting permissions to all GitLab jobs

Every authenticated GitLab job is also a member of two groups:

  • cfke.io:third-party-idp — every CI/CD identity (GitLab or GitHub)
  • cfke.io:third-party-idp:gitlab — every GitLab CI identity

Bind a role to one of these groups to grant a baseline permission to all CI jobs. For example, to let every GitLab job read its own job’s events, replace kind: User with kind: Group and the group name in the binding above. Use this with care: any project on gitlab.com (or your self-hosted GitLab) that points its jobs at your cluster ID will pick up these permissions.

Example: deploying to production from main

The pipeline below applies a manifest update on every commit to main. The cluster trusts pushes to main as a deployer in the production namespace via the RoleBinding above.

.cfke:
  image: debian:13-slim
  variables:
    DEBIAN_FRONTEND: noninteractive
    KUBECTL_VERSION: v1.34
    KUBECONFIG: ${CI_PROJECT_DIR}/kubeconfig
    CFKE_API_SERVER: https://${CFKE_CLUSTER_ID}.${CFKE_REGION}.cfke.io
    CFKE_API_AUD: https://api.cloudfleet.ai/v1/clusters/${CFKE_CLUSTER_ID}
  id_tokens:
    CFKE_TOKEN:
      aud: ${CFKE_API_AUD}
  before_script:
    - apt-get update -qq && apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg
    - install -d /etc/apt/keyrings
    - curl -fsSL "https://pkgs.k8s.io/core:/stable:/${KUBECTL_VERSION}/deb/Release.key" | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    - echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${KUBECTL_VERSION}/deb/ /" > /etc/apt/sources.list.d/kubernetes.list
    - apt-get update -qq && apt-get install -y --no-install-recommends kubectl
    - kubectl config set-cluster cfke --server="${CFKE_API_SERVER}"
    - kubectl config set-credentials gitlab --token="${CFKE_TOKEN}"
    - kubectl config set-context ctx --cluster=cfke --user=gitlab
    - kubectl config use-context ctx

deploy:
  extends: .cfke
  stage: deploy
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - kubectl auth whoami
    - kubectl apply -n production -f k8s/
    - kubectl rollout status -n production deployment/myapp --timeout=5m

CFKE_CLUSTER_ID and CFKE_REGION are inherited from project-level CI/CD variables and substituted into CFKE_API_SERVER and CFKE_API_AUD.

Using a pre-built kubectl image

If you prefer not to install kubectl in every job, swap the image: to one that already has it (for example, bitnami/kubectl) and shorten before_script to just the kubeconfig assembly:

.cfke:
  image:
    name: bitnami/kubectl:1.34
    entrypoint: [""]
  variables:
    KUBECONFIG: ${CI_PROJECT_DIR}/kubeconfig
    CFKE_API_SERVER: https://${CFKE_CLUSTER_ID}.${CFKE_REGION}.cfke.io
    CFKE_API_AUD: https://api.cloudfleet.ai/v1/clusters/${CFKE_CLUSTER_ID}
  id_tokens:
    CFKE_TOKEN:
      aud: ${CFKE_API_AUD}
  before_script:
    - kubectl config set-cluster cfke --server="${CFKE_API_SERVER}"
    - kubectl config set-credentials gitlab --token="${CFKE_TOKEN}"
    - kubectl config set-context ctx --cluster=cfke --user=gitlab
    - kubectl config use-context ctx

Token claims available for admission policies

In addition to the username, CFKE attaches the most useful claims from the GitLab OIDC token to the authenticated user as Kubernetes extras. These are visible to ValidatingAdmissionPolicy and can express checks RBAC cannot:

Extra keySource claimExample
cfke.io/gitlab-projectclaims.project_pathmy-group/my-app
cfke.io/gitlab-refclaims.refmain
cfke.io/gitlab-userclaims.user_loginalice
cfke.io/gitlab-pipeline-idclaims.pipeline_id1834201

The example below denies any modification to resources in the production namespace unless the request comes from a job triggered by a commit to the main branch:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: prod-only-from-main
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: ["*"]
        apiVersions: ["*"]
        operations: ["CREATE", "UPDATE", "DELETE"]
        resources: ["*"]
  validations:
    - expression: |
        !request.userInfo.username.startsWith('gitlab:') ||
        ('cfke.io/gitlab-ref' in request.userInfo.extra &&
         request.userInfo.extra['cfke.io/gitlab-ref'][0] == 'main')        
      message: "Production resources may only be modified from the main branch"

Bind this policy to the production namespace with a ValidatingAdmissionPolicyBinding to enforce it.

Verifying the integration

Add these commands to a job during initial setup to confirm authentication and authorization separately:

verify:
  extends: .cfke
  script:
    - kubectl auth whoami      # proves authentication
    - kubectl auth can-i --list # proves authorization

kubectl auth whoami prints the username and any extras the cluster sees. If this fails with a TLS or token error, authentication itself is broken (wrong cluster ID, wrong region, missing id_tokens block, or — for self-hosted GitLab — an issuer mismatch).

kubectl auth can-i --list prints the verbs and resources the current user is allowed to act on. An empty or near-empty list means RBAC has not been bound yet to this identity.

Security considerations

Bind RBAC to the most specific subject your pipeline produces. The general rule is “what triggered this job is also what the cluster trusts.”

  • Branch-scoped subjects (ref_type:branch:ref:main) are the safest production target. Only commits already merged to the protected branch can act with that identity. Combine with GitLab protected branches to restrict who can push there.
  • Tag-scoped subjects (ref_type:tag:ref:*) suit release pipelines, but anyone who can push tags to the repo can produce that identity. Restrict tag protection if the role is privileged.
  • Environment-scoped subjects (environment:production) compose well with GitLab deployment approvals for an extra approval gate before the cluster trusts the run.
  • Merge request pipelines run with the source branch in their sub. Treat the cluster as compromised by anyone who can open a merge request if you grant cluster permissions to merge request subjects.

CFKE rejects OIDC tokens (from your organization’s identity provider) whose preferred_username would collide with the github: or gitlab: prefixes, so a regular Cloudfleet user cannot impersonate a CI identity.

Reference

Required job configuration

FieldValuePurpose
id_tokens.<NAME>.audhttps://api.cloudfleet.ai/v1/clusters/<cluster-id>Tells GitLab to mint an ID token scoped to this cluster.
Cluster API URLhttps://<cluster-id>.<region>.cfke.ioAPI server endpoint kubectl should target. Served with a publicly trusted TLS certificate.

The id_tokens keyword exposes the JWT as the environment variable named on the left-hand side (in the snippets above, CFKE_TOKEN). Subsequent shell commands can read that variable to assemble the kubeconfig.

Self-hosted GitLab

If your GitLab instance runs at a URL other than https://gitlab.com, the cluster needs to be configured with that issuer URL at provisioning time. The default trusted issuer is https://gitlab.com. Contact Cloudfleet support to override it.

Troubleshooting

error: You must be logged in to the server (Unauthorized)

The token reached the API server but was rejected. Common causes:

  • Wrong cluster ID or region. The audience the job declares must match the cluster’s own issuer URL exactly.
  • Self-hosted GitLab whose issuer URL has not been registered with the cluster. Verify with cloudfleet clusters describe or contact support.
  • The id_tokens block was omitted, so CFKE_TOKEN is empty.

Error from server (Forbidden): ... User "gitlab:project_path:..." cannot ...

Authentication succeeded; authorization failed. The error message contains the exact User the cluster saw. Bind a RoleBinding or ClusterRoleBinding to that user (see Granting permissions with RBAC).

kubectl succeeds early in the job but fails later

GitLab ID tokens are short-lived. Restart the job’s authentication step (re-run before_script) before any kubectl call that may run past the token’s lifetime, or split long-running operations into separate jobs.

Next steps

  • Configure a GitHub Actions integration for projects hosted on GitHub.
  • Review API tokens for cases where OIDC federation is not an option (for example, calling the Cloudfleet API to manage clusters or fleets from CI).
On this page