Skip to content
← Blog

Cloudflare Tunnel Docker Compose: Zero-Port Stack Setup

10 min read

A correct cloudflare tunnel docker compose setup ends with your host firewall exactly where it started: nothing inbound, no published ports, no public IP in DNS. The connector dials out to Cloudflare, traffic rides back down that same outbound link, and every container stays sealed behind Docker's internal network. This runbook builds the fully containerized, multi-service version of that pattern, the one most tutorials skip in favor of a single hostname or a daemon running on the host.

One idea drives the whole design: cloudflared is just another Compose service, and Docker's internal DNS means you never publish a single port, not on the connector, not on any app container.

Why Your Docker Stack Shouldn't Have a Single Open Port

Open ports are liabilities you maintain forever. Each one is a line in a firewall rule, a certificate on a renewal clock, and an entry in some scanner's queue the moment your IP shows up in a DNS A record. Cloudflare Tunnel removes the entire category. Instead of accepting connections, cloudflared makes one outbound connection to Cloudflare's edge and serves your apps through it.

Here is the attack surface, side by side, drawn from Cloudflare's own firewall documentation rather than marketing copy:

SurfaceTraditional VPSCloudflare Tunnel stack
Inbound ports on host80 and 443 open0
TLS managementcertbot or Caddy on a renewal cronAutomatic at the Cloudflare edge
Firewall rulesPer-service iptables/ufw entriesNone inbound
DNS A recordYour public server IPCloudflare anycast IP only
Outbound requirementNone7844 TCP/UDP to argotunnel.com

That last row is the only network change you ever make, and on most VPS hosts you make none, because outbound traffic is permitted by default. cloudflared needs port 7844 open outbound on both TCP (http2) and UDP (quic) toregion1.v2.argotunnel.com andregion2.v2.argotunnel.com. Nothing inbound. If your egress is locked down, those are the only two destinations to allow.

The payoff is structural, not cosmetic. A scanner that finds your origin IP finds nothing to talk to, because the origin IP is never in public DNS to begin with.

What You Need Before You Start

Three things, and this guide assumes you already know Docker and DNS.

  • A Cloudflare account with your domain's nameservers pointed at Cloudflare. The zone has to be active so Cloudflare can answer for your hostnames at its edge.
  • Docker and Docker Compose on the host. Any reasonably current engine works; the connector image is tiny and ships for bothlinux/amd64 andlinux/arm64.
  • A working mental model of Docker service-name resolution. Containers on a shared user-defined network find each other by service name. That single fact is what lets cloudflared reach your apps with no ports published anywhere.

If that last point is hazy, the config.yml section below makes it concrete.

Create the Tunnel in the Cloudflare Zero Trust Dashboard

Go toone.dash.cloudflare.com, then follow Zero Trust > Networks > Connectors > Cloudflare Tunnels > Create a tunnel. Choose the Cloudflared connector type and give the tunnel a name you'll recognize in logs, something likedocker-stack.

The screen that follows is the one that matters. Cloudflare shows you an install command for various platforms, and embedded in it is a long--token eyJ... string. That token is yourTUNNEL_TOKEN. Copy it now; it appears on this connector install screen, not somewhere after the tunnel exists.

Why a token beats mounting a credentials JSON file

The older pattern mounts acredentials.json secret into the container filesystem. It works, but a file on disk is a file that gets committed by accident, copied into an image layer, or read by anything that can exec into the container. The token approach keeps the secret as a single environment variable you source from.env, which you add to.gitignore once and forget. One value, one place, never in the repo.

The credentials-file route also has a famously bad failure mode: when the file is missing, cloudflared complains about a missingcert.pem instead of the actual credentials file, and you lose an afternoon to a red herring. Skipping the file skips the trap.

Write the config.yml That Routes Traffic Inside Docker

Docker runs an embedded DNS resolver at127.0.0.11 inside every container on a user-defined network. Think of it as a private phone book: ask forapp, get back the current IP of theapp container, no host involvement at all. Your config.yml routing leans entirely on that resolver.

Map each public hostname to a sibling service by its Compose name and internal port:

tunnel: docker-stack
ingress:
-   hostname: app.example.com
service: http://app:3000
-   hostname: api.example.com
service: http://api:8080
-   service: http_status:404

Two hostnames, two different containers, one connector.app.example.com lands on theapp container's port 3000;api.example.com lands onapi port 8080. Neither port is published to the host. They don't need to be, because cloudflared and the apps share a Docker network and resolve each other by name.

The finalhttp_status:404 rule is not optional decoration. cloudflared evaluates ingress top to bottom and requires a catch-all; without it, an unmatched request gets dropped in a way that's miserable to debug. The 404 makes the connector say so out loud. One note to avoid a 502: service-name resolution only works on a user-defined network, never the default bridge.

Define the Cloudflare Tunnel Docker Compose Service

Now wire cloudflared into the stack as a peer of your apps. Notice what's absent from the entire file: there is noports: block anywhere, not on the connector and not onapp orapi.

services:
cloudflared:
image: cloudflare/cloudflared:2026.6.1
restart: unless-stopped
command: tunnel --no-autoupdate --config /etc/cloudflared/config.yml run
env_file: .env
volumes:
-   ./config.yml:/etc/cloudflared/config.yml:ro
networks:
-   internal
app:
image: your-app:1.0
restart: unless-stopped
networks:
-   internal
api:
image: your-api:1.0
restart: unless-stopped
networks:
-   internal
networks:
internal:
driver: bridge

The.env file holds the one secret, kept out of version control:

TUNNEL_TOKEN=eyJhIjoi...your-long-token...

