Cloudflare Tunnels¶
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¶
-
Log in to the Cloudflare Zero Trust Dashboard
-
Navigate to Networks > Connectors > Cloudflare Tunnels
-
Click Create a tunnel, choose Cloudflared as the connector type, and select Next
-
Enter a name for the tunnel — use the host name (e.g.
<YOUR_HOSTNAME>) -
Click Save tunnel
-
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.
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
Pinning, auto-update and healthchecks
- Pin a release tag. Inside a container
cloudflaredcan't replace its own binary, so the tag is what decides the version and a plaintunnel runis enough. If you want to be explicit, Cloudflare documents the global flag astunnel --no-autoupdate run(it goes beforerun). - The image is distroless (no shell,
curlorwget), so aCMD-SHELLhealthcheck 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:
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.
-
Navigate to Networks > Connectors > Cloudflare Tunnels
-
Click your tunnel → Published application routes tab → Add a published application route
-
Fill in the details:
Field Value Subdomain e.g. immichDomain <YOUR_DOMAIN>Path leave empty Service Type HTTPURL immich-server:2283(container name + internal port) -
Under Additional application settings, enable Protect with Access — this instructs
cloudflaredto validate the Access JWT on every request, rejecting anything that bypasses the Access layer. -
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.
-
Navigate to Access controls > Applications
-
Click Add an application → Self-hosted
-
Configure:
Field Value Application name e.g. ImmichSession Duration 30 days(recommended for personal use)Application domain Match exactly what was set in the tunnel route (e.g. immich.<YOUR_DOMAIN>) -
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.
-
Configure the policy:
Field Value Policy Name e.g. Allow MeAction Allow -
Under Configure rules:
- Include: Select
Emails→ add each allowed email address - Or use Email ending in for a whole domain
- Include: Select
-
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.