PilotFoxDocs

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.

RequestServed 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 domainThe 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:cloudflare

wrangler 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 as someone@example.zip). The API marks every /api answer without a directive of its own private, 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):

SettingWhat it does
TRUST_PROXY = cloudflared's fixed addressThe only proxy the API believes. Every Cf-* request header of any other peer is deleted.
CLIENT_IP_HEADER=cf-connecting-ipThe 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=cloudflareA 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. cloudflared passes a text/event-stream response on as it arrives, so each event reaches the browser at once. Cloudflare compresses other responses for visitors.
  • Outbound. cloudflared needs 7844/tcp and 7844/udp to region1.v2.argotunnel.com and region2.v2.argotunnel.com, and DNS; its update checks are off (--no-autoupdate: updates come with a new image tag in docker-compose.yml).

On this page