Headlamp: a Kubernetes UI that doesn't require kubectl

Published: 2026-02-05

Headlamp is an open-source, extensible Kubernetes dashboard. It's stateless, runs as a Deployment, and authenticates users via OIDC — so every engineer gets their own scoped view without sharing a single cluster admin token.

This post covers the full production setup: HelmRelease configuration, OIDC integration with GitLab, RBAC wiring, multi-cluster support, and the operational patterns we use day-to-day.

Why Headlamp over the default Dashboard

The official Kubernetes Dashboard requires either token-based auth or a proxy tunnel. Neither scales: token auth means sharing a long-lived credential, and proxy tunnels mean every engineer needs kubectl and cluster access just to open a browser tab.

Headlamp supports OIDC out of the box: users log in with their GitLab account, Headlamp exchanges the token with the cluster's API server, and gets back exactly what RBAC allows. No token to rotate, no proxy to maintain, no kubectl required for read access.

It also has a plugin system. The most useful built-in: headlamp-plugin/prometheus — shows resource graphs directly on the pod page without leaving the UI. You can see CPU/memory trends for a pod without opening Grafana.

Other built-in capabilities that replace daily kubectl commands:

  • Live log streaming — same as kubectl logs -f, with colour coding and search
  • Exec into containers — same as kubectl exec -it, in-browser terminal
  • Resource editing — YAML editor with schema validation
  • Event timeline — per-namespace event stream with filtering

HelmRelease

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: headlamp
  namespace: observability
spec:
  chart:
    spec:
      chart: headlamp
      version: "0.25.*"
      sourceRef:
        kind: HelmRepository
        name: headlamp
        namespace: flux-system
  values:
    replicaCount: 1
    config:
      oidc:
        clientID: "${HEADLAMP_OIDC_CLIENT_ID}"
        clientSecret: "${HEADLAMP_OIDC_CLIENT_SECRET}"
        issuerURL: "https://gitlab.example.com"
        scopes: "openid profile email groups"
    ingress:
      enabled: false   # handled by ApisixRoute
    resources:
      requests:
        cpu: 50m
        memory: 64Mi
      limits:
        cpu: 200m
        memory: 128Mi

OIDC client ID and secret come from an ExternalSecret pulling from Vault. The issuerURL is the GitLab instance — GitLab implements OIDC natively, no extra configuration needed.

Note the groups scope: without it, the OIDC token doesn't include the user's GitLab group memberships, and Kubernetes RBAC can't map groups to roles.

GitLab OIDC application setup

In GitLab, create an application under Admin > Applications (for instance-level) or Group > Settings > Applications (for group-level):

  • Redirect URI: https://headlamp.dev.example.com/oidc-callback
  • Scopes: openid, profile, email, groups
  • Save the Application ID and Secret to Vault under secret/headlamp/oidc

The ExternalSecret:

yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: headlamp-oidc
  namespace: observability
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: headlamp-oidc-secret
    creationPolicy: Owner
  data:
    - secretKey: clientID
      remoteRef:
        key: secret/headlamp/oidc
        property: client_id
    - secretKey: clientSecret
      remoteRef:
        key: secret/headlamp/oidc
        property: client_secret

Then in the HelmRelease valuesFrom:

yamlvaluesFrom:
  - kind: Secret
    name: headlamp-oidc-secret
    valuesKey: clientID
    targetPath: config.oidc.clientID
  - kind: Secret
    name: headlamp-oidc-secret
    valuesKey: clientSecret
    targetPath: config.oidc.clientSecret

ApisixRoute

Headlamp gets its own route in the routes layer:

yamlapiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: headlamp
  namespace: ingress-apisix
spec:
  http:
    - name: headlamp
      match:
        hosts:
          - headlamp.dev.example.com
        paths:
          - "/*"
      backends:
        - serviceName: headlamp
          serviceNamespace: observability
          servicePort: 80

The route has no authentication plugin at the APISIX level — auth is handled entirely by Headlamp's OIDC flow. APISIX just proxies the request.

If you want to restrict access to the Headlamp URL by IP before the OIDC redirect (defense in depth), add an ip-restriction plugin:

yamlplugins:
  - name: ip-restriction
    enable: true
    config:
      whitelist:
        - "10.0.0.0/8"
        - "192.168.0.0/16"

RBAC for OIDC users

Headlamp passes the OIDC groups claim from GitLab to the Kubernetes API. The API server uses this claim for RBAC evaluation. A ClusterRoleBinding maps the GitLab group to a ClusterRole:

yamlapiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: headlamp-dev-readonly
subjects:
  - kind: Group
    name: "example/dev-team"
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: view
  apiGroup: rbac.authorization.k8s.io

The name in subjects must exactly match the group path as returned by GitLab in the OIDC groups claim. GitLab returns the full path: parentgroup/subgroup. If your group is acme-corp/platform/dev-team, use that exact string.

Our tiered access model:

GitLab Group ClusterRole Can do
org/devops-team cluster-admin Everything
org/platform-team edit Deploy, scale, restart pods
org/dev-team view Read-only, no secrets
org/qa-team custom qa-viewer Read pods/logs, no configs

The custom qa-viewer role:

yamlapiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: qa-viewer
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log", "pods/exec", "services", "endpoints"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
    verbs: ["get", "list", "watch"]

QA engineers can view pods and stream logs but can't read Secrets or modify anything. All bindings live in spoke/rbac/ and are applied by the spoke Kustomization — separate from the Headlamp HelmRelease so RBAC changes don't require a Headlamp rollout.

Multi-cluster in one UI

Headlamp discovers clusters from the kubeconfig mounted into the pod. For multi-cluster use, a Secret with multiple kubeconfig contexts is mounted at /headlamp/kubeconfig:

yamlvolumes:
  - name: kubeconfigs
    secret:
      secretName: headlamp-kubeconfigs
volumeMounts:
  - name: kubeconfigs
    mountPath: /headlamp/kubeconfig
    readOnly: true

The secret contains a merged kubeconfig with contexts for all clusters. Engineers see a cluster selector dropdown in the top-left and switch between dev, test, sre, etc. without a separate browser tab.

Generating the merged kubeconfig:

bashKUBECONFIG=kubeconfigs/dev.yaml:kubeconfigs/test.yaml:kubeconfigs/sre.yaml \
  kubectl config view --flatten > merged-kubeconfig.yaml

kubectl create secret generic headlamp-kubeconfigs \
  --from-file=config=merged-kubeconfig.yaml \
  -n observability \
  --dry-run=client -o yaml | \
  kubeseal --cert flux-sealed-secrets.pem --format yaml > headlamp-kubeconfigs-sealedsecret.yaml

When you add a cluster, regenerate the merged kubeconfig, reseal the secret, and commit. Headlamp picks it up on the next pod restart.

Plugin management

Headlamp plugins are loaded from the /headlamp/plugins directory inside the container. There are two ways to install them:

1. Init container approach (for custom or community plugins):

yamlinitContainers:
  - name: install-plugins
    image: alpine:3.19
    command:
      - sh
      - -c
      - |
        wget -O /plugins/prometheus.tar.gz \
          https://github.com/headlamp-k8s/plugins/releases/download/v0.8.0/prometheus-0.8.0.tar.gz
        tar -xzf /plugins/prometheus.tar.gz -C /plugins/
    volumeMounts:
      - name: plugins
        mountPath: /plugins

2. ConfigMap approach (for small plugins, not recommended for production):

Mount the plugin JavaScript directly from a ConfigMap. Works for testing but makes plugin updates a ConfigMap update, which is awkward.

The init container approach is cleaner: version pin the plugin archive, the checksum is implicit in the git history.

Useful keyboard shortcuts and tips

  • Ctrl+Shift+K — open command palette (find any resource fast)
  • Clicking a pod name → live logs tab, no kubectl logs needed
  • The "Exec" tab gives a terminal inside the container — same as kubectl exec -it
  • The "Describe" tab shows the full resource YAML with annotations — faster than kubectl describe

Filtering by label

Headlamp's resource list supports label selector filtering in the search bar. Type app=redis and the list filters to matching resources. This works across all resource types — useful when you have 200 pods and need to find all pods for a specific service.

Port forwarding

Headlamp supports port-forward from the UI: open a pod → "Port Forward" tab → enter local and container port. Works the same as kubectl port-forward but without a terminal.

Troubleshooting

OIDC login fails with "invalid client"

The client ID or secret is wrong, or the redirect URI in GitLab doesn't match the Headlamp URL exactly (including the /oidc-callback suffix).

User can log in but sees "forbidden" on everything

The groups claim is not being sent. Check the OIDC scopes — groups must be included. Also verify the OIDC groups claim in the token with jwt.io: decode the ID token from the browser's network tab and check the groups field.

Multi-cluster kubeconfig shows old cluster names

Headlamp caches the kubeconfig on startup. Restart the pod after updating the kubeconfigs secret: kubectl rollout restart deployment/headlamp -n observability.