Originally published on kuryzhev.cloud
We found our Kubernetes Dashboard sitting on a NodePort, reachable from the internet, during a routine Shodan sweep we ran against our own IP ranges. No VPN in front of it, no Access policy, just `kubectl proxy --address=0.0.0.0` leftover from a debugging session three weeks earlier. That's the scenario that pushed us to build a Kubernetes Dashboard Cloudflare Tunnel setup and turn it into a repeatable checklist instead of a one-off fix.
Why this checklist
Exposing Kubernetes Dashboard directly — via NodePort, LoadBalancer, or an open kubectl proxy — is one of the most common ways clusters get taken over. Censys and Shodan both index thousands of dashboards with default configs, and if the ClusterRoleBinding is cluster-admin (which it often is out of the box), an attacker with dashboard access has full cluster access. We've seen this in the wild on client audits more than once.
The traditional fix is a VPN: WireGuard or OpenVPN in front of the cluster, client software on every laptop, IP allowlists that break the moment someone works from a coffee shop. It works, but it's heavy for what is really a single-app access problem. Cloudflare Tunnel plus Zero Trust Access solves this differently — cloudflared makes an outbound-only connection from inside the cluster to Cloudflare's edge, so there's nothing to open inbound at all. Access then sits in front of the tunnel hostname and enforces identity per request, not per network.
This checklist assumes you already have a running Kubernetes Dashboard (we're referencing v2.7.0, chart 6.x) and a Cloudflare account with Zero Trust enabled. The free tier covers up to 50 seats, which is plenty for internal engineering access — check the Cloudflare Access docs for current pricing tiers if your team is bigger. We're not covering dashboard installation here, just how to expose it safely once it exists.
The checklist (numbered)
Run through these in order. Each item includes why it matters and the exact thing to verify.
-
cloudflared runs in-cluster, not on a jump box. A jump box is another host to patch and another single point of failure. Deploy it as a Kubernetes Deployment in
kube-systemso it's managed the same way as everything else. -
Tunnel credentials go into a Secret, never a ConfigMap. The output of
cloudflared tunnel create k8s-dashboardis a JSON file containing your TunnelSecret — anyone with read access to a ConfigMap can hijack the tunnel. -
Ingress rule matches the hostname exactly. In
config.yaml,hostname: dashboard.example.commust be precise — a typo or wildcard mismatch means cloudflared falls through to the catch-all 404 rule and you'll spend twenty minutes assuming the tunnel is broken. -
Origin service targets ClusterIP, never NodePort. The whole point of the tunnel is that nothing needs to be node-reachable. Point it at
https://kubernetes-dashboard.kube-system.svc:443. - An Access application exists in Zero Trust with a real policy. Email domain rule or service token — either works, but "no policy" means anyone who resolves the hostname gets to the dashboard's own login screen at best.
- Session duration is set explicitly. Default Access sessions can run long; for an admin tool like the dashboard, we set ours to a few hours, not the default 24.
- Dashboard RBAC is scoped, not cluster-admin. Check the ClusterRoleBinding — this is the single most damaging default in the whole stack.
-
No
--enable-skip-loginflag anywhere in the Dashboard args. This flag bypasses the dashboard's own auth entirely, meaning even a correctly configured tunnel leaves you exposed to anyone who reaches the origin directly. - NetworkPolicy restricts ingress to the dashboard Service to cloudflared pods only. This is your last line of defense if the tunnel config is ever wrong.
-
TLS verification is on at the origin —
noTLSVerify: falsewithoriginServerNameset to avoid SNI mismatch errors against the in-cluster service certificate.
Verify the whole chain with two requests: one without an Access session (expect 403), one from an authenticated browser (expect 200 and the actual dashboard UI).
Commonly missed items
The happy-path setup usually works on the first try — that's exactly why teams stop checking. Here's what we found missing during later audits.
The old NodePort or LoadBalancer Service is still there. Migrating to a tunnel doesn't remove the previous exposure automatically. We've walked into environments where the dashboard was reachable on both the new tunnel hostname and the original NodePort, because nobody ran kubectl delete svc after cutting over.
Tunnel credentials never get rotated. The same creds.json from the initial cloudflared tunnel create gets copied into every environment — dev, staging, prod — because rotating it means recreating Secrets everywhere. Use cloudflared tunnel token <tunnel-name> to reissue tokens without recreating the tunnel itself.
Access logs never leave Cloudflare. Failed authentication attempts are visible in the Zero Trust dashboard, but if nobody's piping Access logs to a SIEM via Logpush, nobody notices a brute-force pattern until it's too late.
Dashboard image and RBAC drift over time. We've seen the image pinned to an old tag with known skip-login CVEs, and the ClusterRoleBinding quietly reverted to cluster-admin after a Helm upgrade reset values. Neither of these show up unless you're specifically checking for them.
Automation ideas
Manual checklists rot. Here's how we turned this one into something enforced.
We codified the tunnel, DNS CNAME, Access application, and Access policy with the Terraform cloudflare provider — one PR, one review, one apply. That killed the "who changed the Access policy in the dashboard UI" problem entirely. See the Cloudflare Terraform provider docs for the resource types you'll need (cloudflare_tunnel, cloudflare_access_application, cloudflare_access_policy).
The cloudflared Deployment and ConfigMap live in our GitOps repo, synced by Argo CD — any manual kubectl edit on the config gets reverted within a sync cycle, which catches drift before it becomes a security gap.
We run a scheduled GitHub Action that calls the Cloudflare API and diffs live Access policies against Terraform state, alerting on Slack if they don't match — this is what caught someone adding a temporary "allow all" rule during an incident and forgetting to remove it.
Finally, a Kyverno policy in the dashboard namespace blocks any Service of type: LoadBalancer or NodePort from being created at all, which closes the exact gap that started this whole checklist. If you're building out policy enforcement more broadly, we cover related patterns in our DevOps_DayS posts.
Here's the full manifest we deploy — Secret, ConfigMap, Deployment, and NetworkPolicy in one file, ready to adapt for your hostname and namespace.
# cloudflared-dashboard.yaml
# Deploys cloudflared in-cluster to tunnel traffic to the Kubernetes Dashboard
# without exposing any NodePort/LoadBalancer.
apiVersion: v1
kind: Secret
metadata:
name: cloudflared-creds
namespace: kube-system
type: Opaque
stringData:
creds.json: |
# Output of: cloudflared tunnel create k8s-dashboard
# NEVER commit this raw — inject via sealed-secrets/external-secrets in real repos
{"AccountTag":"xxxx","TunnelSecret":"xxxx","TunnelID":"xxxx"}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cloudflared-config
namespace: kube-system
data:
config.yaml: |
tunnel: k8s-dashboard
credentials-file: /etc/cloudflared/creds.json
protocol: quic
ingress:
# Exact hostname match — anything else falls to the catch-all below
- hostname: dashboard.example.com
service: https://kubernetes-dashboard.kube-system.svc:443
originRequest:
noTLSVerify: false
originServerName: kubernetes-dashboard.kube-system.svc
- service: http_status:404 # catch-all, required by cloudflared
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: kube-system
spec:
replicas: 2 # HA: single replica drops the tunnel on pod restart
selector:
matchLabels: {app: cloudflared}
template:
metadata:
labels: {app: cloudflared}
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:2024.10.1
args: ["tunnel", "--config", "/etc/cloudflared/config.yaml", "run"]
volumeMounts:
- name: config
mountPath: /etc/cloudflared
- name: creds
mountPath: /etc/cloudflared/creds.json
subPath: creds.json
volumes:
- name: config
configMap: {name: cloudflared-config}
- name: creds
secret: {secretName: cloudflared-creds}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: dashboard-allow-tunnel-only
namespace: kube-system
spec:
podSelector:
matchLabels: {k8s-app: kubernetes-dashboard}
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels: {app: cloudflared}
Two 502s and a context-deadline error are the most common failures you'll hit while testing this. A 502 Bad Gateway from the Cloudflare edge means cloudflared can't reach the ClusterIP — check the NetworkPolicy and the namespace in your ingress rule first. An error="context deadline exceeded" in the cloudflared logs usually means the origin service is unhealthy or you've got the wrong port. Watch out for both — they look like Cloudflare-side problems but are almost always local misconfiguration.
Run these verification commands after deployment to confirm the whole chain is actually locked down, not just "looks configured."
# Verification checklist commands and expected output
# 1. Confirm old NodePort/LoadBalancer is gone (Commonly missed item #1)
kubectl -n kube-system get svc kubernetes-dashboard -o jsonpath='{.spec.type}'
# Expected: ClusterIP (NOT NodePort/LoadBalancer)
# 2. Confirm tunnel is healthy and connected
cloudflared tunnel info k8s-dashboard
# Expected output includes:
# NAME: k8s-dashboard
# ID: xxxxx
# CONNECTOR ID: xxxxx CONNECTED (2/2) <- matches replica count
# 3. Test unauthenticated request -> must be blocked by Access
curl -I https://dashboard.example.com
# Expected: HTTP/2 403 (Cloudflare Access login page, not the dashboard)
# 4. Check Dashboard RBAC isn't cluster-admin (Mistake #1)
kubectl get clusterrolebinding kubernetes-dashboard -o jsonpath='{.roleRef.name}'
# BAD: cluster-admin
# GOOD: kubernetes-dashboard (scoped role)
If step 3 returns 200 instead of 403, stop — your Access policy isn't attached correctly, and everything downstream is meaningless until it is. This is worth testing from an incognito browser and a raw curl, since browser sessions can mask a broken policy if you're already authenticated from a previous test.
Once all four checks pass, you've got a Kubernetes Dashboard Cloudflare Tunnel setup that's outbound-only, identity-gated, and scoped down to a non-admin role — no VPN client, no open ports, and no NodePort left dangling for the next Shodan scan to find. Re-run this checklist after every dashboard upgrade or Helm values change; that's usually where the drift creeps back in.
Top comments (0)