Skip to content

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:

yaml
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. latest means a connector upgrade lands on whatever day you next run up -d, which is the day you are least expecting the tunnel to change.
  • :? on the variable. Without it, an empty TUNNEL_TOKEN runs cloudflared --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:

HostnameService
api.e-safework.comhttp://nginx:80
storage.e-safework.comhttp://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

bash
# 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:

bash
brew install cloudflared
cloudflared tunnel run --token "$TUNNEL_TOKEN"    # token from your shell env, not inline