A Cloudflare Tunnel replaces inbound port forwarding with an outbound, encrypted connection: cloudflared on your machine dials out to Cloudflare's edge and holds it open. Visitors reach your hostname at Cloudflare, and traffic rides back over that existing connection. No public IP, no open ports, no router rules — just TLS, DDoS filtering, and optionally Access-based identity checks on top.
I retired our last port-forwarding rule the day I stood up Cloudflare Tunnel on the homelab rack. We had a Home Assistant box, a small internal web dashboard, and a management SSH host — all reachable only because our ISP handed out a public IPv4 and we were brave enough to punch holes in the router. The moment that public IP churned during a firmware update, everything went dark for an hour, and every hour in between, our ports were open to the entire internet. After switching to a tunnel, the router rules died, the firewall became a straight deny-all, and access kept working through the churn. This post is the working record of how that setup actually behaves in production, not marketing copy.
How the architecture inverts the connection
With port forwarding, the internet initiates: your router advertises a listening port, scanners find it, and your service sits exposed behind a rule you must patch and babysit forever. A tunnel does the opposite. The cloudflared daemon on your origin machine makes an outbound connection to Cloudflare's edge — over TLS, using QUIC by default — and holds it. Visitors hit your hostname, which resolves to Cloudflare, and Cloudflare hands the request back down the already-open connection to your origin.
Two consequences matter operationally:
- No port on your network is ever inbound-reachable, so the attack surface shrinks to "the hostnames Cloudflare exposes for you."
- Your public IP — or CGNAT address, or the fact that you have no public IP at all — is irrelevant to reachability. That alone killed an entire class of outages for us.
The trade-off: your availability now depends on three things instead of one — your host is up, cloudflared is running, and Cloudflare's network is healthy. Budget for that. Also understand that traffic transits Cloudflare, so it is no more end-to-end encrypted than any site behind the CDN; for privacy-sensitive services the mitigation is Access-restricting who can even reach it (see zero-trust networking).
Install cloudflared
cloudflared is a single static binary from Cloudflare. On Debian/Ubuntu I use the official apt repo so it gets security updates with the normal unattended-upgrades cycle:
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' \ | sudo tee /etc/apt/sources.list.d/cloudflared.list sudo apt update && sudo apt install cloudflared The binary also ships an RPM repo and installers for macOS/Windows, and for containers a single cloudflare/cloudflared image works. The full source lives at https://github.com/cloudflare/cloudflared and the product docs at https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ .
Two authentication modes: token vs credentials-file
You have to tell the daemon which tunnel it belongs to. There are two ways, and I use both in different places:
Token mode. In the Zero Trust dashboard you create a tunnel, copy a base64 token, and run:
cloudflared tunnel run <your-tunnel-token> That is the whole setup: no config file, no DNS routing (the dashboard creates the CNAME for you). It is perfect for a quick test, a temporary container, or a machine I do not want extra state on. The downside for long-lived infrastructure is that the secret lives in the process command line or a hand-maintained env file.
Credentials-file mode. This is what I run everywhere permanent. One-time bootstrap:
cloudflared tunnel login cloudflared tunnel create homelab cloudflared tunnel route dns homelab ha.example.com cloudflared tunnel route dns homelab dash.example.com login opens a browser, you authorize the zone, and create drops a tunnel UUID plus a JSON credentials file into ~/.cloudflared/. route dns automatically creates the CNAME records so ha.example.com and dash.example.com point into the tunnel — no manual DNS, and it just works.
Writing the service config: /etc/cloudflared/config.yml
When the daemon runs as a system service it reads /etc/cloudflared/config.yml, not your home directory — that distinction has bitten me more than once. The file in production looks like this:
tunnel: 3f9c2ab1-6e7d-4a8c-9b0e-1d2e3f4a5b6c credentials-file: /etc/cloudflared/3f9c2ab1-6e7d-4a8c-9b0e-1d2e3f4a5b6c.json ingress: - hostname: ha.example.com service: http://127.0.0.1:8123 - hostname: dash.example.com service: http://127.0.0.1:3000 originRequest: connectTimeout: "10s" responseHeaderTimeout: "60s" - hostname: ops.example.com service: ssh://127.0.0.1:22 - service: http_status:404 Notes from real use:
- The final
service: http_status:404catch-all rule is mandatory; without it cloudflared refuses to start. -
ssh://entries are not HTTP — visitors get a browser-based web terminal, so I can reach the host even from a laptop with no client installed. - Per-service
originRequesttimeouts are where I tune upstream 502s (more below). - Add
tunnelandcredentials-fileat the top, keepcredentials-fileowned by the service user, mode0600.
Run it as a systemd unit
I never leave a tunnel on a terminal. The unit:
# /etc/systemd/system/cloudflared.service [Unit] Description=cloudflared (managed by me, config at /etc/cloudflared/config.yml) After=network-online.target Wants=network-online.target [Service] ExecStart=/usr/bin/cloudflared service run Restart=always RestartSec=2s User=cloudflared StateDirectory=cloudflared Group=cloudflared [Install] WantedBy=multi-user.target sudo useradd -r -s /usr/sbin/nologin cloudflared sudo cp ~/.cloudflared/config.yml /etc/cloudflared/config.yml sudo cp ~/.cloudflared/3f9c2ab1-*.json /etc/cloudflared/ sudo chown -R cloudflared:cloudflared /etc/cloudflared sudo chmod 600 /etc/cloudflared/*.json sudo systemctl enable --now cloudflared Restart=always plus RestartSec=2s is the safety net: the flaps I see in the logs self-heal within seconds. The critical rule I follow before every config change — run cloudflared tunnel run homelab in the foreground against a staging copy first, watch it register, only then update the service file and systemctl restart cloudflared.
Put Cloudflare Access in front of everything non-public
A tunnel is a pipe; identity is the lock. For ops.example.com (SSH) and the dashboard I register each hostname as a self-hosted application in the Zero Trust console and add one policy: allow my email domain, require one-time PIN or SSO. The effect: an attacker scanning the internet finds Cloudflare's edge IPs, not mine, and gets an identity challenge before the request ever touches the origin. This is the standard zero-trust pattern — validate identity at the edge, not the firewall — and I keep the full runbook in zero-trust network design.
On SSL mode: for tunnel hostnames I use Full (Strict) so Cloudflare validates the origin certificate end to end. For internal services with self-signed certs behind the tunnel, the simpler path is noTLSVerify: true under originRequest rather than weakening the edge mode. The Access and TLS settings both live under https://developers.cloudflare.com/zero-trust/access/ .
Troubleshooting: what I actually see
- 502 Bad Gateway. The tunnel is fine; the origin is not. My first move is always
curl -v http://127.0.0.1:8123on the host itself. Second move: check I did not point the service at a stale port after a config migration. Third: tuneoriginRequest.responseHeaderTimeout— I had a dashboard whose cold start took 45s, and the default timeout produced exactly the "502 upstream timeout" symptom until I set it to 60s. - Tunnel flapping / reconnecting. Read
journalctl -u cloudflaredbefore touching anything — the log names the reason. Our flaps turned out to be (a) a cellular uplink with a 90s NAT idle timeout, and (b)cloudflaredbeing OOM-killed on a 1GB VPS during log bursts. In both cases the reconnect reason in the journal was unambiguous. - Certificate/credential errors. If you moved the credentials file and forgot to chown it to the service user, the daemon fails to authenticate and logs a 401-style rejection. Same class of bug: JSON mode 600 owned by root, service running as
cloudflared. - DNS not resolving. After
route dns, the CNAME propagates within minutes;dig ha.example.comshould show a target at*.cfargotunnel.com. If it resolves to the wrong edge or stale IP, check that the zone's nameservers are actually Cloudflare. - Free-tier limits. Cloudflare's terms prohibit using free tunnels as streaming/video servers (Plex, Jellyfin distribution), and outbound latency on free tunnels goes through their public return path — fine for management and dashboards, wrong tool for game servers or UDP-heavy workloads.
Operate it like infrastructure, not a toy
Tunnels fail quietly: a deleted CNAME, a stale credentials file, an OOM-killed daemon, and the hostnames just stop working with no alarm at the origin. My minimum discipline: an external synthetic monitor probing ha.example.com for 2xx from outside the network; cloudflared alive-check plus edge latency in the same dashboard; and before any ingress change, a foreground test run plus a dig to confirm the CNAME. When I needed to reach the homelab through the old NAT world for anything else, port forwarding with iptables is the reference I keep handy — but I now use it only for the router's own management, never for apps. And because the tunnel terminates at Cloudflare while my internal zone still resolves through a local caching resolver, the whole stack sits on top of the dnsmasq configuration that serves the internal names.
FAQ
Why not just keep a VPS + WireGuard and skip the tunnel entirely? For a handful of always-reachable internal services, a tunnel is dramatically less to operate: no VPN clients on visitor devices, no key distribution, no VPS to keep patching. I keep a WireGuard site-to-site link as a second path to the rack; the tunnel is for things that must be reachable by hostname from anywhere.
Does the tunnel expose my whole internal network? Only what you write in ingress. Every rule names a specific hostname and a specific origin address, so the blast radius is exactly the services you listed. My red line: never a catch-all rule pointing at an internal subnet.
What about latency? Expect one extra network hop through Cloudflare's edge. For dashboards and shell access it is noise; for latency-sensitive anything, measure first — that is the whole point of the free-tier caveat above.
Related articles
- iptables port forwarding: NAT rules and what to stop exposing
- Zero-trust network design: from perimeter to identity
- dnsmasq configuration: a local DNS cache for the internal zone
My wider collection of travel notes, guides and field tools lives at jiunotes.top. The ops runbooks and troubleshooting notes that back this walkthrough are published on the same site at ops.jiunotes.top.

