Docker Homelab
Blue shipping containers stacked on a server connect by a blue cable through a padlocked tunnel pipe beneath a cloud to a laptop, in soft 3D style.
networking

How to Expose Docker Services with Cloudflare Tunnel

Run cloudflared in Docker Compose, route subdomains to containers by name, add Cloudflare Access and a healthcheck. No open ports, works behind CGNAT.

By Docker Homelab Editorial · · 8 min read

Here is how to expose Docker services with Cloudflare Tunnel: run the cloudflare/cloudflared container on the same Docker network as your apps, create a tunnel in the Cloudflare dashboard, hand its token to the container, then add one published route per subdomain pointing at http://container-name:port. Your router gets zero open ports, and it works behind CGNAT.

Add Cloudflare Access in front of admin pages and a healthcheck so Docker knows when the tunnel is down. The catch: Cloudflare decrypts your traffic at its edge, and its terms are unfriendly to video streaming.

Who this is for (and who should skip it)

This is for you if you want browser-friendly public URLs for a handful of web apps, your ISP has you behind CGNAT or you’d rather not port-forward, and the people using those apps will never install a VPN client.

Skip it if you are the only user. Tailscale on your phone and laptop is less work, nothing becomes public, and Cloudflare stays out of the data path. Also skip it if the real goal is streaming Jellyfin or Plex away from home, for reasons covered below.

You need a domain whose DNS is an active zone on Cloudflare (moving nameservers is the slow part), a free Cloudflare account, and Docker with compose v2. Hardware barely matters: any N100 box from our mini PC tier list is plenty, and arm64 images cover a Pi or ARM NAS.

What does Cloudflare Tunnel actually do?

Cloudflare Tunnel is an outbound-only connector. The cloudflared daemon dials out from your server to Cloudflare’s edge and holds those connections open. When someone requests app.example.com, Cloudflare sends the request down the tunnel and cloudflared proxies it to the service URL you mapped. Nothing listens on your WAN IP.

Cloudflare’s tunnel overview puts it simply: “Most firewalls allow outbound traffic by default,” so you can block all inbound traffic and still serve requests. A tunnel shows as Healthy when it holds four connections to Cloudflare’s network, per the monitoring docs.

On a Docker host, cloudflared becomes your public reverse proxy. Use a remotely-managed tunnel, created in the dashboard: routing lives in Cloudflare, the container needs only a token, and there is no config file to mount.

Step 1: Create the tunnel and copy the token

In the Cloudflare dashboard, go to Networking > Tunnels > Create a tunnel. Name it after the host (nuc-docker, not tunnel1; you will thank yourself when there are two) and pick Docker as the environment. From the docker run command it shows, copy only the long --token value. The remote tunnel guide covers these screens if the labels have moved.

Create the shared network once and store the token next to your compose file:

docker network create tunnel
echo "TUNNEL_TOKEN=paste-your-token-here" > .env
chmod 600 .env

Treat that token like a password. It is the tunnel’s only credential, and anyone holding it can attach a connector to your tunnel and receive a share of its requests.

Step 2: The compose stack

