Cloudflare
The Cloudflare side of a self-hosted instance: the tunnel to the server, the web app's Worker, the routes and the zone's settings.
Cloudflare is the front of every instance. It serves the web app and the public site from a Worker with static
assets, and hands the API's requests to your server through a Cloudflare Tunnel: a cloudflared container on the
server connects out to Cloudflare, so the server needs no inbound port at all. The app, the API, the tracking script
and the emails all stay on one origin, your PUBLIC_URL, so cookies stay same-site and nothing needs CORS.
| Request | Served by |
|---|---|
/api/*, /.well-known/*, /maps/* | The API, through the tunnel: Workers routes with no Worker, which "negate any less specific patterns" |
| every other path on your domain | The web app's Worker, on the route <your domain>/* (frontend/wrangler.jsonc) |
your docs host (pilotfox.app: docs.pilotfox.app) | This documentation's Worker, on a custom domain (pilotfox-documentation/wrangler.jsonc), if you serve it |
No Worker runs for an API request, so the API sees Cloudflare's CF-Connecting-IP and visitor location headers as for
any request Cloudflare passes on. The operator guide, backend/docs/deploy.md ("Cloudflare"), has every command.
Set it up
Create the tunnel
Dashboard → Networking → Tunnels → Create a tunnel, and name it. The next page shows an installation
command per operating system: copy only the token from it (the long string starting with eyJ) and do not run the
command, since docker-compose.yml runs cloudflared. Keep it in a file only you can read, or in Doppler as
CLOUDFLARE_TUNNEL_TOKEN (The tunnel token). Anyone with the token can
run your tunnel; the tunnel's Overview → Refresh token replaces it.
Deploy the web app's Worker
From a checkout, with an account-owned API token from the Edit Cloudflare Workers template in
CLOUDFLARE_API_TOKEN and your account ID in CLOUDFLARE_ACCOUNT_ID. First set the route in frontend/wrangler.jsonc
to your domain ("pattern": "analytics.example.com/*", "zone_name": "example.com"), then:
cd frontend && bun install --frozen-lockfile && bun run deploy:cloudflarewrangler deploy builds the web app and the public site first, uploads them as static assets, and creates the route.
The developer documentation deploys the same way from pilotfox-documentation/, on a host name of its own (it uses
the root of its origin). The repository's workflow backend/ci/github/workflows/workers.yml can deploy both on every
push.
Send the API's paths to the tunnel
Create three routes without a Worker, once: <your domain>/api/*, <your domain>/.well-known/* and
<your domain>/maps/* (Workers Routes in the dashboard, or the API's "Create Route" with only a pattern). The most
specific route wins, and a route without a Worker sends its requests to the zone's DNS record, which step 4 points at
the tunnel.
Point the tunnel at the API
Once the server runs (Start the stack) and the dashboard shows the
tunnel connected: the tunnel's Routes tab → Add route → Published application, your domain, no path,
Service URL http://api:3001. Cloudflare creates the proxied DNS record that points the name at the tunnel; an
existing A or AAAA record of the same name has to go first.
Set the zone's settings
-
SSL/TLS → Edge Certificates → Always Use HTTPS on. The Worker and the API send HSTS themselves.
-
Rules → Settings → Managed Transforms: Add visitor location headers on, so visitors get a region, city and time zone, not only a country. Leave Remove visitor IP headers off: it removes
CF-Connecting-IP, and every visitor would then share a Cloudflare server's address (sessions, rate limits and IP exclusions all key on it). -
Cache Rules: a rule that bypasses the cache for the API except the tracking script's public files. Under When incoming requests match, choose Edit expression and enter:
starts_with(http.request.uri.path, "/api/") and not (http.request.uri.path in {"/api/s.js" "/api/r.js"} or starts_with(http.request.uri.path, "/api/c/"))Set Cache eligibility to Bypass cache, and place the rule after any other Cache Rule that matches
/api/*. Cloudflare caches by file extension, not by content type, and an API URL can end in one (a user id such assomeone@example.zip). The API marks every/apianswer without a directive of its ownprivate, no-store, which Cloudflare does not cache; the rule still holds under a Cache Rule whose Edge TTL ignores the origin's headers. -
Optionally HTTP/3 for visitors: Speed → Settings → Protocol Optimization.
Check
https://<your domain>/ is the home page and /login the app, /api/v1/health answers {"data":{"ok":true}},
/api/v1/ready answers 404, and new sessions show visitors' own countries. On the server:
docker compose exec -T api \
node -e "fetch('http://127.0.0.1:3001/api/v1/ready').then((r) => r.json()).then((j) => console.log(j.data.geoip, j.data.geoFallback))"prints the MaxMind state and cloudflare.
What the API believes
docker-compose.yml sets these for the API (Set by the compose file):
| Setting | What it does |
|---|---|
TRUST_PROXY = cloudflared's fixed address | The only proxy the API believes. Every Cf-* request header of any other peer is deleted. |
CLIENT_IP_HEADER=cf-connecting-ip | The visitor is Cloudflare's CF-Connecting-IP, "the client IP address connecting to Cloudflare", never an X-Forwarded-For a visitor can prefill. Without a valid one, the visitor is cloudflared itself. |
GEOIP_FALLBACK=cloudflare | A visitor MaxMind cannot place takes Cloudflare's country, continent, region, city, coordinates and time zone, all together; MaxMind stays first. Cloudflare sends no network (ASN), so network names and the datacenter flag still need MaxMind. |
READY_LOOPBACK_ONLY=true | /api/v1/ready, which names the stores and their errors, answers the container's own health check only; a 404 from outside. |
Why CF-Connecting-IP can be believed: only a request that came through the tunnel reaches the API from
cloudflared's address. The API publishes no port, the tunnel network holds only the two of them, and a tunnel
serves only the routes of its own Cloudflare account ("The cfargotunnel.com subdomain only proxies traffic for DNS
records in the same Cloudflare account",
DNS records),
so nobody can send the server a request around Cloudflare. Cloudflare's docs do not say whether the edge removes a
location header a visitor sends when it has no value of its own for it; at worst such a visitor misplaces their own
visits, as a VPN does. Server-side events (/api/v1/ingest/*, API keys) never use these headers.
The web app's Worker
Pages, their .md twins and every other file are static assets, which are not billed as Worker requests; a small
script (frontend/site/src/worker.ts) answers only what no file matches: the app's own URLs (the app's index.html,
marked noindex), the not-found page with status 404 for an unknown /docs page, and the redirects of /docs/…/ and
the legal pages' URLs with a trailing slash. The generated _headers and _redirects files give each page its URL
and the security headers (frame-ancestors 'none', nosniff, Referrer-Policy, HSTS), the cache lifetimes and the
Markdown twins' Link headers. frontend/site/src/cloudflare.test.ts runs the Worker locally in wrangler dev and
checks every rule. On the Workers Free plan the script may run 100 000 times a day (first loads of app URLs, unknown
paths, redirects); static files are free and unlimited.
What else the Cloudflare route changes
- Request bodies. Cloudflare refuses a request body over the plan's limit with 413 before it reaches the server: 100 MB on Free and Pro, 200 MB on Business. Tracker events and replay chunks are far below it; an import file above it cannot be uploaded.
- Long responses. Cloudflare answers 524 when the origin sends nothing for 125 seconds. The API's own idle deadlines are shorter (exports 60 s, replay pages 30 s), and the live stream sends a heartbeat every 15 s.
- The live stream.
cloudflaredpasses atext/event-streamresponse on as it arrives, so each event reaches the browser at once. Cloudflare compresses other responses for visitors. - Outbound.
cloudflaredneeds 7844/tcp and 7844/udp toregion1.v2.argotunnel.comandregion2.v2.argotunnel.com, and DNS; its update checks are off (--no-autoupdate: updates come with a new image tag indocker-compose.yml).