← All blogs

Expose the MicroK8s Dashboard Safely with Cloudflare Tunnel and systemd

Sep 2026 · 10 min read · Infrastructure

The Kubernetes dashboard is the most useful tool on a homelab cluster and the most dangerous thing to leave on the open internet. A single misconfigured RBAC binding hands the keys to the entire cluster away. This guide pairs MicroK8s's bundled dashboard with a Cloudflare Tunnel so it stays reachable from anywhere without exposing a port — and uses systemd to keep the port-forward alive across reboots and tunnel reconnects.

Diagram: a browser connects over HTTPS to the Cloudflare edge, which reaches the MicroK8s dashboard through an outbound tunnel, a systemd-managed localhost port-forward, and the kubernetes-dashboard-kong-proxy service.
The tunnel carries encrypted traffic into localhost; systemd keeps the port-forward up so the tunnel has something to dial.

Why this shape

MicroK8s's dashboard isn't a thing you point a tunnel at directly — it's a Kubernetes Service of type ClusterIP, which by definition has no external IP. The dashboard addon (which is really kubernetes-dashboard fronted by Kong) listens on :443 inside the cluster only. To reach it from the host you have two options: an ingress with a NodePort, or a kubectl port-forward. The first leaks a port on every node IP; the second stays on 127.0.0.1 where only the tunnel can see it.

  • No NodePort, no public IP. The dashboard service stays ClusterIP.
  • Loopback only. The port-forward binds 127.0.0.1:8443, so nothing on the LAN — let alone the internet — can hit it directly.
  • Cloudflare Access in front of it. Same gate you put in front of Cockpit; a one-time PIN before any token is requested.
  • Self-healing. systemd restarts the port-forward if it dies, and cloudflared reconnects automatically when the tunnel blips.
Conventions used below. k8s.example.com is the public hostname you'll route through Cloudflare, ops and admin are the two dashboard service accounts. Replace both with your own values.

What you'll end up with

LayerAddressOwned by
Public URLhttps://k8s.example.comCloudflare edge
Tunnel target on the hosthttps://localhost:8443cloudflared
systemd-managed port-forward127.0.0.1:8443 → kubernetes-dashboard-kong-proxy:443microk8s-dashboard-forward.service
Dashboard servicekubernetes-dashboard/kong-proxy:443MicroK8s dashboard addon

Prerequisites

  • MicroK8s installed and joined on a single node (microk8s status --wait-ready shows running)
  • A cloudflared tunnel already created and running on the same host — this guide assumes you've already done the Cockpit setup, so the daemon is installed and authenticated
  • A hostname in your Cloudflare zone (here, k8s.example.com) that you can add a public hostname to

Step 1 — Enable the dashboard addon

sudo microk8s status --wait-ready
sudo microk8s enable dashboard

The addon pulls down the dashboard manifests and Kong (its ingress proxy) and waits for them to roll out. Confirm the service exists before continuing:

sudo microk8s kubectl get svc -A | grep dashboard

You should see:

kubernetes-dashboard   kubernetes-dashboard-kong-proxy   ClusterIP   10.152.183.123   <none>   443/TCP   3m

ClusterIP with no external IP is correct — that's the whole reason for the port-forward in the next step. If the addon reports enabled in microk8s status but the service is missing, give it another minute: Kong and the dashboard rollout independently and the service only appears once both are ready.

Step 2 — Forward the dashboard to localhost

sudo microk8s kubectl -n kubernetes-dashboard port-forward \
  --address 127.0.0.1 \
  svc/kubernetes-dashboard-kong-proxy 8443:443

The dashboard is now reachable at https://127.0.0.1:8443. Two details are doing real work here:

  • --address 127.0.0.1 binds the listener to loopback only. Without it, kubectl binds to 0.0.0.0 and any host on your LAN can reach the dashboard — defeating the whole exercise.
  • 8443:443 maps host loopback :8443 to the service's :443. We use 8443 on the host because :443 is where the tunnel and other services already live.

Try it interactively before going further. Ctrl+C to stop — in the next step we'll hand this same command to systemd so it survives the next reboot.

Step 3 — Route it through the existing tunnel

Open the Cloudflare Zero Trust dashboard and go to Networks → Tunnels → your tunnel → Public Hostname → Add a public hostname. Fill in:

FieldValue
Subdomaink8s
Domainexample.com
TypeHTTPS
URLlocalhost:8443

Under Additional application settings → TLS, turn on No TLS Verify. Kong serves its own self-signed certificate that won't match the public hostname, so verification would fail without it. The public leg of the connection (browser ↔ Cloudflare edge) is still fully verified TLS — this only relaxes the edge ↔ origin hop, exactly like the Cockpit route in the previous guide.

Save, then verify DNS and reachability:

dig +short k8s.example.com
curl -sk https://k8s.example.com | head -20

A Cloudflare-issued certificate on the response and dashboard HTML in the body mean the whole chain is up. The browser will warn about an untrusted cert on 127.0.0.1:8443 only if you've bypassed the tunnel and hit the port-forward directly — which you shouldn't be doing from a browser anyway.

If you've put the Cockpit tunnel behind Cloudflare Access, do the same here. The dashboard's login form is the only thing standing between the open internet and cluster-admin. Treat k8s.example.com the same way: require an email OTP before any token is even requested. Five minutes in Zero Trust beats a leaked token rotation any day.

Step 4 — Keep the port-forward alive with systemd

The kubectl port-forward from Step 2 is a foreground process — the moment your SSH session closes or the host reboots, it dies. Tunnel reconnects can't reach the dashboard if nothing is listening on 127.0.0.1:8443. systemd fixes this in about a dozen lines:

sudo nano /etc/systemd/system/microk8s-dashboard-forward.service
[Unit]
Description=MicroK8s Dashboard Port Forward
After=network-online.target snap.microk8s.daemon-kubelite.service
Wants=network-online.target
Requires=snap.microk8s.daemon-kubelite.service

[Service]
Type=simple
ExecStart=/snap/bin/microk8s kubectl -n kubernetes-dashboard port-forward --address 127.0.0.1 svc/kubernetes-dashboard-kong-proxy 8443:443
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Three lines are doing the actual work:

  • Requires=snap.microk8s.daemon-kubelite.service makes systemd refuse to start the unit until kubelite (MicroK8s's API server) is up. Without it, the unit races the API server on boot and fails a few times before settling.
  • Restart=always + RestartSec=5 is the self-healing part. The port-forward occasionally drops when the API server restarts for an addon upgrade — this re-establishes it without intervention.
  • WantedBy=multi-user.target starts it at boot, in the normal services runlevel.

Reload systemd, enable the unit, and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now microk8s-dashboard-forward.service
sudo systemctl status microk8s-dashboard-forward.service
Use the enable --now shorthand. It enables the unit at boot and starts it immediately, in one command. enable alone only flips the boot flag; start alone only starts it once. For new units you almost always want both.

Step 5 — Create the admin service accounts

The dashboard ships with no default user. Create the accounts you want, bind them to cluster-admin (full control), and mint a bearer token for each. The dashboard treats these accounts as distinct users — they show up separately in the audit log, so two accounts is more useful than one shared one.

The first:

sudo microk8s kubectl create serviceaccount ops -n kubernetes-dashboard
sudo microk8s kubectl create clusterrolebinding ops \
  --clusterrole=cluster-admin \
  --serviceaccount=kubernetes-dashboard:ops

sudo microk8s kubectl -n kubernetes-dashboard create token ops

The second, same shape:

sudo microk8s kubectl create serviceaccount admin -n kubernetes-dashboard
sudo microk8s kubectl create clusterrolebinding admin \
  --clusterrole=cluster-admin \
  --serviceaccount=kubernetes-dashboard:admin

sudo microk8s kubectl -n kubernetes-dashboard create token admin
Treat those tokens like passwords. Each one is a bearer credential for cluster-admin on your cluster. They're valid for one hour by default; mint them at login time rather than storing them. Never paste them into chat, screenshots, or version control. If one leaks, delete the service account and recreate it — there's no concept of "rotating" a token without invalidating every existing session of that user.

Step 6 — Log in

Open https://k8s.example.com. If you've put the hostname behind Cloudflare Access, complete the email OTP first. On the dashboard's sign-in page, choose Token, paste either token from Step 5, and you land in the cluster overview.

Tokens expire after one hour by default. If you find yourself minting new tokens several times a day, the dashboard's session is doing what it should — a long-lived admin token sitting in a browser is exactly the failure mode this design avoids.

Step 7 — Useful checks

# Cluster and addon status
sudo microk8s status
sudo microk8s kubectl get pods -n kubernetes-dashboard
sudo microk8s kubectl get svc -n kubernetes-dashboard

# Port-forward itself
sudo systemctl status microk8s-dashboard-forward.service
sudo ss -lntp | grep 8443
sudo journalctl -u microk8s-dashboard-forward.service -n 50 --no-pager

When something breaks, run these in order. Most failures are visible at one of three layers: the addon (microk8s status), the port-forward (ss -lntp for the listener, journalctl for the unit), or the public route (dig for DNS, then a curl -sk from outside).

Troubleshooting

SymptomCause and fix
Dashboard service missing after enable dashboardKong and the dashboard rollout independently. Wait a minute and rerun kubectl get svc -A.
port-forward exits immediatelyWrong service name or namespace. Verify with kubectl get svc -n kubernetes-dashboard.
connection refused on 127.0.0.1:8443Unit not running. systemctl status microk8s-dashboard-forward.service.
Unit fails on boot with connection refusedPort-forward raced the API server. Requires=snap.microk8s.daemon-kubelite.service in the unit fixes this — Step 4.
522 from Cloudflare on the public URLTunnel is up but 127.0.0.1:8443 isn't. Check the systemd unit, not cloudflared.
526 from Cloudflare on the public URLTLS handshake failed origin-side. No TLS Verify must be ON for that public hostname (Step 3).
Dashboard logs in but namespaces look emptyToken bound to a ServiceAccount without cluster-wide view. Check the ClusterRoleBinding with kubectl get clusterrolebinding ops -o yaml.
Forbidden everywhere after loginToken created in the wrong namespace. Tokens are namespace-scoped to the ServiceAccount — make sure you're minting from -n kubernetes-dashboard.

Closing notes

What holds this setup together isn't the tunnel — it's the three boundaries that never collapse: the dashboard service stays ClusterIP, the port-forward stays on loopback, and the public hostname stays behind Cloudflare Access. Each one is a single configuration line. Take any of them away and the attack surface grows back in the same place you cut it.

The systemd unit is the smallest piece but the easiest to skip. Without it the tunnel reaches nothing on every reboot and you only notice when you actually need the dashboard. Treat it like any other boot-time service and you stop thinking about it entirely.

← All blogs