Cloudflare Tunnel + Zero Trust — homelab without exposing ports
I have 4 machines, dozens of services and zero ports open to the internet. All access goes through Cloudflare Tunnel with Zero Trust auth. Showing how I set it up and what's worth knowing.

Classic homelab: VPN or open port 443 with a reverse proxy. I didn't want either. I deployed Cloudflare Tunnel, an outbound tunnel from my network to CF, they terminate TLS and authorize. Zero exposure on the WAN side.
Architecture
[Internet]
↓ HTTPS
[Cloudflare CDN + Zero Trust auth]
↓ tunnel (cloudflared)
[Mini PC 172.25.100.36]
↓ HTTP
[Local services: Vikunja, Obsidian sync, blog, agent]A client connects to app.kamilkaletka.dev, CF authorizes (Google SSO + email allowlist), then proxies to my service. My router has no ports open to the internet.
Setup in 4 steps
1. Cloudflare account + domain. Domain has been on CF for a while. Free tier is enough.
2. Create the tunnel.
cloudflared tunnel login
cloudflared tunnel create homelab
# saves credentials.json3. Routing config.
# ~/.cloudflared/config.yml
tunnel: homelab
credentials-file: /home/kkaletka/.cloudflared/credentials.json
ingress:
- hostname: vikunja.kamilkaletka.dev
service: http://localhost:3456
- hostname: obsidian-sync.kamilkaletka.dev
service: http://localhost:5984
- hostname: betting.kamilkaletka.dev
service: http://172.25.100.49:3002
- service: http_status:4044. Run as systemd.
sudo cloudflared service install
sudo systemctl enable --now cloudflaredA minute later subdomains are live.
Zero Trust — auth
This is the key piece. The tunnel alone gives a public URL. Zero Trust adds the auth layer.
In the CF Zero Trust dashboard:
- Application → Self-hosted
- Domain:
vikunja.kamilkaletka.dev - Identity Provider: Google (free integration)
- Policy:
email is in [[email protected]]
Every visit requires Google login, the email must be on the list. Links I share with someone, I ask them to add their email to the allowlist.
Trap #1: forge IP
I have services across machines. Mini PC = 172.25.100.36, Forge PC = 172.25.100.49. Ingress rules for forge services MUST use IP, not localhost. Because cloudflared lives on the mini PC.
# WRONG
- hostname: betting.kamilkaletka.dev
service: http://localhost:3002 # localhost = mini PC
# RIGHT
- hostname: betting.kamilkaletka.dev
service: http://172.25.100.49:3002Cost me an hour of debugging on day one.
Trap #2: NO_PROXY and Docker
I have SOCKS5 proxies in some projects. Without NO_PROXY Docker tries to resolve internal hostnames externally and breaks.
export NO_PROXY="172.25.0.0/16,localhost,127.0.0.1,*.kamilkaletka.dev"Every new internal service goes here.
Trap #3: Caddy reload
I often want to tweak Caddy config (between the tunnel and the container). docker exec caddy caddy reload does NOT work when Caddy is bind-mounted, the reload signal reads the inode, not the path. Full container restart required.
# WORKS
docker restart caddy
# DOES NOT WORK SOMETIMES (bind mount)
docker exec caddy caddy reload --config /etc/caddy/CaddyfileWhat I gained
1. Zero open ports. My router has WAN closed. An attacker can't even scan for open ports.
2. HTTPS everywhere, free. CF terminates TLS, free cert for every subdomain.
3. Audit logs. CF Zero Trust logs every visit. I can see what IP I logged in from a year ago.
4. WAF for free. Cloudflare auto-blocks typical attacks before they reach my server.
What's missing
1. WebSocket through the tunnel works, but latency is +20-30ms vs direct connection. Fine for most services, not for real-time games.
2. The tunnel is a single point of failure. If cloudflared dies, all services vanish. I have a systemd unit with restart=always, works, but it's still one process.
3. CF rate limits. Free tier has a 100k requests/day limit per zone. Enough, but a big traffic spike could be a problem.
Cloudflare Tunnel + Zero Trust is the best security-to-effort ratio I found for a homelab. Costs $0, gives TLS, auth, WAF and zero open ports. If you still expose 443 to a reverse proxy, switch.