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 logsneeded - 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.