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.
visitor ─HTTPS─▶ Cloudflare (prg/vie) ◀─tunnel, outbound QUIC─ cloudflared (pod)
│ HTTP
▼
Traefik (kube-system) ─▶ homelab-docsThe 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:
| What | Replaced with |
|---|---|
| server IP address | <server-ip> |
| router IP addresses | <router-ip> |
| server username | <user>, header prompt user@homelab |
| specific router models | generic “ISP router” and “own Wi-Fi router” |
| MAC addresses, SSID, passwords, tokens | never on the site |
Check the built site, not just the sources:
cd homelab-docs/site && hugo --destination /tmp/public
grep -rhoE "192\.168\.[0-9.]+" /tmp/public | sort -u # must return nothing2. 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 group | 1st year | Renewal | Verdict |
|---|---|---|---|
.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:
- type Cloudflared, name
homelab, - 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,
- 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
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:
read -rsp "Paste the whole command from Cloudflare: " LINE; echoThen:
TOKEN="${LINE##* }"; unset LINE
echo "Token length: ${#TOKEN}"
kubectl -n cloudflared create secret generic tunnel-token --from-literal=token="$TOKEN"
unset TOKENread -rspreads 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,unsetremoves the variable from the shell’s memory.
Check the length of the stored token, again without printing it:
kubectl -n cloudflared get secret tunnel-token -o jsonpath='{.data.token}' | base64 -d | wc -c5. The cloudflared connector
The manifest cloudflared/cloudflared.yaml contains a Namespace and a Deployment:
| Setting | Why |
|---|---|
image cloudflare/cloudflared:2026.7.3 (pinned) | no surprise changes; upgrades are a deliberate tag change |
--no-autoupdate | the 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 /ready | Kubernetes restarts the connector when it loses connectivity |
namespace restricted, uid 65532, read-only FS, drop ALL | as strict as the site itself |
| 128 Mi limit | without swap a limit is a must |
kubectl apply -f cloudflared/cloudflared.yaml
kubectl -n cloudflared logs deploy/cloudflared -fA successful start in the log:
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:
| Field | Value |
|---|---|
| Subdomain | empty (apex domain) |
| Domain | svejklab.dev |
| Path | empty |
| Service URL | http://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
| Risk | Mitigation |
|---|---|
| attack on the home network via an open port | no port is open, the tunnel is outbound |
| token leak | Secret, read -s, never in files; on leak Rotate token |
| site compromise | static HTML, non-root nginx, read-only FS, CSP headers |
| attack from a compromised pod on the cluster | automountServiceAccountToken: false, PSS restricted |
| overload / DDoS | Cloudflare sits in front |
| topology disclosure | sanitized 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.