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.
For this setup, I use one tunnel per host. One cloudflared container routes all services on that host through a shared external Docker network. Services join the network — no extra containers, no extra tokens.
Cloudflare Edge
│
├─ tunnel: machine1 ──── cloudflared
│ ├─ immich.domain.me → immich-server:2283
│ └─ ha.domain.me → homeassistant:8123
│
└─ tunnel: machine2 ─────── cloudflared
├─ seerr.domain.me → seerr:5055
└─ files.domain.me → filebrowser:80
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.
prod-deb-01) -
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.
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:latest
container_name: cloudflared
restart: unless-stopped
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
command: tunnel --no-autoupdate run
networks:
- tunnel-net
networks:
tunnel-net:
external: true
/opt/docker/cloudflared/.env
TUNNEL_TOKEN=your_token_here
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:latest
container_name: cloudflared
restart: unless-stopped
environment:
- TUNNEL_TOKEN=your_token_here
command: tunnel --no-autoupdate run
networks:
- tunnel-net
my-app:
image: your-app-image:latest
container_name: my-app
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 domain.mePath 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.domain.me) -
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 cannot complete the browser-based OTP flow. For services accessed via a native app (e.g. Immich mobile), skip Access and rely on the app's own built-in auth instead. Ensure the app's auth is properly configured before exposing it.