Cloudflare Tunnel removes inbound ports from the equation. cloudflared dials
out to Cloudflare, requests arrive back through that connection, and your
firewall can drop everything except SSH.
Putting Traefik behind it means you configure routing once, with labels, instead of adding a public hostname to the tunnel for every service.
Everything below runs on one Ubuntu 24.04 host with Docker 27.
What this replaces
Without the tunnel you open 80 and 443, allowlist Cloudflare’s IP ranges, and manage an origin certificate. With it you open nothing, and the origin is not reachable by IP at all.
The mechanism is straightforward: cloudflared opens outbound connections to
Cloudflare’s edge and keeps them alive. When a request arrives for one of your
public hostnames, Cloudflare sends it down an existing connection rather than
dialling your server. Because the connections are outbound, a NAT gateway, a
dynamic IP address or a default-deny firewall makes no difference — which is
also why this works on a home server or inside a private network.
The trade is a dependency on cloudflared staying connected, and one more moving part in the request path.
Not covered here: routing non-HTTP protocols such as SSH or RDP through the
tunnel (possible, uses cloudflared access), or Cloudflare Access policies
beyond a note at the end.
Create the tunnel
Use a remotely-managed tunnel. The connector gets a token, and the hostname routing lives in the dashboard rather than in a file on the server — which means you can change routes without touching the host.
- In the Cloudflare dashboard, open Zero Trust → Networks → Tunnels and create a tunnel. Name it after the host, not the application.
- Choose the Docker connector. Cloudflare shows a
docker runcommand with a long token in it. - Copy only the token. You will put it in an env file, not in a shell command that lands in your history.
TUNNEL_TOKEN=eyJhIjoiN2Y...chmod 600 /opt/edge/.envThe Compose stack
networks:
edge:
name: edge
services:
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate --metrics 0.0.0.0:2000 run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN}
ports:
# Metrics on loopback only — never publish this.
- "127.0.0.1:2000:2000"
networks:
- edge
depends_on:
- traefik
traefik:
image: traefik:v3.3
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --providers.docker.network=edge
- --entrypoints.web.address=:80
- --entrypoints.web.forwardedHeaders.trustedIPs=172.18.0.0/16
- --accesslog=true
- --log.level=INFO
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- edge
whoami:
image: traefik/whoami
restart: unless-stopped
networks:
- edge
labels:
- traefik.enable=true
- traefik.http.routers.whoami.rule=Host(`app.example.com`)
- traefik.http.routers.whoami.entrypoints=web
- traefik.http.services.whoami.loadbalancer.server.port=80Traefik publishes no ports. cloudflared reaches it as traefik:80 over the
edge network, and nothing else can.
Find the real subnet before copying 172.18.0.0/16 — Docker assigns it when it
creates the network:
docker compose up -d
docker network inspect edge --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'172.18.0.0/16If it differs, update trustedIPs and restart Traefik.
Why trustedIPs matters
Traefik strips X-Forwarded-Proto, X-Forwarded-For and X-Forwarded-Host
from requests unless they arrive from an address in trustedIPs. That default
is correct — otherwise any client could claim any source IP — but it means an
untrusted cloudflared container produces two visible symptoms:
- Your application sees
httpand generateshttp://links or redirects to HTTPS, causing a loop. - Every access log line shows the cloudflared container’s internal address instead of the visitor’s.
With the subnet trusted, Cloudflare’s CF-Connecting-IP and the standard
forwarded headers reach your app intact.
Route the hostname to Traefik
Back in the tunnel’s configuration, add a public hostname:
| Field | Value |
|---|---|
| Subdomain | app |
| Domain | example.com |
| Service type | HTTP |
| URL | traefik:80 |
Cloudflare creates the DNS record for you — a proxied CNAME pointing at the tunnel. You do not add an A record, and there is no origin IP in DNS at all.
Every additional service needs a Traefik label and a public hostname entry
pointing at the same traefik:80. Traefik picks them apart by Host header.
Verify
Check the connector is registered:
curl -s localhost:2000/ready{"status":200,"readyConnections":4}Four connections is normal — cloudflared establishes redundant links to two Cloudflare data centres.
Then check the route end to end:
curl -sS https://app.example.com/ | head -5Hostname: 9f3c2b1a4d77
IP: 127.0.0.1
IP: 172.18.0.4
RemoteAddr: 172.18.0.3:41522Now close the firewall and confirm nothing else answers:
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw enable# from another machine
nmap -Pn -p 80,443,2000 203.0.113.42PORT STATE SERVICE
80/tcp filtered http
443/tcp filtered https
2000/tcp filtered cisco-sccpThe site still works. That is the point of the exercise.
Common errors
ERR Couldn't start tunnel error="Unauthorized: Failed to get tunnel" — the
token is wrong, truncated, or belongs to a deleted tunnel. Regenerate it from
the dashboard.
Cloudflare Error 1033. DNS points at a tunnel with no connector attached.
docker compose logs cloudflared will show why it is not registering.
Error 502 from Cloudflare. cloudflared connected but could not reach the
origin service. Usually the public hostname URL is localhost:80 instead of
traefik:80 — inside a container, localhost is that container.
Traefik returns 404 for a service that is running. exposedByDefault=false
requires traefik.enable=true on the container, and the router needs an
entrypoints label matching a defined entrypoint. Check
docker compose logs traefik for the router it actually built.
Infinite redirect loop. Your app redirects HTTP to HTTPS and does not see
X-Forwarded-Proto: https. Fix trustedIPs, or remove the redirect and let
Cloudflare’s Always Use HTTPS handle it at the edge.
The tunnel drops every few minutes. Outbound UDP on port 7844 is blocked, so
cloudflared falls back and reconnects repeatedly. Allow it outbound, or force
HTTP/2 with --protocol http2.
Production notes
Run two connectors on different hosts against the same tunnel once the service matters. Cloudflare load balances across registered connectors, so this is a config-free HA improvement.
Pin the cloudflared image to a dated tag rather than latest. Connector
releases are frequent and an unattended pull can restart your edge at an
unhelpful moment.
Put Cloudflare Access in front of anything internal — dashboards, admin panels, the Traefik dashboard if you expose it. It authenticates at the edge, so unauthenticated requests never reach your host at all. That is a stronger position than any application-level login, and it takes about five minutes per hostname.
Scrape localhost:2000/metrics from your monitoring. The connection count
dropping to zero is the signal you want to page on, and it happens well before
users notice.