Two lines in that compose file carry more weight than they appear to. The first is the pinned image tag.

Pin the version; don't ride:latest

cloudflare/cloudflared:2026.6.1 was the current stable tag on Docker Hub as of 23 June 2026, published a few days earlier. Pin a real version like that, not:latest. cloudflared ships frequently, and:latest is a moving target that can swap underneath you on a silent pull during an unrelated restart, breaking tunnel auth or config parsing with no change on your end. Find the current tag yourself athub.docker.com/r/cloudflare/cloudflared/tags, set it explicitly, and bump it on purpose.

What restart: unless-stopped actually buys you

When the connector goes offline, Cloudflare's edge marks its connections dead and starts returning errors for those hostnames within seconds. The instant a connector re-registers, the edge resumes routing to it.restart: unless-stopped closes that loop: a crashed or OOM-killed connector comes back automatically and re-registers, but a connector you deliberatelydocker compose stop stays down. That's the difference between a self-healing tunnel and a 3 a.m. page.

Bring the Stack Up and Confirm the Tunnel Is Live

Start everything withdocker compose up -d, then read the connector's logs:

docker compose logs -f cloudflared

You're looking for a registration line, something close toRegistered tunnel connection connIndex=0 ... location=.... That confirms cloudflared reached the edge over 7844 and the tunnel is serving traffic. Back in the dashboard, the tunnel's connector status flips to Healthy. Hithttps://app.example.com and you should see your app, with TLS already terminated at Cloudflare's edge and a valid certificate you never requested.

If you need strict startup ordering, cloudflared exposes a readiness probe. Thecloudflared tunnel ready command returns HTTP 200 once the connector is actively connected, and practical Compose healthcheck values areinterval: 1s,timeout: 2s,retries: 60. Wire that into ahealthcheck and let dependent services wait oncondition: service_healthy. For a two-container stack, it's overkill, but it earns its place once another service genuinely depends on the tunnel being up.

Fixes for the Three Errors Almost Everyone Hits First

Each of these has a signature in the logs or the browser. Match the signal first, then apply the fix.

cloudflared exits immediately with 'failed to unmarshal config'

The container starts, printsfailed to unmarshal config, and dies. Almost always the culprit is a YAML indentation slip underingress: or a missing top-leveltunnel: field. Every ingress entry's- hostname andservice must sit at consistent indentation, and the file needs thetunnel: line at the top. Run it through a YAML linter before you blame Cloudflare.

Service returns 502, but cloudflared is up

Signal: the tunnel shows healthy, the hostname loads, and the response is a502. Theservice: target in config.yml almost certainly points somewhere cloudflared cannot reach, typicallyhttp://localhost:3000 instead ofhttp://app:3000. Inside the connector container,localhost is the connector itself, not your app. Use the Docker service name, and confirm cloudflared and the target share the same user-defined network. This is exactly the error the embedded DNS section was built to prevent.

Hostname resolves but Cloudflare returns error 1033

Signal: DNS resolves, but the page shows Cloudflare error1033 tunnel not found. Cause: you defined the hostname in config.yml but never created the matching public hostname route in the dashboard, so the edge has no idea which tunnel owns that name. Fix: open the tunnel in Networks > Cloudflare Tunnels, add the public hostname under Published application routes, and point it at the tunnel. config.yml routes traffic inside Docker; the dashboard tells Cloudflare's edge the hostname exists.

Adding a Second Service Takes Three Lines, Not a New Port

This is where the architecture pays you back. To expose a new service, add the container to the compose file on theinternal network, then add one stanza above the 404 rule in config.yml:

-   hostname: grafana.example.com
service: http://grafana:3000

No new tunnel. No new connector. No published port. Register the public hostname in the dashboard, rundocker compose up -d to reload, and the connector serves it over the same outbound link it already holds open. The tunnel scales with stanzas, not with ports, and your host still has zero inbound rules, same as day one.

Common Questions About Cloudflare Tunnel in Docker

Do I need to open any firewall ports on my server to use Cloudflare Tunnel?

No. Cloudflare Tunnel requires zero inbound ports. cloudflared only needs outbound access on port 7844 (TCP and UDP) to Cloudflare'sargotunnel.com endpoints, and most hosts already permit outbound traffic by default, so typically you change nothing.

Can one cloudflared container handle routing for multiple Docker Compose services?

Yes. A single connector routes any number of hostnames. Each service is one ingress rule in config.yml mapping a public hostname to that container's Docker service name and port, all served over the same tunnel.

Where do SSL certificates come from, and do I manage them manually?

You don't manage them at all. TLS terminates at Cloudflare's edge, which issues and renews the certificate for your hostname automatically. There's no certbot, no Caddy renewal cron, and no certificate stored on your host.

What Docker image tag should I pin for cloudflared in production?

Pin a specific version, such ascloudflare/cloudflared:2026.6.1, never:latest. cloudflared releases often, and:latest can change on a silent pull and break tunnel auth or config parsing. Checkhub.docker.com/r/cloudflare/cloudflared/tags for the current stable tag and update deliberately.

What happens to public traffic if the cloudflared container restarts?

There's a brief outage. When the connector drops, Cloudflare's edge stops routing to it within seconds and returns errors for its hostnames; once the connector restarts and re-registers, the edge resumes routing automatically. Setrestart: unless-stopped so a crashed connector recovers on its own.

Follow Sundar Shahi Thakuri for more posts on containerized infrastructure, AI automation, and agentic engineering setups.

Working through something like this? I help teams ship AI and cloud systems that hold up, and cost what they should.