PilotFoxDocs

Self-hosting overview

Run PilotFox yourself: the one compose file, what runs where, and what is exposed to the internet.

PilotFox runs one way, the way pilotfox.app runs:

  • Your server runs docker-compose.yml from the root of this repository: the API, Postgres 17 with its continuous backups, ClickHouse, Valkey and cloudflared. It publishes no port.
  • Cloudflare serves the web app and the public site from a Worker, and sends the API's paths to your server through a Cloudflare Tunnel.

The only image built is the API's (backend/apps/api/Dockerfile), on the server from a checkout of this repository (no release images are published yet); the others are pulled. Every command in these pages runs from the repository root. The operator guide in the repository, backend/docs/deploy.md, has the reasoning and test notes behind these procedures.

A self-hosted instance runs the same API, web app and tracking script as the rest of these docs describe. Wherever an example uses https://pilotfox.app, use your instance's PUBLIC_URL instead.

What runs where

                visitors (HTTPS, HTTP/3)
                          │
               ┌──────────▼───────────┐
               │   Cloudflare's edge  │  /api/*, /.well-known/*, /maps/*  → the tunnel
               │                      │  every other path                 → the web app's Worker
               └──────────┬───────────┘
                          │  tunnel (outbound from the server, 7844/tcp+udp)
   your server ┌──────────▼───────────┐
               │     cloudflared      │  network "tunnel" (cloudflared is 10.89.242.2)
               └──────────┬───────────┘
               ┌──────────▼───────────┐
               │         api          │  REST API, tracker, ingest, jobs, MCP
               └──┬────────┬────────┬─┘
                  │        │        │      network "stores"
         ┌────────▼──┐ ┌───▼──────┐ ┌▼─────────┐
         │ postgres  │ │clickhouse│ │  valkey  │
         └────┬──────┘ └──────────┘ └──────────┘
              │ socket
         ┌────▼──────┐
         │ pgbackrest│  base backups; postgres archives its WAL ──► R2 (network "postgres-egress")
         └───────────┘
ServiceImageWhat it doesData
apimine-api:local, built from backend/apps/api/DockerfileThe REST API, the tracking script and its ingest endpoints, server-side ingest, the MCP server, and the background jobs (a job queue in Postgres). Runs the database migrations at start.api-data (event spool), geoip, maps volumes
postgresghcr.io/saiakashneela/postgres-ssl-pgbackrest:pg17Postgres 17 with TLS: accounts and a record of each sign-in session (deleted within a day after the session ends), organizations, sites and their settings, API keys, share links, the job queue. Archives every change to R2 when R2 is set.postgres-data volume
pgbackrestthe same imageTakes Postgres's base backups to R2 on a schedule, by itself; sleeps without R2.none (reads postgres-data)
clickhouseclickhouse/clickhouse-server:26.8Events, bot events, the session replay index, identities, payments and subscriptions, Search Console data.clickhouse-data volume
valkeyvalkey/valkey:9.1-alpineSign-in sessions, visitors' open sessions, live presence, rate limits and caches.valkey-data volume (append-only file)
cloudflaredcloudflare/cloudflared, a fixed releaseConnects out to Cloudflare and hands the tunnel's requests to the API.none (the token is a compose secret)
The web app and public sitea Cloudflare Worker (frontend/wrangler.jsonc)The dashboard, sign-in and the public pages, as static assets.none

The compose project is mine-analytics. Six networks keep the services apart:

  • app: the API's route out (MaxMind, R2, mail, payment providers).
  • stores: the API, Postgres, ClickHouse and Valkey. It is internal, with no route out.
  • clickhouse-egress: ClickHouse alone, so it can write its backups to R2.
  • postgres-egress: Postgres and pgbackrest, so they can write Postgres's backups to R2.
  • tunnel: cloudflared and the API, internal. cloudflared has the fixed address 10.89.242.2, the only peer whose forwarding headers the API believes (TRUST_PROXY). TUNNEL_NET_PREFIX moves the subnet.
  • tunnel-egress: cloudflared alone, its route to Cloudflare.

Optional services extend what the instance does: Cloudflare Email Sending (or an SMTP server) for mail, a MaxMind account for visitor locations, a Cloudflare R2 bucket for session replay, imports and backups (Postgres's continuous ones included), and a Google Cloud OAuth client for Search Console. See Requirements.

What is exposed

No service publishes a port, and the server needs no inbound port at all. Everything reaches it through the tunnel, which serves only the routes of your own Cloudflare account.

Path on your domainServed by
/api/*, /.well-known/*, /maps/*The API, through the tunnel. Cloudflare's CF-Connecting-IP names the visitor.
/api/v1/readyNobody: the API answers 404 to everyone but its own container's health check, since the answer names internal store errors.
/maps/tiles/*The API: the map's region boundary tiles.
/ and /docs/*The public site, from the Worker. Its pages name https://pilotfox.app as their canonical address.
everything elseThe web app, from the Worker. Unknown paths get the app's index.html, marked noindex.

The /docs/* pages are the public site's user guides. This developer documentation is a separate static site, built from pilotfox-documentation/ (bun run build writes out/) and served by a Worker of its own on a host name of its own (pilotfox.app's is docs.pilotfox.app). Every instance serves its own API reference at /api/docs/ and the OpenAPI document at /api/docs/openapi.json.

  • Headers. The API sends its own security headers. For the web app the Worker sends Content-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin and Strict-Transport-Security: max-age=31536000; includeSubDomains.
  • No visitor addresses or tokens in logs. The API's request log keeps no visitor address and no URL values (share tokens travel in URLs); cloudflared logs requests only at its debug level, which is off; the Worker has Workers Logs off.
  • Compression. Cloudflare compresses responses for visitors; the live event stream is passed on event by event.

For administration, open the stores through compose: docker compose exec postgres psql -U app -d app, docker compose exec clickhouse clickhouse-client and docker compose exec valkey valkey-cli, which need no password typed (Postgres trusts its own socket; the others find theirs in the container's environment).

Next steps

On this page