weekends are for leisure

Least-privileged Headlamp installation on Kubernetes

Tags: #Kubernetes  #Rbac 

When I first explored Kubernetes many many years ago, Kubernetes Dashboard was a thing. However, the Dashboard project page says it is now deprecated and unmaintained, recommending Headlamp instead. So let’s install it! But with care, in least-privileged fashion.

Preparation

Luckily, Headlamp has some pretty good installation docs. I’m going to follow the top-level In-cluster installation using simple yaml if you want to follow along.

The docs show running kubectl to apply a YAML file URL, but let’s download and inspect it first. Click this link to download kubernetes-headlamp.yaml.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
kind: Service
apiVersion: v1
metadata:
  name: headlamp
  namespace: kube-system
spec:
  ports:
    - port: 80
      targetPort: 4466
  selector:
    k8s-app: headlamp
---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: headlamp
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      k8s-app: headlamp
  template:
    metadata:
      labels:
        k8s-app: headlamp
    spec:
      containers:
        - name: headlamp
          image: ghcr.io/headlamp-k8s/headlamp:latest
          args:
            - "-in-cluster"
            - "-plugins-dir=/headlamp/plugins"
          env:
            - name: HEADLAMP_CONFIG_TRACING_ENABLED
              value: "true"
            - name: HEADLAMP_CONFIG_METRICS_ENABLED
              value: "true"
            - name: HEADLAMP_CONFIG_OTLP_ENDPOINT
              value: "otel-collector:4317"
            - name: HEADLAMP_CONFIG_SERVICE_NAME
              value: "headlamp"
            - name: HEADLAMP_CONFIG_SERVICE_VERSION
              value: "latest"
          ports:
            - containerPort: 4466
              name: http
            - containerPort: 9090
              name: metrics
          readinessProbe:
            httpGet:
              scheme: HTTP
              path: /
              port: 4466
            initialDelaySeconds: 30
            timeoutSeconds: 30
          livenessProbe:
            httpGet:
              scheme: HTTP
              path: /
              port: 4466
            initialDelaySeconds: 30
            timeoutSeconds: 30
      nodeSelector:
        'kubernetes.io/os': linux
---
kind: Secret
apiVersion: v1
metadata:
  name: headlamp-admin
  namespace: kube-system
  annotations:
    kubernetes.io/service-account.name: "headlamp-admin"
type: kubernetes.io/service-account-token

As you can see, it contains three resource types: a Service, a Deployment, and a Secret.

The Service definition contains no Type, which means it defaults to ClusterIP. Thus, the Service will only be reachable from within the cluster. That’s not quite what I’m looking for, since I want to access Headlamp from another machine while on my local network. For this use case, the NodePort type is what I want, since it exposes the service on each Node’s IP address, at a static port. So let’s specify a different Type, and 30000 for the NodePort:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
kind: Service
apiVersion: v1
metadata:
  name: headlamp
  namespace: kube-system
spec:
  type: NodePort
  ports:
    - port: 80
      targetPort: 4466
      nodePort: 30000
  selector:
    k8s-app: headlamp

At this point, I saved the current configuration in a file called headlamp.yaml.

Granting permissions

If you scroll down to the Accessing Headlamp section of the documentation, it mentions that you can enable access by creating a service account, or by setting up OIDC. I’m going to use a service account since I want to keep things simple.

The main Installation page has commands for creating a Service Account, Cluster Role Binding, and Service Account Token. It also has this enlightening bit of information:

Headlamp uses RBAC for checking users’ access to resources. This means that the recommended way to log in to Headlamp is to use a Service Account token.

Here are the commands, but don’t run them because we’re going to take a different approach. Remember, I want to create a least-privileged installation:

1
2
3
kubectl -n kube-system create serviceaccount headlamp-admin
kubectl create clusterrolebinding headlamp-admin --serviceaccount=kube-system:headlamp-admin --clusterrole=cluster-admin
kubectl create token headlamp-admin -n kube-system

If you look at the second command, it creates a Cluster Role Binding between the headlamp-admin Service Account and the cluster-admin Cluster Role. This seems like a bad idea… I don’t want to give Headlamp “cluster admin” privileges!

So let’s see if there’s another role we can use.

1
2
3
4
5
$ kubectl get clusterroles
. . .
system:service-account-issuer-discovery                                2026-06-07T18:53:33Z
system:volume-scheduler                                                2026-06-07T18:53:33Z
view                                                                   2026-06-07T18:53:33Z

Yay! There is a view Cluster Role. Let’s use the yaml for view as a foundation for a new least-privileged Role.

1
$ kubectl get clusterrole view -o yaml

