PilotFoxDocs

Deploy with Docker Compose

Write the secrets, start the server with the repository's docker-compose.yml, and sign in as the first admin.

This page takes a server that meets the requirements to a running instance: the secrets, the start, the first sign-in. Every command runs from the root of a checkout of this repository, on the server. The Cloudflare side (the tunnel, the web app's Worker and the routes) is on its own page, Cloudflare: create the tunnel there first, since the server needs its token.

The env file

backend/scripts/setup.sh writes the env file, .env next to docker-compose.yml, with freshly generated secrets. Compose reads it for its own settings and hands it whole to the API container.

backend/scripts/setup.sh https://analytics.example.com you@example.com   # writes .env (mode 600); never changes an existing line

Its arguments are <public-url> [admin-email] [env-file]; the env file defaults to .env in the repository root. On a new file it prints:

Wrote .env (mode 600) with new secrets for https://analytics.example.com.
First admin: you@example.com. The password is BOOTSTRAP_ADMIN_PASSWORD in .env.
Next: set MAXMIND_*, R2_* and mail in .env if you use them, then see backend/docs/deploy.md.

What it writes:

  • Generated secrets, 64 lower-case hex characters each: BETTER_AUTH_SECRET (the server secret, which signs sessions and encrypts stored keys and tokens), POSTGRES_PASSWORD, CLICKHOUSE_PASSWORD, CLICKHOUSE_READER_PASSWORD (the read-only ClickHouse user), VALKEY_PASSWORD, PGBACKREST_REPO1_CIPHER_PASS (the passphrase that encrypts Postgres's continuous backups) and, when you give an admin email, BOOTSTRAP_ADMIN_PASSWORD. It never prints a secret.
  • Settings: NODE_ENV=production, PUBLIC_URL, SIGNUP_POLICY=open, BOOTSTRAP_ADMIN_EMAIL, GEOIP_AUTO_DOWNLOAD=maxmind, LOG_LEVEL=info, and empty lines for the optional groups: BOOTSTRAP_ORG_NAME, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_EMAIL_TOKEN, SMTP_URL, MAIL_FROM, MAIL_REPLY_TO, MAXMIND_ACCOUNT_ID, MAXMIND_LICENSE_KEY and the four R2_* values.

The URL must be a lower-case origin exactly as the API will use it: no path, no default port (:443 for https, :80 for http), and an IPv4 address only as a plain dotted quad. The script cannot validate internationalised (xn--) host names: run it with a placeholder URL such as https://placeholder.example.com, then edit PUBLIC_URL in .env by hand. Admin emails are limited to letters, digits, _, +, - and dots, so nothing in the file needs quoting.

Re-running it is safe. A new file is written to a private temporary file and published in one step, so an interrupted run leaves no .env at all. When the file exists, a re-run with the same URL (and email) only appends the keys the file lacks, such as a secret a newer version of the script adds, and changes no existing line:

.env already has every key - nothing to add, nothing changed.

It refuses with exit code 1 and leaves the file untouched when the file is not private (chmod 600 it), is a symlink or has a second hard link; when it is empty, has no PUBLIC_URL line, or has another PUBLIC_URL or admin email; or when a generated secret's line is empty or not 64 hex characters, or a NODE_ENV, SIGNUP_POLICY, GEOIP_AUTO_DOWNLOAD or LOG_LEVEL line, or the admin password's line, is empty or cut short (fix or delete that line, then re-run). Bad arguments, or no openssl on the server, exit with code 2. One line per key: the script only reads the first line that sets a key.

Keep a copy of the secrets off the server

Backups cannot be used without the secrets in .env, and Postgres's continuous backups cannot even be decrypted without PGBACKREST_REPO1_CIPHER_PASS, which can never change. Keep it private: compose hands the whole file to the API container, so anyone who can docker inspect or exec into it can read every value, the store passwords and BOOTSTRAP_ADMIN_PASSWORD included (compose blanks the passphrase there; only Postgres's side gets it). Payment-provider keys, Search Console tokens and Slack or Discord webhook URLs are stored encrypted with a key derived from BETTER_AUTH_SECRET: changing that secret makes them unreadable.

The database

There is nothing to fill in: docker-compose.yml runs Postgres 17 itself (the postgres service, Requirements) and builds the API's connection to it from POSTGRES_PASSWORD. Postgres takes that password when it creates its database in an empty volume, on the first start; to change it later, change it in Postgres too (docker compose exec postgres psql -U app -d app -c '\password app'), then in .env, then up -d. If you ran an earlier version against your own Postgres, move its data in as Upgrades describes.

The tunnel token

cloudflared needs the token of your Cloudflare Tunnel (Cloudflare, step 1). It is never a line of .env, since the API container receives every line of that file. Compose reads it from the environment of the docker compose command, CLOUDFLARE_TUNNEL_TOKEN, and copies it into the cloudflared container alone as a secret. Keep it in a file only you can read, outside the repository:

( umask 077 && mkdir -p ~/.pilotfox && read -rsp 'Tunnel token: ' t && printf '%s\n' "$t" > ~/.pilotfox/tunnel-token ); echo

and pass it to every up: CLOUDFLARE_TUNNEL_TOKEN=$(cat ~/.pilotfox/tunnel-token) docker compose up …. Without it compose stops at cloudflared: environment variable "CLOUDFLARE_TUNNEL_TOKEN" required by secret "mine-analytics_cloudflared_token" is not set.

With Doppler instead

pilotfox.app keeps every secret in Doppler and none in a file on the server. backend/scripts/compose-doppler.sh runs docker compose in the repository root inside two doppler run commands: the outer one puts CLOUDFLARE_TUNNEL_TOKEN alone into the environment, and the inner one writes every other secret to .env as a named pipe that exists only while the command runs, so compose reads it as usual and nothing stays on disk. Run every compose command through it:

backend/scripts/compose-doppler.sh up -d --build --wait --remove-orphans
backend/scripts/compose-doppler.sh ps
backend/scripts/compose-doppler.sh logs api

Set up the project and config in Doppler, put the secrets setup.sh would generate there (or upload a .env and delete it), add CLOUDFLARE_TUNNEL_TOKEN, then give the repository root a read-only service token with doppler configure set token --scope "$PWD" and doppler setup, in the root itself: Doppler scopes a directory and its subdirectories. No value may contain a single quote. The operator guide, backend/docs/deploy.md ("Secrets with Doppler"), has every command.

Fill in the optional settings

Edit .env (or Doppler) for the services you use, all described in the configuration reference:

  • CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_EMAIL_TOKEN (Cloudflare Email Sending) or SMTP_URL, and MAIL_FROM, for mail, and with it open sign-up (Mail).
  • MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY for visitor locations and networks (GeoIP).
  • The four R2_* values for session replay, imports and backups, Postgres's continuous ones included (Object storage).
  • SEARCH_CONSOLE_CLIENT_ID and SEARCH_CONSOLE_CLIENT_SECRET for Search Console (Google Search Console).
  • Memory limits on a server with less than 8 GB (Memory).

You can also start first and add them later: a later up -d recreates the API with the new values.

Start the stack

CLOUDFLARE_TUNNEL_TOKEN=$(cat ~/.pilotfox/tunnel-token) docker compose up -d --build --wait --remove-orphans
  • No -f and no --env-file. Compose finds docker-compose.yml in the working directory and reads .env next to it. Keep docker-compose.yml the only compose file there: a compose.yaml next to it would take precedence.
  • --build builds the API image from backend/apps/api/Dockerfile, the only image built.
  • --wait returns once every container is running and healthy. Postgres's health check is pg_isready; the API's reads /api/v1/ready from inside its container, which checks Postgres, ClickHouse and Valkey, and allows a 60-second start period; cloudflared's needs the tunnel connected to Cloudflare, so a wrong token makes --wait fail with container … cloudflared-1 is unhealthy.
  • On the first start Postgres creates its database, then the API creates the ClickHouse database, applies the migrations to Postgres and ClickHouse, and creates the first admin. With R2 set, the pgbackrest service then takes Postgres's first full backup (Backups and restore).
  • --remove-orphans removes containers of the project the file no longer names (see Upgrades).

The compose project is mine-analytics, with the services api, postgres, pgbackrest, clickhouse, valkey and cloudflared. Then finish the Cloudflare side (Cloudflare, steps 4 and 5) and check from anywhere that the API answers through the tunnel:

curl https://analytics.example.com/api/v1/health
{"data":{"ok":true}}

Sign in as the first admin

Open PUBLIC_URL and sign in with the admin email and the BOOTSTRAP_ADMIN_PASSWORD from .env, then change the password on the Account page.

  • That account is a system admin: it sees the Admin page (instance health, all organizations, sites and users, background jobs, backups) and can create users and organizations.
  • It owns an organization named after BOOTSTRAP_ORG_NAME, or Default when that is empty, ready for your first site.
  • It is created once, on the first start that finds no system admin. Later starts leave it alone, so the password in .env stops working once you have changed it. If another system admin already exists, the API ignores the setting and logs a warning.

Without an admin email, setup.sh writes no admin password and no first admin is created: give it one.

Sign-up and email

Open sign-up needs email. With SIGNUP_POLICY=open (the default, and what setup.sh writes) and mail configured (the CLOUDFLARE_* pair or SMTP_URL, see Mail), anyone can create an account at /signup. They confirm their address from the verification mail, which signs them in, and their first sign-in creates an organization they own, where they add their first site and invite their team. People with a pending invitation join the inviting organization instead. A user may own up to 5 organizations (system admins: no limit). Sign-up is limited to 10 an hour per client address (an IPv6 /64), and there is no CAPTCHA.

Without mail, sign-up stays invite-only even with SIGNUP_POLICY=open, since a new account could never verify its address. The API logs a warning at start, and GET /api/v1/config reports "signupPolicy": "invite_only", so the sign-in page offers no sign-up link.

Invite-only. To admit only invited people (and users a system admin creates), set SIGNUP_POLICY=invite_only. Only system admins can then create organizations; existing accounts and organizations stay. An .env written by an older setup.sh has SIGNUP_POLICY=invite_only: change it to open yourself to open sign-up.

Without mail, nothing is mailed:

  • Inviting someone shows the invite link to the inviter instead; send it to them yourself. Treat it like a password until it is accepted or expires (after 7 days): whoever opens it can create the account for the invited address. Its ?token= part is the secret; the invitation id alone proves nothing. A link still unused when BETTER_AUTH_SECRET changes no longer verifies a sign-up: resend the invitation (Organization settings → Members → Pending invitations → Resend) to get a new link, and send that one.
  • Someone who signs up through that link starts with a verified email and signs in straight away. A sign-up without it stays unverified and cannot sign in; a system admin deletes that user, then invites them again.
  • Password resets, email sign-in codes, email changes, emailed reports and new-sale emails need mail. Slack and Discord new-sale messages do not.

Account mail is budgeted per address, per client and per inviter; see Mail for the limits and the volume alert.

After changing any of these, recreate the API with up -d (and the token, as above).

Running the stack

  • One API container. Never docker compose up --scale api=2: the event writer's spool lives in the shared api-data volume, and two processes must never replay the same spool files.
  • Container hardening. The API runs with a read-only root filesystem, a 64 MiB in-memory /tmp, no Linux capabilities and no-new-privileges; it writes only to its volumes. Postgres and pgbackrest run the same way as the image's postgres user (pgbackrest reads Postgres's volume read-only). cloudflared runs as an unprivileged user with no Linux capabilities and no-new-privileges.
  • Stopping. Compose gives the API 30 seconds to stop; it flushes buffered events within 20. Never docker kill it. Compose stops cloudflared first, then the API, then the stores (pgbackrest before Postgres), and gives ClickHouse 60 seconds and Postgres 30 for their own shutdown.
  • Logs. Container logs rotate at 10 MB × 5 files per service. Never use LOG_LEVEL=trace or debug in production: they log request details.
  • Failed background jobs show under Admin → Jobs, with a failed count per queue (GET /api/v1/admin/jobs, system admins only).
  • The stores are reachable from the host only through compose: docker compose exec postgres psql -U app -d app, docker compose exec clickhouse clickhouse-client and docker compose exec valkey valkey-cli.

