A quick mental model for how Cloudflare Tunnel works and why it’s safer than port forwarding.
If you’ve ever exposed a service from home, you know the classic approach:
Cloudflare Tunnel flips that entire model.
Instead of allowing inbound connections to your server, you run a lightweight connector (cloudflared) inside your network that makes outbound-only connections to Cloudflare’s edge. When someone visits your domain, Cloudflare sends that request through the tunnel to your origin.
This gives you the two big wins most self-hosters want:
Think of a tunnel as “Cloudflare has a private line into my network, but I dial it from the inside.”
Source: Cloudflare docs
Cloudflare Tunnel establishes outbound connections (tunnels) between your resources and Cloudflare’s global network. A tunnel is a persistent object that routes traffic to DNS records. Within the same tunnel, you can run multiple connectors (for redundancy) and Cloudflare will send traffic to the nearest/healthy connector.
When you run cloudflared, it initiates outbound connections to Cloudflare. Once they’re established, traffic can flow both ways over that encrypted channel.
Because most networks allow outbound traffic by default, you can keep all inbound ports closed and still publish services.
Below is the “smallest useful setup” I reach for. It assumes:
http://localhost:3000)cloudflaredFollow the official install for your OS. (On NixOS, see the Nix section below.)
cloudflared tunnel login
This opens a browser, you pick the Cloudflare account/zone, and it stores credentials locally.
cloudflared tunnel create my-tunnel
Copy the tunnel UUID it prints (you’ll use it in config).
Create ~/.cloudflared/config.yml:
tunnel: <YOUR_TUNNEL_UUID>
credentials-file: /home/<you>/.cloudflared/<YOUR_TUNNEL_UUID>.json
ingress:
- hostname: app.example.com
service: http://localhost:3000
- service: http_status:404
That last rule is important: it prevents accidental “fallthrough routing” if you mistype a hostname.
cloudflared tunnel route dns my-tunnel app.example.com
Cloudflare will create the DNS record that points the hostname at your tunnel.
cloudflared tunnel run my-tunnel
Now https://app.example.com should reach your local service without opening inbound ports.
Once you have a tunnel, you can route lots of services through it:
ingress:
- hostname: grafana.example.com
service: http://localhost:3001
- hostname: immich.example.com
service: http://localhost:2283
- hostname: ssh.example.com
service: ssh://localhost:22
- service: http_status:404
If the service is “personal/admin”, don’t expose it to the whole internet—even behind Cloudflare.
Cloudflare Access lets you require identity (Google/GitHub/OTP, etc.) before traffic ever reaches your tunnel. The pattern is:
grafana.example.com)If you’re on NixOS, you can manage the connector as a systemd service. The exact module options differ by NixOS release, but the overall approach is:
/var/lib/cloudflared/config.yml (ingress + tunnel id)cloudflared as a long-running serviceIf you want, I can tailor a working snippet for your setup (where your credentials live, and which hostnames/services you’re exposing).
cloudflared can’t reach your local service. Confirm the service URL/port from the machine running cloudflared (not from your laptop).cloudflared connectors for the same tunnel on different hosts; Cloudflare will load-balance.X-Forwarded-* headers).http_status:404 rule: it prevents weird routing surprises.Cloudflare Tunnel is a clean way to publish services without port forwarding:
cloudflared)If you tell me what you’re exposing (Grafana? Immich? Home Assistant? SSH?), I can adjust the ingress examples and security posture for that exact stack.
services.cloudflared and manage the tunnel as a systemd service.