Cloudflare Tunnel
The tunnel is what makes the VM reachable without opening a port. cloudflared runs as a container on the VM and dials outbound to Cloudflare's edge; traffic for api.e-safework.com and storage.e-safework.com comes back down that connection. The firewall allows SSH only — there is no inbound 80/443 and no TLS certificate on the box.
Never write the tunnel token into a doc
The token is a full credential for the tunnel. It belongs in /opt/esw/.env as TUNNEL_TOKEN and nowhere else — not in this file, not in a commit, not in a chat message. If one has ever been pasted into a tracked file, rotate it in the Cloudflare dashboard (Zero Trust → Networks → Tunnels → the tunnel → Configure → refresh token) before doing anything else.
Configuration model
This tunnel is remotely managed: the public-hostname → origin mapping lives in the Cloudflare dashboard, not in a config.yml on the VM. The container is started with nothing but the token, and pulls its routing from the edge:
cloudflared:
image: cloudflare/cloudflared:2026.8.2
restart: unless-stopped
command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN:?set TUNNEL_TOKEN in /opt/esw/.env}Two deliberate choices in that block:
- The image tag is pinned.
latestmeans a connector upgrade lands on whatever day you next runup -d, which is the day you are least expecting the tunnel to change. :?on the variable. Without it, an emptyTUNNEL_TOKENrunscloudflared --token "", which fails obscurely. With it, compose refuses to start and says why.
Public hostnames
Set these in the dashboard under the tunnel's Public Hostname tab. Origins are container names on the compose network:
| Hostname | Service |
|---|---|
api.e-safework.com | http://nginx:80 |
storage.e-safework.com | http://minio:9000 |
api is reached through nginx (body-size limit, forwarded headers); MinIO is addressed directly.
No depends_on — on purpose
cloudflared declares no dependencies. It used to name [nginx, minio], but nginx depends on api being healthy, so up -d cloudflared transitively demanded an api image that does not exist in GHCR until CI has built one — making an infrastructure-only first bring-up impossible.
The tunnel retries its origins on its own, so ordering buys nothing. Without the dependency, cloudflared serves 502 for the few seconds before nginx is up, which it would do anyway.
Checking it
# on the VM
docker compose -f /opt/esw/docker-compose.prod.yml logs --tail=50 cloudflared
# expect four "Registered tunnel connection" lines, one per edge colo
# from anywhere
curl -sS -o /dev/null -w '%{http_code}\n' https://api.e-safework.com/GET / is the bare health route outside /api. Everything under /api/v1 is guarded and answers 401 — a 401 from the root would mean you hit the wrong path, not a broken tunnel.
Running it outside Docker
Only useful for a laptop-side test tunnel, never for this deployment:
brew install cloudflared
cloudflared tunnel run --token "$TUNNEL_TOKEN" # token from your shell env, not inline