ClickHouse users

The API uses two ClickHouse users: app writes (events, deletes, imports, migrations), and app_ro (backend/docker/clickhouse/users.d/reader.xml) runs dashboard queries and exports. app_ro may only read, with a 30-second default query timeout and a memory cap per query, CLICKHOUSE_QUERY_MAX_MEMORY; a query that needs more fails, and the API answers 400 asking to narrow the time window. To change the cap, set CLICKHOUSE_QUERY_MAX_MEMORY (bytes) in .env and run up -d --wait.

A value other than plain bytes or a decimal suffix makes ClickHouse exit at start in a loop; its error log then says why:

docker compose cp clickhouse:/var/log/clickhouse-server/clickhouse-server.err.log - | tar -xO | tail -n 20

ClickHouse's own log runs at warning, not the image's default trace, which would record every query with its parameter values and the client address. It also masks every single-quoted value in what it logs, R2 credentials included.

Map boundaries

The map's country and region boundaries come from a tiles archive that is not in any image and not in any backup. Build it as backend/tools/boundaries/README.md describes, from backend/ (the first build compiles its tools and downloads the Natural Earth data):

docker build -t pilotfox-boundaries:dev tools/boundaries
mkdir -p tools/boundaries/dist # else Docker creates the bind-mount source as root on Linux and --user cannot write
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/tools/boundaries/dist:/out" pilotfox-boundaries:dev

