Blog — Infrastructure
Zero open ports.
Still publicly reachable.
Cloudflare Tunnel lets your server reach out to Cloudflare — never the other way around. No inbound firewall rules, no port 80 or 443 open, no IP exposed. Pair it with Traefik and Docker and you get automatic routing for every container you deploy. This is the setup, from scratch.
Published: — 6 min read
01 Why This Stack
Traditional self-hosting opens ports 80 and 443 to the internet. Your server's IP is public. Every scanner on the planet can reach it directly. The logs from a fresh deployment show what that looks like within hours of going live.
This stack eliminates that exposure entirely:
| Component | Role | What it replaces |
|---|---|---|
| Cloudflare Tunnel | Outbound-only connection from your server to Cloudflare's edge | Open inbound ports, DynDNS, reverse-proxy VMs |
| Traefik | Container-aware reverse proxy — reads Docker labels and routes traffic automatically | Manual nginx vhosts, Caddy Caddyfile entries |
| Docker | Runs your app and Traefik as isolated containers on a shared internal network | Bare-metal installs, manual service management |
The result: your server's IP never appears in DNS. Cloudflare proxies all traffic. The only thing reaching your machine is an outbound HTTPS connection your own cloudflared daemon established.
Your domain must be on Cloudflare (free plan is fine). You need Docker and Docker Compose installed on your server. The cloudflared CLI can run as a Docker container — no host install required.
02 How Traffic Flows
Understanding the request path matters when something breaks. Here it is end to end:
Browser
└─► Cloudflare CDN (TLS terminates here)
└─► Cloudflare Tunnel (encrypted, outbound from your server)
└─► cloudflared daemon (Docker container on your server)
└─► Traefik (internal reverse proxy, same Docker network)
└─► Your app container
A few things worth noting about this path:
- TLS terminates at Cloudflare. Traffic between Cloudflare and your server travels through the tunnel — you control whether that leg is HTTP or HTTPS. For most setups, plain HTTP inside the tunnel is fine because the tunnel itself is encrypted.
- Traefik never touches the internet. It only listens on the internal Docker network. Nothing outside the tunnel can reach it.
- Your server's public IP is never in DNS. Cloudflare's anycast IPs are. Attackers scanning by IP will find nothing on your server's actual address.
03 Step 1 — Create the Cloudflare Tunnel
Create the tunnel
In the Cloudflare dashboard: Zero Trust → Networks → Tunnels → Create a tunnel. Choose "Cloudflared" as the connector type. Give it a name (e.g. myserver). Cloudflare will show you a token — copy it, you'll need it in a moment.
Configure the public hostname
Still in the tunnel config, add a public hostname:
- Subdomain: leave blank for the apex, or enter
www - Domain: your domain
- Service:
http://traefik:80— this is the internal Docker service name and port Traefik listens on
That's it on the Cloudflare side. The tunnel will stay disconnected until your cloudflared container starts and authenticates with the token.
TLS mode
In Cloudflare's SSL/TLS settings for your domain, set the mode to Full (not Full Strict) if you're terminating at Traefik without a valid cert. If you're using Let's Encrypt inside Traefik, use Full (Strict). For static sites behind a tunnel, Flexible is the simplest option — Cloudflare handles TLS to the browser, plain HTTP to your server.
04 Step 2 — Docker Compose Setup
Everything runs in a single docker-compose.yml. Three services: cloudflared, traefik, and your app. They share an internal network called web.
services:
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
networks:
- web
traefik:
image: traefik:v3
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=web"
- "--entrypoints.web.address=:80"
- "--log.level=WARN"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- web
myapp:
image: nginx:alpine
restart: unless-stopped
volumes:
- ./site:/usr/share/nginx/html:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.entrypoints=web"
- "traefik.http.routers.myapp.rule=Host(`mysite.example.com`)"
- "traefik.http.services.myapp.loadbalancer.server.port=80"
networks:
- web
networks:
web:
name: web
Store your tunnel token in a .env file in the same directory — Docker Compose reads it automatically:
# .env — never commit this file
TUNNEL_TOKEN=eyJhIjoiZ...your_token_here
Add .env to .gitignore before your first git init.
Why exposedbydefault=false
Traefik's default behaviour is to route to every container it can see. That means a container you spun up for a test could accidentally become publicly reachable. exposedbydefault=false means only containers with traefik.enable=true in their labels get routes. Explicit over implicit.
Why mount the Docker socket read-only
Traefik watches the Docker socket to detect new containers and add routes automatically. Read-only (:ro) means Traefik can observe but not control Docker — it can't start, stop, or modify containers. The Docker socket is effectively root access to the host; never mount it writable unless you fully understand the implications.
05 Step 3 — Adding More Apps
Adding a second app is just adding labels. No nginx config to edit, no Traefik config to reload.
api:
image: myorg/api:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.entrypoints=web"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"
- "traefik.http.services.api.loadbalancer.server.port=3000"
networks:
- web
Add another public hostname in the Cloudflare tunnel config pointing to http://traefik:80 — the routing is handled by the Host() rule in the Traefik label, not by Cloudflare. One tunnel, many apps.
| Label | What it does |
|---|---|
traefik.enable=true |
Opt this container into Traefik routing |
traefik.http.routers.NAME.rule=Host(`…`) |
Route requests for this hostname to this container |
traefik.http.routers.NAME.rule=Host(`…`) && PathPrefix(`/api`) |
Route by hostname AND path prefix |
traefik.http.services.NAME.loadbalancer.server.port=8080 |
Override which port Traefik forwards to inside the container |
traefik.http.routers.NAME.middlewares=redirect-to-https |
Apply a middleware (e.g. redirect, auth, rate limit) to this router |
06 Step 4 — Deployment
A minimal deploy script — rebuild your app image, bring Compose up with the new image, run a health check:
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="myapp"
DOMAIN="mysite.example.com"
echo "==> Building image..."
docker compose build "$APP_NAME"
echo "==> Restarting app container..."
docker compose up -d --no-deps "$APP_NAME"
echo "==> Health check..."
sleep 3
STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://${DOMAIN}/")
if [[ "$STATUS" == "200" ]]; then
echo " ✓ ${DOMAIN} returns 200"
else
echo " ✗ ${DOMAIN} returned ${STATUS}" >&2
exit 1
fi
echo "==> Done."
--no-deps restarts only the app container — Traefik and cloudflared keep running. There's no downtime window for those services during an app redeploy.
07 Gotchas Worth Knowing
The tunnel hostname must match the Traefik Host() rule
Cloudflare sends requests with the original Host header intact. If your tunnel public hostname is mysite.example.com and your Traefik rule is Host(`www.mysite.example.com`), requests will 404 at Traefik. They must match exactly.
Real visitor IPs get replaced by tunnel IPs
By default, your app sees Cloudflare's internal tunnel IP, not the visitor's real IP. Fix it in two places:
- In Cloudflare Tunnel settings, enable "Send visitor IP headers" — this adds
CF-Connecting-IPandX-Forwarded-Forto each request. - Configure your app (or nginx inside the container) to trust and log
CF-Connecting-IPrather than the remote address.
Without this, rate limiting and log analysis are based on the wrong IP.
Traefik needs to be on the same network as your app
If a container isn't on the web network (or whatever you named it), Traefik can see its labels but can't reach it to forward traffic. The symptom is a 502 from Traefik. Check with docker inspect <container> that it lists the shared network.
Container names are not automatically DNS hostnames inside Docker
Docker's internal DNS resolves service names (from Compose) not container names. Use the Compose service name in your tunnel config (http://traefik:80), not the container name or a hardcoded IP.
Don't expose Traefik's dashboard publicly
Traefik includes a dashboard that shows all routes, middlewares, and services. It's useful locally but exposes your full routing topology if reachable from the internet. Keep it off unless you've put it behind authentication. The config above doesn't enable it — intentionally.
Tunnel token is a credential — treat it as one
The tunnel token authenticates your server to Cloudflare. Anyone with it can run a competing tunnel for your domain. Store it in .env, exclude .env from version control, and rotate it if it's ever exposed.
08 Security Trade-offs
This setup meaningfully reduces your attack surface — but it shifts some trust to Cloudflare. Worth being clear about:
| Property | Status | Notes |
|---|---|---|
| Server IP hidden from DNS | Yes | Cloudflare's IPs appear in DNS, not yours |
| No inbound ports required | Yes | Firewall can block all inbound; tunnel is outbound |
| DDoS absorption | Yes | Cloudflare's network absorbs volumetric attacks before they reach you |
| WAF / bot protection | Optional | Available on paid Cloudflare plans; basic bot fight mode on free |
| Traffic visibility | Partial | Cloudflare sees plaintext if using Flexible TLS mode |
| Docker socket exposure | Managed risk | Traefik requires it; mount read-only and restrict to the Traefik container |
| Single point of failure | Yes | If Cloudflare is down, your site is unreachable — this is true of any Cloudflare-proxied site |
For personal projects, small teams, and static or low-traffic sites, this stack gives you a genuinely strong security posture with very little operational overhead. The main thing you're trusting is Cloudflare — which is a reasonable trust boundary for most use cases.
Published: