Skip to content

Cloudflare Tunnels

Cloudflare docs Cloudflare


Cloudflare Tunnel provides a secure way to connect resources to Cloudflare without a publicly routable IP address. Instead of sending traffic to an external IP, cloudflared creates outbound-only connections to Cloudflare's global network — no open ports, no exposed home IP.

This page covers the one tunnel per host design: every Docker host runs its own cloudflared container, and each public hostname is a route on that host's tunnel pointing straight at a container. Services join a shared external Docker network. No reverse proxy is involved.

Not what I run now: this is the alternative approach

I started with one tunnel per host. The live setup is now a single cloudflared connector that sends every public hostname to Nginx Proxy Manager, with one wildcard DNS route and one wildcard Access application in front. See Tunnel + NPM (Wildcard).

Why it changed. I didn't write down a single reason at the time, so here is the trade-off as it stands:

One tunnel per host (this page) One connector → NPM (current)
Adding a public service New route + DNS record + Access app in the Cloudflare dashboard Add a proxy host in NPM; the wildcard route already covers it
Where routing lives Cloudflare dashboard, per tunnel NPM, next to the LAN names: one place for internal and external
Tokens and connectors One per host One
Containers on other hosts Must sit on that host's tunnel network NPM reaches them over the LAN
Default exposure Only what you explicitly route Anything you add to NPM under the public domain is public (behind Access) at once
Blast radius A host's tunnel only reaches that host The connector + NPM reach every proxied service
Single point of failure Each host is independent NPM and the one connector carry all remote access

The per-host design is the safer default: deny-by-default, explicit routes, smaller blast radius. The NPM design is much less work to run day to day. If you pick the wildcard model, read the hardening notes on the Tunnel + NPM page.

Cloudflare Edge
    │
    ├─ tunnel: server1 ──── cloudflared
    │                               ├─ immich.<YOUR_DOMAIN>   → immich-server:2283
    │                               └─ ha.<YOUR_DOMAIN>       → homeassistant:8123
    │
    └─ tunnel: server2 ──── cloudflared
                                    ├─ seerr.<YOUR_DOMAIN>    → seerr:5055
                                    └─ pdf.<YOUR_DOMAIN>      → bentopdf:8080

Note

Cloudflare terminates HTTPS and manages certificates automatically. No ports need to be opened on OPNsense, and no SSL certs need to be managed manually.


1. Create the Tunnel

  1. Log in to the Cloudflare Zero Trust Dashboard

  2. Navigate to Networks > Connectors > Cloudflare Tunnels

  3. Click Create a tunnel, choose Cloudflared as the connector type, and select Next

  4. Enter a name for the tunnel — use the host name (e.g. <YOUR_HOSTNAME>)

  5. Click Save tunnel

  6. On the Install connector page, select Docker and copy the token from the provided command — it's the long string after --token


2. Create the Shared Tunnel Network

On the host, create a persistent external Docker network. This must exist before any stack tries to join it.

docker network create tunnel-net

This network persists independently of any compose stack. Only create it once per host.


3. Deploy the Connector

One Tunnel per Host

Recommended

Create a standalone compose stack for cloudflared. This is the only place the tunnel token lives.

/opt/docker/cloudflared/compose.yaml

services:
  cloudflared:
    image: cloudflare/cloudflared:2026.10.3   # pinned; Renovate opens a PR for each new release
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel run
    env_file:
      - .env              # TUNNEL_TOKEN lives here, never in git
    dns:
      - <YOUR_DNS_SERVER_IP>
    networks:
      - tunnel-net

networks:
  tunnel-net:
    external: true

/opt/docker/cloudflared/.env

.env.example
TUNNEL_TOKEN=<YOUR_TUNNEL_TOKEN>

Pinning, auto-update and healthchecks

  • Pin a release tag. Inside a container cloudflared can't replace its own binary, so the tag is what decides the version and a plain tunnel run is enough. If you want to be explicit, Cloudflare documents the global flag as tunnel --no-autoupdate run (it goes before run).
  • The image is distroless (no shell, curl or wget), so a CMD-SHELL healthcheck isn't practical. Monitor the tunnel from the outside instead: an uptime check against a public URL that doesn't sit behind Access (a monitor that follows the Access redirect reports UP even when the backend is dead).

Start it:

cd /opt/docker/cloudflared
docker compose up -d

The connector will appear in the Zero Trust dashboard. Once running, click Next in the dashboard to proceed. The tunnel should show as Healthy under Networks > Connectors.

3a. One Tunnel Per Service

Instead of one tunnel per host, it is possible to run a separate cloudflared container per service — colocated in the same compose stack.

Not Recommended

Each cloudflared container establishes four outbound connections to Cloudflare's edge. With 10+ services, that's 40+ persistent connections and 10+ containers doing nothing but tunneling — more compose files to maintain, more tokens to track, more things to break. Per the Cloudflare community, one tunnel per host is the recommended approach.

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

  my-app:
    image: your-app-image:latest
    container_name: my-app
    restart: unless-stopped
    networks:
      - tunnel-net
    # No ports need to be exposed to the host!

networks:
  tunnel-net:
    name: tunnel-net

4. Expose a Service

For any service you want to expose externally, add it to the shared tunnel-net network. No cloudflared container needed in the service's compose file.

services:
  my-app:
    image: your-app-image:latest
    container_name: my-app
    restart: unless-stopped
    networks:
      - tunnel-net      # joins the shared tunnel network
      - internal-net    # for DB, cache, etc — isolated from tunnel

networks:
  tunnel-net:
    external: true      # must already exist on the host
  internal-net:
    internal: true      # no external access

The service URL used in Cloudflare is the container name and internal port — not the host port.

container_name: bentopdf      →  http://bentopdf:8080
container_name: immich-server →  http://immich-server:2283

Warning

Do not expose ports: to the host for services routed through the tunnel. Traffic enters via Cloudflare only. Services not going through the tunnel may still use ports: for internal LAN access — this is intentional.


5. Add a Published Application Route

Tell Cloudflare where to route traffic for this service. Per the official docs, this is done immediately after the connector is running — before or alongside creating the Access application.

  1. Navigate to Networks > Connectors > Cloudflare Tunnels

  2. Click your tunnel → Published application routes tab → Add a published application route

  3. Fill in the details:

    Field Value
    Subdomain e.g. immich
    Domain <YOUR_DOMAIN>
    Path leave empty
    Service Type HTTP
    URL immich-server:2283 (container name + internal port)
  4. Under Additional application settings, enable Protect with Access — this instructs cloudflared to validate the Access JWT on every request, rejecting anything that bypasses the Access layer.

  5. Select Save. Cloudflare automatically creates the CNAME DNS record.

Warning

The route is publicly reachable as soon as it's saved. Proceed immediately to step 6 to lock it down with an Access application.

Note

Docker's internal DNS resolves container names across the shared tunnel-net network — use the container name, not an IP address.


6. Create an Access Application

Per the official docs, creating an Access application is what controls who can reach the published route. All Access applications are deny-by-default — a user must match an Allow policy to be granted access.

  1. Navigate to Access controls > Applications

  2. Click Add an application → Self-hosted

  3. Configure:

    Field Value
    Application name e.g. Immich
    Session Duration 30 days (recommended for personal use)
    Application domain Match exactly what was set in the tunnel route (e.g. immich.<YOUR_DOMAIN>)
  4. Click Policies tab

Create an Access Policy

Access Policies

There are many different options regarding the access policy. Email is the easiest for this guide to show the process, however you can include, exclude or require various things including names, countries, etc.

  1. Configure the policy:

    Field Value
    Policy Name e.g. Allow Me
    Action Allow
  2. Under Configure rules:

    • Include: Select Emails → add each allowed email address
    • Or use Email ending in for a whole domain
  3. Click Next through Setup, then Add application

Tip

For multiple users, create a reusable group under Reusable components > Lists, add emails there, then reference the group in each Access policy instead of listing emails individually.

Native apps and Access

Cloudflare Access breaks native app authentication: mobile apps can't complete the browser-based login. For a service used from a native app (a photo-backup app, a password manager), add an Access policy with the Bypass action for that one hostname, and only when the app has strong authentication of its own: a real login, MFA enforced for every user, and login rate limiting or bans. Everything else stays behind an Allow policy. Review bypassed hostnames now and then; each one is a service that faces the internet on its own.


Sources & further reading