services:
  cloudflared:
    image: cloudflare/cloudflared:2026.9.3
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${TUNNEL_TOKEN}
      TUNNEL_METRICS: 127.0.0.1:60123
    healthcheck:
      test: ["CMD", "cloudflared", "tunnel", "--metrics", "127.0.0.1:60123", "ready"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s
    networks:
      - tunnel

  whoami:
    image: traefik/whoami
    container_name: whoami
    restart: unless-stopped
    networks:
      - tunnel

networks:
  tunnel:
    external: true

Run docker compose up -d; after the first 30-second check interval, docker ps should show cloudflared as (healthy). Why it looks like this:

  • Pinned tag. 2026.9.3 is the newest cloudflared release at the time of writing. Bump it on purpose.
  • --no-autoupdate. Containers get updated by replacing the image, not by the binary patching itself.
  • The healthcheck. The image has no curl or wget, which a GitHub issue complained about. PR #1135, merged November 2024, added cloudflared tunnel ready, which fails unless an edge connection is up. It needs a known metrics address, hence the fixed TUNNEL_METRICS; in containers the default is 0.0.0.0, and loopback keeps it off your network.
  • The token shows up in docker inspect. cloudflared 2025.4.0 and later accept TUNNEL_TOKEN_FILE (run parameters), which pairs with a compose secret.

whoami has no ports: section; cloudflared reaches it by container name via Docker’s embedded DNS (see the Docker Compose networking guide). Apps in other compose projects join the same network:

services:
  myapp:
    # existing config unchanged
    networks:
      - default
      - tunnel

networks:
  tunnel:
    external: true

Keep default in that list or the app loses its database sibling. Keep ports: only if you still want LAN access by IP and port.

How do you route a subdomain to a container?

Open the tunnel, go to Routes > Add route > Published application, and enter a subdomain, your domain, and a service URL of http://<container-name>:<internal-port>. Use the port the app listens on inside its container, never a host-published port and never localhost. Adding the route in the dashboard creates the DNS record automatically.

OriginService URL
Container on the tunnel networkhttp://whoami:80
Immich serverhttp://immich-server:2283
Host-network container (Home Assistant)http://192.168.1.10:8123
HTTPS-only app, self-signed certhttps://portainer:9443 plus No TLS Verify

The localhost trap catches nearly everyone once. Inside the cloudflared container, localhost is cloudflared itself, so the dial fails with “connection refused” and the visitor gets a 502. Cloudflare’s common errors page documents it, plus the redirect loop you get when an http:// service URL hits an app that forces HTTPS.

Containers using network_mode: host can’t join a bridge network, so route them to the host’s LAN IP; the host networking guide explains why.

Two more gotchas from the tunnel guide: a multi-level subdomain like photos.home.example.com needs a paid Advanced Certificate, and path routes pass the full path through unchanged. Stick to single-level subdomains.

Should you put Cloudflare Access in front of it?

Yes, for anything with an admin panel or a login you wouldn’t bet the house on. Access adds a Cloudflare-hosted login, either a one-time PIN sent to an allowed address or an identity provider such as GitHub, before any request reaches your container. Cloudflare lists the free tier as “$0 forever” for teams under 50 users.

In the Zero Trust dashboard, go to Access controls > Applications > Create new application > Self-hosted, add the public hostname, and attach an Allow policy listing who gets in. Per the Access docs, applications are deny-by-default; the route’s “Protect with Access” option also validates the Access token at the origin.

Where Access hurts: native mobile and TV apps generally talk straight to the API and can’t complete a browser login. For those hostnames, bypass Access and rely on the app’s own authentication, or keep them off the tunnel. For Vaultwarden, Tailscale is the calmer choice.

What breaks, or gets you in trouble?

Three things: uploads over 100 MB per request fail on the free plan, streaming video runs against Cloudflare’s CDN terms, and Cloudflare decrypts every request at its edge. None of that matters for a recipe app. All of it matters for media.

Uploads. Free and Pro zones cap uploads at 100 MB per request. A phone backing up a long 4K clip to Immich can blow past that.

Video. Cloudflare’s service terms say non-Enterprise customers must use paid services such as Stream “in order to serve video and other large files via the CDN,” and reserve the right to limit accounts that don’t. Stream your Jellyfin server over Tailscale or a port-forward instead.

Privacy. Cloudflare terminates TLS and can read every request. If that bothers you for a given app, don’t tunnel it.

Cloudflare Tunnel vs Tailscale vs port forwarding

Cloudflare TunnelTailscalePort forward + reverse proxy
Open router portsNoneNone80 and 443
Works behind CGNATYesYesNo
Visitors need a clientNoYes (except Funnel)No
Who terminates TLSCloudflareYouYou
Large uploads and videoRestrictedFineFine

Tailscale Funnel is the middle ground, but it “can only listen on ports 443, 8443, and 10000” and has non-configurable bandwidth limits. Port forwarding with Traefik or Nginx Proxy Manager gives full control but needs a real public IP. The boring, correct setup for most homelabs: a tunnel for two or three public web apps, Tailscale for everything else.

Operations

Updates. Cloudflare supports cloudflared versions within one year of the latest release, so a pinned tag can’t sit forever. Let Renovate open a PR or bump it monthly. Watchtower works, but auto-updating your only way into the house is how you lose remote access on holiday.

Logs and alerts. docker logs -f cloudflared shows the origin error behind every 502. Point an uptime monitor at the public hostname, not the container, so it tests the whole path; a stopped tunnel shows visitors a 1016 error.

Redundancy. A tunnel can run several connectors, but each must reach every service URL. Container names only resolve on the original Docker network, so a second connector on your NAS would 502 its share of requests. Use LAN IPs in routes or skip replicas.

Backups and the restore drill. Routes live in Cloudflare, so local state is the compose file and the token. Keep the token in your password manager and a route list in a README beside the compose file. On a fresh host: create the network, copy both files, docker compose up -d, check for (healthy). If the token leaks, delete the tunnel and rebuild routes from the README.

FAQ

is cloudflare tunnel free

Yes, Cloudflare Tunnel costs nothing on a free Cloudflare account, and Cloudflare Access, the login layer worth putting in front of admin pages, is free for teams under 50 users. The real costs are a domain name and the free plan’s limits, including a 100 MB per-request upload cap and terms that restrict video streaming.

do i need a reverse proxy with cloudflare tunnel

No. cloudflared already routes public traffic by hostname, and Cloudflare manages the public certificate, so Traefik or Nginx Proxy Manager is optional. Keep a reverse proxy only if you also want clean hostnames and HTTPS on your LAN, or if some services must stay reachable without Cloudflare sitting in the path.

can i use cloudflare tunnel without a domain

Only for testing. Quick Tunnels give you a temporary trycloudflare.com URL with no account or domain required, but each one is limited to 200 in-flight requests and Cloudflare says they are meant for development. Persistent, named routes to your Docker services need a domain whose DNS is an active zone on Cloudflare.

is cloudflare tunnel secure for a homelab

It is safer than port forwarding, because nothing on your network accepts inbound connections, but it is not private. Cloudflare terminates TLS and can read the traffic, and a tunnel will happily publish a weak login page to the entire internet. Put Cloudflare Access on admin interfaces and keep each app’s own authentication switched on.

Sources

  1. Cloudflare Tunnel overview (Cloudflare One docs)
  2. Create a remotely-managed tunnel (Cloudflare docs)
  3. Tunnel routing and DNS records (Cloudflare docs)
  4. cloudflared run parameters (Cloudflare docs)
  5. Tunnel metrics (Cloudflare docs)
  6. Add tunnel ready command, PR #1135 (cloudflare/cloudflared on GitHub)
  7. Common tunnel errors (Cloudflare docs)
  8. Publish a self-hosted application with Access (Cloudflare docs)
  9. Cloudflare Access plans
  10. Default cache behavior and upload limits (Cloudflare docs)
  11. Service-Specific Terms: Application Services (Cloudflare)
  12. cloudflared downloads and version support (Cloudflare docs)
  13. Quick Tunnels (Cloudflare docs)
  14. Tailscale Funnel (Tailscale docs)
#cloudflare-tunnel#cloudflared #docker-compose #remote-access#zero-trust

Related