Starting with the yaml from the view Cluster Role, I changed the Role name to headlamp-view and removed portions of the doc that didn’t seem relevant.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: headlamp-view
rules:
- apiGroups:
  - ""
  resources:
  - configmaps
  - endpoints
  - persistentvolumeclaims
  - persistentvolumeclaims/status
  - pods
  - replicationcontrollers
  - replicationcontrollers/scale
  - serviceaccounts
  - services
  - services/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  resources:
  - bindings
  - limitranges
  - namespaces/status
  - pods/log
  - pods/status
  - replicationcontrollers/status
  - resourcequotas
  - resourcequotas/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  resources:
  - namespaces
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  - events.k8s.io
  resources:
  - events
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - discovery.k8s.io
  resources:
  - endpointslices
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - apps
  resources:
  - controllerrevisions
  - daemonsets
  - daemonsets/status
  - deployments
  - deployments/scale
  - deployments/status
  - replicasets
  - replicasets/scale
  - replicasets/status
  - statefulsets
  - statefulsets/scale
  - statefulsets/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - autoscaling
  resources:
  - horizontalpodautoscalers
  - horizontalpodautoscalers/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - batch
  resources:
  - cronjobs
  - cronjobs/status
  - jobs
  - jobs/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - extensions
  resources:
  - daemonsets
  - daemonsets/status
  - deployments
  - deployments/scale
  - deployments/status
  - ingresses
  - ingresses/status
  - networkpolicies
  - replicasets
  - replicasets/scale
  - replicasets/status
  - replicationcontrollers/scale
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - policy
  resources:
  - poddisruptionbudgets
  - poddisruptionbudgets/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - networking.k8s.io
  resources:
  - ingresses
  - ingresses/status
  - networkpolicies
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - resource.k8s.io
  resources:
  - resourceclaims
  - resourceclaims/status
  - resourceclaimtemplates
  verbs:
  - get
  - list
  - watch

Now let’s define the new Service Account:

1
2
3
4
5
6
7
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: headlamp-view
  namespace: kube-system
---

And finally, bind the two together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: headlamp-view
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: headlamp-view
subjects:
- kind: ServiceAccount
  name: headlamp-view
  namespace: kube-system

If we save our updated headlamp.yaml file and apply it, we should end up with a nearly complete headlamp installation including a Service Account with some minimal permissions.

1
$ kubectl apply -f headlamp.yaml

The only remaining piece is to generate a Service Account Token. Run the command we reviewed previously:

1
$ kubectl create token headlamp-view -n kube-system

Copy the token, then open http://CLUSTER_IP:30000 in your browser. Paste the token, press the Authenticate button, and you should see the Headlamp UI.

But we’re not done yet …

If you visit the Nodes page, you should see “You don’t have permissions to view this resource”. Expand the “Details” link and you should see:

nodes is forbidden: User “system:serviceaccount:kube-system:headlamp-view” cannot list resource “nodes” in API group "" at the cluster scope

Looks like we need to give our Cluster Role more permissions.

Granting more privileges

Follow the hint given by the error message and update your YAML with additional permissions for your headlamp-view Role:

1
2
3
4
5
6
7
8
- apiGroups:
  - ""
  resources:
  - nodes
  verbs:
  - get
  - list
  - watch

Re-apply the updated YAML, and refresh the Nodes page. You should see the error message go away.

I continued this process of exploring the UI, and updating the Role with additional permissions as needed. I encountered many new resource types in the process, which helped expand my knowledge of what Kubernetes is capable of.

My final read-only Cluster Role permissions can be found below. Now I can use Headlamp without wondering whether it’s capable of doing something malicious due to having given it too many permissions.

Thanks for reading!

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: headlamp-view
rules:
- apiGroups:
  - ""
  resources:
  - configmaps
  - endpoints
  - persistentvolumeclaims
  - persistentvolumeclaims/status
  - pods
  - replicationcontrollers
  - replicationcontrollers/scale
  - serviceaccounts
  - services
  - services/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  resources:
  - bindings
  - limitranges
  - namespaces/status
  - pods/log
  - pods/status
  - replicationcontrollers/status
  - resourcequotas
  - resourcequotas/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  resources:
  - namespaces
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  - events.k8s.io
  resources:
  - events
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - discovery.k8s.io
  resources:
  - endpointslices
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - apps
  resources:
  - controllerrevisions
  - daemonsets
  - daemonsets/status
  - deployments
  - deployments/scale
  - deployments/status
  - replicasets
  - replicasets/scale
  - replicasets/status
  - statefulsets
  - statefulsets/scale
  - statefulsets/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - autoscaling
  resources:
  - horizontalpodautoscalers
  - horizontalpodautoscalers/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - batch
  resources:
  - cronjobs
  - cronjobs/status
  - jobs
  - jobs/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - extensions
  resources:
  - daemonsets
  - daemonsets/status
  - deployments
  - deployments/scale
  - deployments/status
  - ingresses
  - ingresses/status
  - networkpolicies
  - replicasets
  - replicasets/scale
  - replicasets/status
  - replicationcontrollers/scale
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - policy
  resources:
  - poddisruptionbudgets
  - poddisruptionbudgets/status
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - networking.k8s.io
  resources:
  - ingresses
  - ingresses/status
  - networkpolicies
  - ingressclasses
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - gateway.networking.k8s.io
  resources:
  - gateways
  - gatewayclasses
  - httproutes
  - grpcroutes
  - referencegrants
  - backendtlspolicies
  - xbackendtrafficpolicies
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - resource.k8s.io
  resources:
  - resourceclaims
  - resourceclaims/status
  - resourceclaimtemplates
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  - "metrics.k8s.io"
  resources:
  - nodes
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - "rbac.authorization.k8s.io"
  resources:
  - roles
  - clusterroles
  - rolebindings
  - clusterrolebindings
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  resources:
  - persistentvolumes
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - "storage.k8s.io"
  resources:
  - storageclasses
  - volumeattributesclasses
  verbs:
  - get
  - list
  - watch


Older Post