The usual way to reach a home service from outside is to forward a port on your router, point dynamic DNS at your changing IP, and manage a TLS cert. Each one adds a place to misconfigure, break, or expose more than intended. Cloudflare Tunnel takes a different path: a small daemon on your box makes an outbound connection to Cloudflare's edge, and traffic for your hostname rides back down that connection. No open inbound ports, no public IP, no public certificate required on your origin.
How it works
You run cloudflared next to your services. It dials out to Cloudflare and holds the connection open. When someone hits ha.example.com, Cloudflare routes the request down the tunnel to the local service you mapped — say http://homeassistant:8123. Cloudflare terminates TLS at its edge, so your origin never needs a public certificate and your router's inbound ports stay shut.
One prerequisite: your domain needs to be on Cloudflare DNS. In a normal full setup (domain on Cloudflare's nameservers), Cloudflare can create the tunnel's DNS records for you — a CNAME pointing to <tunnel-id>.cfargotunnel.com for each hostname. In a partial (CNAME) setup, you add that record manually at your external DNS provider.
Two ways to run it
As of 2026, Cloudflare steers most people toward remotely-managed tunnels: you create the tunnel in the Zero Trust dashboard (one.dash.cloudflare.com → Networks → Tunnels), and the local daemon just needs a token. It's the fastest path, and you can edit routes without restarting anything:
# remotely-managed: config lives in the dashboard, daemon takes a token
docker run -d --name cf-tunnel --restart unless-stopped \
cloudflare/cloudflared:latest \
tunnel --no-autoupdate run --token eyJhbGci...
Because Docker containers don't update in place, recreate this one with a fresh image on your normal cadence (docker pull then re-run, or --pull always when you recreate it). The --no-autoupdate flag just stops cloudflared from trying to self-update inside the container.
The other option is locally-managed with a config.yml. It's the older route, but if you're running a stack of hostnames it has one real advantage: the ingress map lives in a file you can keep in git, right next to your Compose files.
# config.yml -- locally-managed ingress, version-controllable
tunnel: <tunnel-uuid>
credentials-file: /etc/cloudflared/<tunnel-uuid>.json
ingress:
- hostname: ha.example.com
service: http://homeassistant:8123
- hostname: grafana.example.com
service: http://grafana:3000
- service: http_status:404 # catch-all, required last
Pick one or the other — dashboard/token mode for simplicity, config.yml if you'd rather version-control your routes. You can run multiple connectors against a single tunnel for resiliency; what you don't want is to split route ownership between dashboard-managed hostnames and a local config.yml unless you understand exactly how that deployment is managed.
The token is a credential: anyone who has it can run your tunnel, so store it like a password and never commit it to git. And because a tunnel turns publishing a service into one step, it's easy to drop a login page onto the open internet without thinking it through. Put Cloudflare Access (Zero Trust) in front of anything sensitive so requests are authenticated at the edge before they reach your box, and run cloudflared as a non-root user.
One caveat: Access is a clean extra gate for browser-based dashboards, but Home Assistant also has mobile apps, API clients, webhooks, and voice integrations that won't sail through an interactive login. Gate the browser dashboard with Access if you like — but test the non-browser paths, since some will need a service-token route, WARP / private access, or a deliberately excluded path.
The Home Assistant gotcha
If you tunnel to Home Assistant and get a "400: Bad Request" or a refused login, it's almost always the proxy headers. HA won't trust a forwarded connection until you tell it to. Add this to configuration.yaml and restart:
http:
use_x_forwarded_for: true
trusted_proxies:
- 172.20.0.5 # the exact cloudflared container IP, if static
# or a narrow custom network the container sits on:
# - 172.20.0.0/24 # use the network address, not a host address
Set this with care: a trusted proxy is allowed to assert the client's real IP, so use the exact cloudflared container IP or a narrow custom Docker network — not a broad range like 172.16.0.0/12. If you use a subnet, give the network address (e.g. 172.20.0.0/24), not a host address with a mask, and make sure the immediate upstream proxy is in the list. HA blocks untrusted forwarded requests on purpose — that "400: Bad Request" is a safety default, not a bug.
The mistake to avoid
The tunnel itself is the easy part; the trap is treating "reachable" as "secured." A tunnel closes your inbound ports, but it doesn't authenticate anyone — that's Access's job. Expose a service dashboard, gate it behind Access, and confirm an incognito window actually gets stopped at the login before you call it done.
The setup, in order
- Move your domain onto Cloudflare nameservers (one-time).
- Create a tunnel in the Zero Trust dashboard, or with
cloudflared tunnel createfor config.yml mode. - Map each hostname to a local service — the dashboard's Public Hostname tab, or the
ingress:block. - Run
cloudflaredas a service or container and confirm the tunnel shows Healthy. - Put Cloudflare Access in front of anything not meant for the whole internet, then test it from a logged-out browser.
Cloudflare Tunnel gets you off port-forwarding and dynamic DNS for good, with TLS handled at the edge. Just remember it only solves reachability — pair it with Access and you've got remote access that's reachable, authenticated, and easier to reason about.