Homelab Haven

Cloudflare Tunnel with Docker: Publish an App Without Opening a Port

June 22, 20265 min read

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

  • cloudflared makes 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:Edit for 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

  • cloudflared shows Healthy in the Zero Trust dashboard
  • https://app.example.com loads 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