That writes backend/tools/boundaries/dist/boundaries.pmtiles. Copy it into the maps volume and restart the API, from the repository root:

docker compose cp backend/tools/boundaries/dist/boundaries.pmtiles api:/data/maps/boundaries.pmtiles && docker compose restart api

Without the archive, boundary tile requests answer 404.

The API image

--build builds the API image on the server. To build and check it on its own:

docker build -f backend/apps/api/Dockerfile -t mine-api:local backend
backend/scripts/image-check.sh mine-api:local   # non-root, NODE_ENV=production, no test tooling, strict licence gate, size
  • The image runs node --enable-source-maps dist/main.js as node (uid 1000), listens on 3001, and serves the tracking script bundles built into it. It never contains GeoLite data, the boundary tiles or an .env file.
  • Behind a TLS-intercepting proxy, give the build its CA as a build secret, which is never stored in a layer: docker build --secret id=build_ca,src=/path/to/ca.pem -f backend/apps/api/Dockerfile -t mine-api:local backend
  • Run outside compose, start it with docker run --init and stop it with docker stop -t 30 <container>: Docker's default 10 seconds is shorter than the API's 20-second shutdown.
  • The repository's CI workflow is written to publish tagged releases of the API image as ghcr.io/<owner>/mine-api:<version> (linux/amd64), but it is staged at backend/ci/github/workflows/ci.yml and runs only once it is moved to .github/workflows/: no release image exists yet. See Upgrades.

On this page