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.ymlfrom the root of this repository: the API, Postgres 17 with its continuous backups, ClickHouse, Valkey andcloudflared. 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")
└───────────┘| Service | Image | What it does | Data |
|---|---|---|---|
api | mine-api:local, built from backend/apps/api/Dockerfile | The 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 |
postgres | ghcr.io/saiakashneela/postgres-ssl-pgbackrest:pg17 | Postgres 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 |
pgbackrest | the same image | Takes Postgres's base backups to R2 on a schedule, by itself; sleeps without R2. | none (reads postgres-data) |
clickhouse | clickhouse/clickhouse-server:26.8 | Events, bot events, the session replay index, identities, payments and subscriptions, Search Console data. | clickhouse-data volume |
valkey | valkey/valkey:9.1-alpine | Sign-in sessions, visitors' open sessions, live presence, rate limits and caches. | valkey-data volume (append-only file) |
cloudflared | cloudflare/cloudflared, a fixed release | Connects out to Cloudflare and hands the tunnel's requests to the API. | none (the token is a compose secret) |
| The web app and public site | a 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 andpgbackrest, so they can write Postgres's backups to R2.tunnel:cloudflaredand the API, internal.cloudflaredhas the fixed address10.89.242.2, the only peer whose forwarding headers the API believes (TRUST_PROXY).TUNNEL_NET_PREFIXmoves the subnet.tunnel-egress:cloudflaredalone, 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 domain | Served by |
|---|---|
/api/*, /.well-known/*, /maps/* | The API, through the tunnel. Cloudflare's CF-Connecting-IP names the visitor. |
/api/v1/ready | Nobody: 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 else | The 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-originandStrict-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);
cloudflaredlogs 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
Requirements
The server, the database, Cloudflare and the optional accounts.
Deploy with Docker Compose
Write the secrets, start the server and sign in as the first admin.
Cloudflare
The tunnel, the web app's Worker, the routes and the zone's settings.
Configuration reference
Every setting, with its type and default.
Backups and restore
Back up to Cloudflare R2 from the web app and continuously with pgBackRest, and restore.
Upgrades
Update to a new version, and move an external Postgres or the earlier compose files in.