Cloudflare Tunnel with Docker: Publish an App Without Opening a Port
Part 2 of Securing Remote Access to Your Homelab. Covers apps you've decided should be public — see Part 1 if you haven't made that call yet.
Two containers do the whole job: cloudflared opens an outbound connection to
Cloudflare and relays traffic down it, and Caddy reverse-proxies that traffic to
whichever app it's for, handling TLS automatically along the way. Neither needs a port
forwarded on your router.
Summary
cloudflaredmakes an outbound connection — nothing listens for inbound traffic on your network at all.- Caddy gets its TLS certificates via a Cloudflare DNS-01 challenge, which needs a
plugin the stock Caddy image doesn't ship with — build it in yourself with
xcaddy(one Dockerfile, shown below). - Adding a new app later is one block in one file, then
caddy reload— no new container, no restart of anything else.
What you need first
- A domain added to Cloudflare (the free plan is enough).
- Zero Trust enabled on your Cloudflare account (also free) — this is where tunnels live.
- A Cloudflare API token scoped to
Zone:DNS:Editfor your domain, for the TLS certificate step later.
Step 1 — create the tunnel and get a token
In the Cloudflare dashboard: Zero Trust → Networks → Tunnels → Create a tunnel →
choose Cloudflared → name it (e.g. homelab). The setup screen gives you a run
command containing a long token — that token is the only secret this step produces;
copy it.
Step 2 — run cloudflared
# /compose/cloudflared/compose.yaml
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run
env_file:
- ./.env
extra_hosts:
# Lets cloudflared reach services published on the HOST's ports, not just
# other containers on this compose network — needed once you're proxying
# through Caddy in the next step, since Caddy will be doing the same thing.
- "host.docker.internal:host-gateway"
# /compose/cloudflared/.env
TUNNEL_TOKEN=paste-the-token-from-step-1
docker compose -f /compose/cloudflared/compose.yaml up -d, then check the tunnel
shows Healthy back in the Zero Trust dashboard — that confirms the outbound
connection is up before you route anything through it.
Step 3 — Caddy, with the Cloudflare DNS plugin built in
The stock caddy image can reverse-proxy fine, but it can't get you a certificate via
DNS-01 challenge on its own — that needs the caddy-dns/cloudflare plugin, which
isn't in the default build. xcaddy builds a custom Caddy binary with exactly the
plugins you ask for:
# /compose/caddy/Dockerfile
FROM caddy:2-builder-alpine AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
# /compose/caddy/compose.yaml
services:
caddy:
build: .
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
env_file:
- ./.env
volumes:
# Directory mount, not a single-file mount — lets `caddy reload` pick up
# Caddyfile changes without recreating the container, including changes
# written by another process (a sync tool, a script) via atomic rename.
- /path/to/appdata/caddy:/etc/caddy
- /path/to/appdata/caddy/data:/data
- /path/to/appdata/caddy/config:/config
extra_hosts:
- "host.docker.internal:host-gateway"
# /compose/caddy/.env
[email protected]
CLOUDFLARE_API_TOKEN=your-zone-dns-edit-token
Inside /path/to/appdata/caddy/, one Caddyfile:
(cloudflare_dns) {
tls {
dns cloudflare {$CLOUDFLARE_API_TOKEN}
}
}
(proxy_app) {
import cloudflare_dns
encode zstd gzip
reverse_proxy {args[0]}
}
https://app.example.com {
import proxy_app host.docker.internal:8080
}
build: . means this image is built once, locally, the first time you docker compose up -d — not pulled from a registry, since the whole point of the Dockerfile
is producing a binary nobody else's public image ships.
Step 4 — route the tunnel at your app
Back in the Zero Trust dashboard, on your tunnel: Public Hostname → Add a public
hostname. Subdomain app, domain your zone, service type HTTP, URL
localhost:80 — this tells Cloudflare "when someone requests app.example.com,
deliver it down this tunnel to port 80 on the tunnel's own host," which is exactly
where Caddy is listening. Caddy then does the actual routing to whichever app the
Caddyfile block points at, by hostname.
Visit https://app.example.com. If it loads, DNS, the tunnel, and Caddy's proxy are
all correctly wired together.
The trap: a revoked API token still looks fine
Existing certificates don't care if CLOUDFLARE_API_TOKEN is still valid — they keep
working right up until they need to renew, which for Let's Encrypt is roughly
every 60 days. A revoked or expired token produces no immediate symptom at all.
The first sign is usually a certificate that quietly expires weeks later, at which
point every route using it breaks at once.
There's no good passive warning for this short of monitoring the token's own
validity (Cloudflare's API has a GET /user/tokens/verify endpoint you can point an
uptime check at). Simplest practical mitigation: note the token's creation date
somewhere you'll actually see it, and treat "when did I last touch Cloudflare's API
token settings" as a question worth asking if a cert renewal ever fails.
Adding your next app
Once this is running, publishing a new app is small: add a block to the Caddyfile,
point the tunnel's public hostname config at the same localhost:80, caddy reload.
No new container per app, no port-forward, no certificate step to repeat — Caddy
requests and renews each hostname's certificate itself the first time it sees traffic
for it.
Checkpoint
cloudflaredshows Healthy in the Zero Trust dashboardhttps://app.example.comloads with a valid certificate, proxied through Caddy to your app- No inbound port forwarded on your router for any of this
Next: Part 3 — Tailscale with Docker. Previous: Part 1 — Cloudflare vs Tailscale.
If this saved you some time, a coffee keeps the lights on and the posts coming.
☕ Buy me a coffee