user@homelab:~/docs$ cat public-site.md

Public site (Cloudflare Tunnel)

Sanitizing content, choosing a domain, a tunnel from the cluster to Cloudflare, and svejklab.dev online

# contents

Result

The site is public at https://svejklab.dev:

  • no open port on the router, no public IP address and no port forwarding,
  • HTTPS is handled by Cloudflare (certificate and renewal),
  • only HTTP via Traefik leaves the cluster; SSH and the Kubernetes API are not exposed through the tunnel.
text
visitor ─HTTPS─▶ Cloudflare (prg/vie) ◀─tunnel, outbound QUIC─ cloudflared (pod)
                                                                    │ HTTP
                                                                    ▼
                                                    Traefik (kube-system) ─▶ homelab-docs

The key point is that cloudflared opens the connection from the cluster outwards. The router sees it as ordinary outbound traffic, just like the laptop opening a website. Double NAT (ISP router → own router) or a missing public IPv4 therefore do not matter.

1. Sanitizing the content

Before going public, everything revealing details of the home network was removed:

WhatReplaced with
server IP address<server-ip>
router IP addresses<router-ip>
server username<user>, header prompt user@homelab
specific router modelsgeneric “ISP router” and “own Wi-Fi router”
MAC addresses, SSID, passwords, tokensnever on the site

Check the built site, not just the sources:

shell @ mac
cd homelab-docs/site && hugo --destination /tmp/public
grep -rhoE "192\.168\.[0-9.]+" /tmp/public | sort -u    # must return nothing

2. Choosing a domain

The domain was bought directly from Cloudflare, which sells at cost with no markup, so the renewal price equals the registry price.

The deciding factor was the renewal price, not the first-year price:

TLD group1st yearRenewalVerdict
.bid, .win, .download, .loan…~$4~$5❌ spammy reputation, often blocked by corporate filters
.fun, .online, .site, .store, .tech~$5$25–49❌ cheap only in the first year
.fyi~$5~$5✅ cheapest sensible option
.dev~$8~$12✅ chosen: technical TLD, enforces HTTPS (HSTS preload)

The name svejklab = surname + lab. The site runs on the apex domain and further services get subdomains: grafana.svejklab.dev, mcp.svejklab.dev…

3. The tunnel in Cloudflare

Zero Trust → Networks → Tunnels → Create a tunnel:

  1. type Cloudflared, name homelab,
  2. the Install and Run screen offers installers for Windows, macOS or Debian. None of them is run, the connector runs in Kubernetes. The copy icon only copies the whole command, because it contains the token,
  3. Continue unlocks only once the connector is connected, so steps 4 and 5 below come first.

4. The token as a Secret in the cluster

shell @ server
kubectl create namespace cloudflared --dry-run=client -o yaml | kubectl apply -f -

Run the next line on its own: read reads from standard input, and if pasted together with other commands it would take the next line as its input. Then paste the command copied from Cloudflare (Cmd+V) and press Enter:

shell @ server
read -rsp "Paste the whole command from Cloudflare: " LINE; echo

Then:

shell @ server
TOKEN="${LINE##* }"; unset LINE
echo "Token length: ${#TOKEN}"
kubectl -n cloudflared create secret generic tunnel-token --from-literal=token="$TOKEN"
unset TOKEN
  • read -rsp reads input without echoing it and without writing it to history (-s = silent),
  • ${LINE##* } is a bash expansion that takes the last word of the text, i.e. the token itself,
  • ${#TOKEN} prints only the token length (184 here). That verifies the paste without showing the token,
  • unset removes the variable from the shell’s memory.

Check the length of the stored token, again without printing it:

shell @ server
kubectl -n cloudflared get secret tunnel-token -o jsonpath='{.data.token}' | base64 -d | wc -c

5. The cloudflared connector

The manifest cloudflared/cloudflared.yaml contains a Namespace and a Deployment:

SettingWhy
image cloudflare/cloudflared:2026.7.3 (pinned)no surprise changes; upgrades are a deliberate tag change
--no-autoupdatethe binary must not change itself inside a container, the image defines the version
TUNNEL_TOKEN from the Secret (secretKeyRef)the token is not in the manifest or in git
--metrics 0.0.0.0:2000 + liveness /readyKubernetes restarts the connector when it loses connectivity
namespace restricted, uid 65532, read-only FS, drop ALLas strict as the site itself
128 Mi limitwithout swap a limit is a must
shell @ server
kubectl apply -f cloudflared/cloudflared.yaml
kubectl -n cloudflared logs deploy/cloudflared -f

A successful start in the log:

text
INF Registered tunnel connection connIndex=0 ... location=prg01 protocol=quic
INF Registered tunnel connection connIndex=1 ... location=vie05 protocol=quic
INF Registered tunnel connection connIndex=2 ... location=prg03 protocol=quic
INF Registered tunnel connection connIndex=3 ... location=vie05 protocol=quic
SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol.
  • 4 connections to 2 locations (Prague, Vienna): losing one Cloudflare data centre does not take the tunnel down,
  • QUIC goes out over UDP 7844 and the firewall allows it (pods have forwarding allowed, see k3s),
  • the token appears in the log only as TUNNEL_TOKEN:*****.

In Cloudflare the tunnel switches to Healthy.

6. The route (Published application)

Tunnel details → Add route → Published application:

FieldValue
Subdomainempty (apex domain)
Domainsvejklab.dev
Pathempty
Service URLhttp://traefik.kube-system.svc.cluster.local:80

Why this address: cloudflared runs inside the cluster, so it uses the service’s internal DNS name in the form <service>.<namespace>.svc.cluster.local. Traffic goes to Traefik, not directly to the site. The Ingress then decides where it belongs. The site’s Ingress has a host-less (catch-all) rule, so it also catches svejklab.dev.

Another application only needs a new route (grafana → same URL) and an Ingress with host: grafana.svejklab.dev.

Cloudflare created the DNS record (CNAME svejklab.dev → <tunnel-id>.cfargotunnel.com, on the apex thanks to CNAME flattening) by itself.

7. Deploying the sanitized version and testing

Ship as usual (see This site) and test from a phone on mobile data, not on home Wi-Fi. Only that proves the site really works from the internet. The header must show user@homelab, which identifies the sanitized version.

Security summary

RiskMitigation
attack on the home network via an open portno port is open, the tunnel is outbound
token leakSecret, read -s, never in files; on leak Rotate token
site compromisestatic HTML, non-root nginx, read-only FS, CSP headers
attack from a compromised pod on the clusterautomountServiceAccountToken: false, PSS restricted
overload / DDoSCloudflare sits in front
topology disclosuresanitized content

What’s next

  • Cloudflare Access (Zero Trust → Access → Applications) for future admin UIs such as Grafana: access only after e-mail login,
  • SSH from anywhere is handled by Tailscale, see Remote access.