Cloudflare Tunnel as a homelab front door

Exposing self-hosted services without opening a single inbound port — and the small Docker network trick that makes it scale to twenty stacks.


My router has no inbound ports open. I run roughly twenty services that are nonetheless reachable from anywhere I care about. The thing in the middle is one cloudflared container, an outbound tunnel, and a shared Docker bridge network that any new stack can join in one line of compose. This piece is about that setup — and the one architectural choice that turns “Cloudflare Tunnel works” into “Cloudflare Tunnel scales.”

The boring promise

Cloudflare Tunnel does one thing well. The cloudflared daemon runs inside your network, opens an outbound TLS connection to Cloudflare’s edge, and accepts requests for whatever public hostnames you’ve configured. Cloudflare absorbs the inbound traffic; the tunnel carries it back to your machine. Your router never knows anyone tried to talk to you.

The promise is:

It’s the kind of thing that, the first time you set it up and your home service answers from a coffee shop on the other side of the country, makes you suspicious. It can’t be this easy. It is.

The single-stack version

The smallest possible setup is a docker-compose.yml with one service:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel run
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}
    networks:
      - cloudflared_bridge

networks:
  cloudflared_bridge:
    driver: bridge

TUNNEL_TOKEN comes from creating a tunnel in the Cloudflare dashboard. The dashboard also lets you map hostnames to local URLs — foo.example.com → http://nginx:80. That’s the entire configuration. The tunnel pulls its routing from the cloud, so you never edit a config file inside the container.

This works for one service. It works for two services if they’re in the same docker-compose.yml. Once you have twenty services across many stacks, it stops working — because each stack has its own network, and cloudflared lives in only one of them.

The single architectural trick

The fix is the part that, when I figured it out, made me wish I’d known it the day I started: make cloudflared_bridge an external network, and have every other stack opt in to it.

In the cloudflared stack:

networks:
  cloudflared_bridge:
    driver: bridge
    name: cloudflared_bridge

In every other stack you want exposed:

services:
  myapp:
    # ... usual config ...
    networks:
      - default
      - cloudflared_bridge   # <-- the magic line

networks:
  default:
    # the stack's private network, unchanged
  cloudflared_bridge:
    external: true            # <-- it lives elsewhere

Now every container that opts in shares a network with cloudflared. The Cloudflare dashboard routes n8n.example.com → http://n8n:5678 and cloudflared resolves n8n because they’re on the same bridge.

The mental model:

                  Internet


             Cloudflare Edge

                     │  outbound TLS, your side opens it

         ┌────────────────────────┐
         │ cloudflared container  │
         │ (cloudflared_bridge)   │
         └──────────┬─────────────┘

          ┌─────────┼─────────────────┐
          │         │                 │
   ┌──────▼───┐ ┌───▼────┐ ┌──────────▼───┐
   │ stack A  │ │ stack B│ │ stack C ...  │
   │ (joined) │ │(joined)│ │ (joined)     │
   └──────────┘ └────────┘ └──────────────┘

Each stack keeps its own default network (where its internal services talk to each other privately). The shared cloudflared_bridge is just the on-ramp.

Why this beats the alternatives

I tried two other approaches before landing here.

Putting everything in one big compose file. Worked. Was an absolute pain to manage — restarting one service required rebuilding the whole stack, version pinning leaked across services, and the file grew to 700 lines. Don’t.

Running cloudflared per stack. Each stack gets its own tiny cloudflared sidecar with its own tunnel token. Worked. Was wasteful — twenty tunnels’ worth of CPU and memory for what is effectively one job — and pushed the multi-tunnel management problem into the Cloudflare dashboard, where it was even more annoying than in compose files.

The shared-external-network pattern keeps cloudflared as one container, one process, one set of credentials, and one place to look when DNS routing goes weird. New service to expose? One line in its compose file. Tear down? Remove the line; the rest of the stack is unaffected.

What I learned to do immediately after setup

Lock down the tunnel hostnames. By default, your tunnel will happily route any subdomain you create. The first time you accidentally create a CNAME pointing at the tunnel for a service you don’t want public, you’ll feel it. Cloudflare’s Access policies are how you fix this — create a default-deny, then explicit allow-listing for the hostnames you actually want public. Five minutes of work; significant peace of mind.

Put internal-only services behind Access policies, not “no policy.” If a service is private (Komodo’s UI, internal dashboards), don’t just leave it without an Access policy — that’s “anyone with the URL can hit it.” Put it behind an Access policy that requires authentication. The friction for me is one click on a Google login I’m already signed into. The friction for an attacker is impossible.

Turn on Cloudflare’s bot fight mode and a rate-limit rule. Free tier. Five minutes. Catches the dumb stuff before it reaches your tunnel. Won’t help against a determined attacker, will catch every random script on the internet that’s curious about your hostnames.

Don’t expose your cloudflared admin endpoints over the tunnel. Don’t create a hostname for the tunnel container itself. I have not made this mistake. Please do not make this mistake.

Failure modes worth knowing about

Cloudflare being down takes you offline. This has happened. Twice in the time I’ve used the tunnel. Both times for under an hour. It’s a real dependency — you traded “I run my own gateway” for “Cloudflare runs my gateway.” For most homelabs, this trade is fine. For anything you actually depend on, have a fallback path (a Tailscale connection to your network is the standard one).

Long-running connections (websockets, SSE) are mostly fine but occasionally drop. Cloudflare Tunnel handles them, but there’s a default idle timeout. Long-running n8n executions, persistent terminal sessions, large file uploads — all things I’ve seen drop. Cloudflare lets you configure the timeout per route, up to several hours. Configure it before you need to.

Tunnel updates can break. cloudflare/cloudflared:latest is convenient. It also occasionally updates in a way that breaks something. I now pin to a known-good version and update deliberately. If you have a service you can’t afford to lose for ten minutes, do not run :latest on anything in its path.

The 30-second compose snippet you actually want

For copy-paste into any new stack you want exposed, here’s the minimal addition:

services:
  yourservice:
    # ... your normal config ...
    networks:
      - default
      - cloudflared_bridge

networks:
  default:
  cloudflared_bridge:
    external: true

Then create a hostname in the Cloudflare dashboard pointing at http://yourservice:<port> and you’re done. Total time from “I just deployed this service” to “it’s reachable from my phone over LTE”: about thirty seconds.

The whole arrangement isn’t sophisticated. It’s just consistent. One tunnel, one shared bridge, every stack opts in the same way. The simplicity is the feature — twenty services later, the diagram in this post is still the diagram.


Adjacent: Splitting Immich across two boxes — a stack that uses this exact pattern.