PilotFoxDocs

Configuration reference

Every environment variable of a self-hosted instance: what it does, its default and when to set it.

A self-hosted instance is configured through one env file, .env in the repository root next to docker-compose.yml, written by backend/scripts/setup.sh and edited by you (or rendered from Doppler). This page lists every setting the API reads, every setting the compose file reads, and what the compose file sets for you.

How settings are read

  • The API gets the whole env file. docker-compose.yml hands .env to the api container (env_file), so every line reaches it, passwords included, but for PGBACKREST_REPO1_CIPHER_PASS, which compose blanks for it. The postgres, pgbackrest, clickhouse and valkey containers only get the values compose names for them, and cloudflared only the tunnel token.

  • Compose reads the same file for its own settings (the store passwords, the backup passphrase, memory limits): run every docker compose command from the repository root, where it finds both docker-compose.yml and .env.

  • Some API settings are fixed by compose. The api service's environment: block wins over the env file, so a value you put in .env for one of those has no effect. They are listed in Set by the compose file.

  • A blank value is unset. KEY= (or only spaces) counts as not set, so the default applies and a required key is reported missing.

  • Invalid settings stop the API at start. It exits with code 1 after logging one JSON line at level fatal ("level":60, "msg":"failed to start") whose err.message holds one KEY: reason line per invalid variable, never the values; docker compose logs api shows it, and Docker keeps restarting the container until the setting is fixed. For example, with TRUST_PROXY=true, SIGNUP_POLICY=closed, LIVE_MAX_GUEST_STREAMS=0, only R2_BUCKET of the four R2_* values, and LOG_LEVEL=trace with NODE_ENV=production, the message reads:

    TRUST_PROXY: blanket "true" is forbidden; use false or a comma list of proxy IPs/CIDRs/names (e.g. loopback)
    SIGNUP_POLICY: Invalid option: expected one of "invite_only"|"open"
    LIVE_MAX_GUEST_STREAMS: must be at least 1
    R2_ACCOUNT_ID: required when any other R2_* setting is set (all or none)
    R2_ACCESS_KEY_ID: required when any other R2_* setting is set (all or none)
    R2_SECRET_ACCESS_KEY: required when any other R2_* setting is set (all or none)
    LOG_LEVEL: must not be trace when NODE_ENV=production (use debug at most)
  • A change takes effect when the container is recreated. After editing .env (or a secret in Doppler), run docker compose up -d (with Doppler: backend/scripts/compose-doppler.sh up -d): compose recreates the containers whose settings changed.

Local development

backend/.env.example is the starting point for a development .env, with values that match the development stack (backend/docker/compose.dev.yml). It says so itself: do not copy it for production; run backend/scripts/setup.sh instead.

The tables below give each setting's type and default as the code has them. The Type column names the accepted values where the code restricts them.

Instance

VariableTypeDefaultDescription
PUBLIC_URLoriginrequiredThe address people and the tracker reach, such as https://analytics.example.com: scheme, host and port only, no path. The API builds links in emails and the OAuth redirect URIs from it and checks Origin against it. Behind a proxy it is the proxy's public https URL, port included.
BETTER_AUTH_SECRETstring, at least 32 charactersrequiredThe server secret: it signs sessions and tokens, keys the hashes of IP addresses used for rate limits, and derives the key that encrypts stored payment-provider keys, Search Console tokens and the Slack or Discord webhook URLs of new-sale messages. Changing it makes those stored values unreadable (connect or enter them again), voids the unsubscribe links of report emails already sent, and stops unused invite links from verifying a sign-up. setup.sh generates it.
NODE_ENVstringdevelopmentCompose sets production. With production, LOG_LEVEL=trace is refused.
APP_ROLEall, api, workerallWhich work this process does. all: HTTP and the background jobs (the compose stack runs one such process). api: HTTP only, with no background jobs and no first-run setup. worker: runs the background jobs (and also answers HTTP).
PORTinteger, 1 to 655353001The HTTP port. Compose sets 3001.
HOSTstring0.0.0.0The address to listen on. Compose sets 0.0.0.0.
DASHBOARD_ORIGINScomma-separated originsunsetFurther origins trusted like PUBLIC_URL: CORS with credentials on /api/v1 and /api/auth, the Origin and redirect URL checks of the sign-in endpoints (/api/auth), the cross-site check on cookie requests, and the MCP server's Origin check. Only for a web app served from another origin.
TRUST_PROXYfalse, or comma-separated IPs, CIDRs, loopback, linklocal, uniquelocalloopbackThe peers whose X-Forwarded-For the API believes. true and hop counts are refused, as is a /0 prefix. Compose sets cloudflared's fixed address, 10.89.242.2 by default (it follows TUNNEL_NET_PREFIX). The API also deletes every Cf-* request header (Cloudflare's visitor address and location) from any other peer.
CLIENT_IP_HEADERx-forwarded-for, cf-connecting-ipx-forwarded-forWhich header of a TRUST_PROXY peer names the visitor. cf-connecting-ip: Cloudflare's single address, for an API that cloudflared reaches directly (compose sets it); a trusted peer's X-Forwarded-For is then ignored, and without a valid CF-Connecting-IP the peer itself is the client.
READY_LOOPBACK_ONLYbooleanfalsetrue: /api/v1/ready, which names the stores and their errors, answers only requests from the API's own loopback (the container health check) and is a 404 for everyone else. Compose sets true: nothing but Cloudflare's edge stands in front of the API.
LOG_LEVELfatal, error, warn, info, debug, trace, silentinfoNever trace or debug in production: they log request details. trace is refused with NODE_ENV=production.
MCP_ENABLEDbooleantrueServes the MCP server at /api/mcp and its OAuth endpoints. Booleans accept true, false, 1, 0, yes, no, on and off.

Databases and Valkey

VariableTypeDefaultDescription
DATABASE_URLURL, postgres:// or postgresql://requiredPostgres: accounts, organizations, sites, settings, API keys, share links and the job queue. Compose sets it to its own Postgres, built from POSTGRES_PASSWORD (Postgres); a line in .env has no effect. Elsewhere, user name and password URL-encoded.
DATABASE_SSL_MODEdisable, require, verifydisablerequire: TLS without checking the server's certificate. verify: TLS checked against DATABASE_CA_CERT, host name included. Compose sets require: its Postgres's certificate names only localhost. An sslmode in DATABASE_URL is ignored.
DATABASE_CA_CERTPEM text, or the path of a readable fileunsetThe CA for DATABASE_SSL_MODE=verify, where it is required. Not used with compose.
CLICKHOUSE_URLURL, http:// or https://http://localhost:8123ClickHouse's HTTP interface. Compose sets http://clickhouse:8123.
CLICKHOUSE_USERstringappThe user that writes: events, deletes and erasure, imports and migrations. Compose sets app.
CLICKHOUSE_PASSWORDstringrequiredIts password. setup.sh generates it; ClickHouse takes it when its volume is first created.
CLICKHOUSE_DBstringanalyticsThe database, created at start when it is missing. Compose sets analytics.
CLICKHOUSE_READER_USERstringunsetThe read-only user for dashboard queries and exports, set together with its password. Unset, they use CLICKHOUSE_USER. Compose sets app_ro.
CLICKHOUSE_READER_PASSWORDstringunsetIts password. setup.sh generates it and compose hands it to both ClickHouse and the API.
REDIS_URLURL, redis:// or rediss://requiredValkey: sign-in sessions, visitors' open sessions, live presence, rate limits, mail budgets and caches. Compose builds it from VALKEY_PASSWORD.

Sign-up and sign-in

VariableTypeDefaultDescription
SIGNUP_POLICYinvite_only, openopenopen takes effect only with mail configured (Mail): anyone can sign up at /signup, verifies their address by mail and gets an organization of their own. Without mail, sign-up stays invite-only and the API logs a warning at start. GET /api/v1/config reports the policy in force as signupPolicy.
BOOTSTRAP_ADMIN_EMAILemailunsetThe first system admin, created at start when no system admin exists yet, with an organization they own. Set together with the password. A process with APP_ROLE=api skips it. While any system admin exists it does nothing, so a deleted bootstrap admin is not recreated (the last admin cannot delete their account). Remove both settings after the first start.
BOOTSTRAP_ADMIN_PASSWORDstring, at least 12 charactersunsetThat admin's first password. setup.sh generates one when you give it an admin email. Sign in once, then change it on the Account page.
BOOTSTRAP_ORG_NAMEstringunsetThe name of that admin's organization; unset, Default.
GITHUB_CLIENT_IDstringunsetGitHub sign-in, set together with the secret. The OAuth app's callback URL is ${PUBLIC_URL}/api/auth/callback/github.
GITHUB_CLIENT_SECRETstringunsetIts secret.
GOOGLE_CLIENT_IDstringunsetGoogle sign-in, set together with the secret. The client's redirect URI is ${PUBLIC_URL}/api/auth/callback/google. Search Console also uses this client when SEARCH_CONSOLE_CLIENT_ID is unset.
GOOGLE_CLIENT_SECRETstringunsetIts secret.

Mail

Mail leaves through the REST API of Cloudflare Email Sending when CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_EMAIL_TOKEN are set, else through the SMTP server of SMTP_URL (local development, or an instance with its own mail server), else not at all: the API logs that a mail was skipped, an inviter sees the invite link to pass on, and sign-up stays invite-only. Password resets, email sign-in codes, email changes, emailed reports and new-sale emails all need mail. Slack and Discord new-sale messages do not.

With Cloudflare, the API makes up to three attempts per mail when Cloudflare answers 429 or 5xx, times out (10 seconds) or cannot be reached, waiting 1 then 2 seconds, or the Retry-After Cloudflare asks for when that is at most 10 seconds. Any other error, or a recipient Cloudflare reports as permanently bounced or suppressed, fails that mail at once. Each failure is logged with Cloudflare's error codes and the recipient's domain only, never the token, the address or the message. Cloudflare refuses a whole message for a header it does not accept, so only the headers it allows are sent: with an http:// PUBLIC_URL, emailed reports go without their one-click unsubscribe headers (Cloudflare takes only https: and mailto: links there), and the link in the email still works.

VariableTypeDefaultDescription
CLOUDFLARE_ACCOUNT_IDstring, 32 hexadecimal charactersunsetThe Cloudflare account that sends mail, set together with the token (find it). With both set, mail goes through Cloudflare even when SMTP_URL is set too.
CLOUDFLARE_EMAIL_TOKENstringunsetAn API token of that account with the Email Sending: Edit permission (an account-owned token is recommended).
SMTP_URLURL, smtp:// or smtps://unsetsmtp://user:pass@host:587 (upgraded with STARTTLS) or smtps://user:pass@host:465 (TLS from the start). URL-encode special characters in the password. Used when the CLOUDFLARE_* pair is unset.
MAIL_FROMan address or Name <address>unsetThe sender, such as PilotFox <welcome@m.pilotfox.app>; quote a name with a comma ("PilotFox, Inc." <welcome@m.pilotfox.app>). With Cloudflare, an address on a domain onboarded for Email Sending. Unset: no-reply@ and the host of PUBLIC_URL.
MAIL_REPLY_TOan address or Name <address>unsetWhere replies go (the Reply-To of every mail), such as hello@pilotfox.app. Unset: replies go to MAIL_FROM.
AUTH_MAIL_RECIPIENT_PER_15_MINinteger, at least 15Password-reset links and sign-in or verification codes asked for one address in 15 minutes, from every client together. Past it the request answers as usual but sends nothing, so the links and codes already sent stay valid and the answer tells nobody whether the account exists.
AUTH_MAIL_CLIENT_PER_HOURinteger, at least 1100Requests that send mail from one client address (an IPv6 /64 counts as one) in an hour: sign-up, resets, codes, email changes. Past it: 429.
AUTH_MAIL_INVITER_PER_HOURinteger, at least 150Invitations one user sends in an hour, re-sent ones included. Past it: 429.
AUTH_MAIL_INVITE_CLIENT_PER_HOURinteger, at least 1150Invitations sent from one client address in an hour. Past it: 429.
AUTH_MAIL_INVITEE_PER_15_MINinteger, at least 15Invitations to one address in 15 minutes. Past it: 429.
AUTH_MAIL_EMAIL_CHANGE_PER_HOURinteger, at least 15Email-change requests of one account in an hour. Past it: 429.
AUTH_MAIL_NEW_EMAIL_PER_15_MINinteger, at least 13Email-change requests to one new address in 15 minutes. Past it: 429.
AUTH_MAIL_ALERT_PER_HOURinteger, at least 1500Not a limit. When all account mail of the instance in one hour passes it, the API logs the warning auth mail this hour passed AUTH_MAIL_ALERT_PER_HOUR, once for that hour.

How the account-mail budgets work:

  • Each budget is checked before the request runs, and counted in Valkey. If Valkey cannot be reached, the request fails (500) rather than send unbudgeted mail.
  • A 429 from a budget carries an X-Retry-After header with the budget's window in seconds; the app tells the person to wait and try again.
  • No budget covers the whole instance: anyone could spend it and stop everyone's mail. Set AUTH_MAIL_ALERT_PER_HOUR under your mail provider's sending limit and alert on its log line instead.
  • Raise AUTH_MAIL_CLIENT_PER_HOUR if more than about 50 people sign in with emailed codes from one network address (an office behind one NAT) in the same hour, and AUTH_MAIL_INVITER_PER_HOUR before one admin invites more than 50 people in an hour (or invite them over two hours).
  • An email change's confirmation link works once.

GeoIP (MaxMind)

PilotFox locates visitors with MaxMind's GeoLite2 databases from your own free MaxMind account; they are never in an image. The API reads GeoLite2-City.mmdb and GeoLite2-ASN.mmdb from GEOIP_DIR (the geoip volume). Without them, the location and network columns of new events stay empty, the internal readiness check reports geoip: "missing", and nothing fails. Cloudflare's visitor location headers fill in the locations MaxMind cannot place (GEOIP_FALLBACK, Cloudflare).

VariableTypeDefaultDescription
MAXMIND_ACCOUNT_IDstringunsetYour MaxMind account id, set together with the licence key.
MAXMIND_LICENSE_KEYstringunsetA licence key of that account.
GEOIP_AUTO_DOWNLOADmaxmind, offmaxmindmaxmind: the API's own updater downloads the databases (below). off: it never downloads; the files in the geoip volume are yours to keep current.
GEOIP_DIRpath/data/geoipWhere the databases are read from and downloaded to. Compose sets /data/geoip.
GEOIP_EDITIONScomma-separated MaxMind editionsGeoLite2-City,GeoLite2-ASNWhat the built-in updater downloads. The API only reads these two editions, so others only take space.
GEOIP_FALLBACKoff, cloudflareoffcloudflare: a visitor MaxMind cannot place gets the location from Cloudflare's visitor location headers. Compose sets it. Outside compose, set it only behind Cloudflare, with TRUST_PROXY naming only the proxy that brings its requests, since anyone can send these headers (the API reads them from TRUST_PROXY peers only).

The built-in updater (GEOIP_AUTO_DOWNLOAD=maxmind, what setup.sh writes). The geoip.refresh job downloads both editions on every start of a process with workers (APP_ROLE all or worker) and then every 6 hours (00:00, 06:00, 12:00 and 18:00 UTC). It asks MaxMind first whether the edition is newer than the file on disk, so a restart costs no download. A failed download keeps the previous file; a run that still fails after its retries counts as failed under Admin → Jobs. It needs to reach download.maxmind.com and the host it redirects downloads to. To download by hand (optional: every start does it), for example to see the result, from the repository root:

docker compose run --rm -T --no-deps api node dist/ops/geoip-download.js

It prints GeoLite2-City: updated and GeoLite2-ASN: updated (unchanged when there is nothing new), and exits 1 when the keys are missing or a download failed. The GeoLite licence wants old databases replaced within 30 days of a new release.

The API notices changed files within 10 minutes, without a restart. To check (the route is not public; this prints ok once both files are loaded):

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))"

Object storage (Cloudflare R2)

Session replay, imports and backups store files in an R2 bucket, and Postgres archives every change to it (Backups and restore). Without the four R2_* values, replay is off, an import upload answers 503, and there are no backups. The R2 API token needs Object Read & Write on the bucket, plus Admin Read & Write if the backups should maintain their retention rule (otherwise delete old backups by hand). Scope it to this bucket: backups contain password hashes, hashed API keys and share tokens.

VariableTypeDefaultDescription
R2_ACCOUNT_IDstringunsetYour Cloudflare account id. All four R2_* values or none.
R2_ACCESS_KEY_IDstringunsetThe R2 API token's access key id.
R2_SECRET_ACCESS_KEYstringunsetIts secret access key.
R2_BUCKETstringunsetThe bucket. Replays are stored under replay/, uploaded import files under imports/, the web app's backups under backups/, Postgres's continuous backups under pgbackrest/.
R2_ENDPOINTURL, http:// or https://unsetUnset: https://<R2_ACCOUNT_ID>.r2.cloudflarestorage.com. Set it to use a bucket in an R2 jurisdiction (https://<R2_ACCOUNT_ID>.eu.r2.cloudflarestorage.com) or another S3-compatible store; then set PGBACKREST_REPO1_S3_ENDPOINT to its host as well (Compose settings).
R2_FORCE_PATH_STYLEbooleanfalsePath-style URLs (<endpoint>/<bucket>/<key>) for an S3-compatible store without bucket host names (MinIO, SeaweedFS and the like). Leave it false for R2.

Backups also read two settings of their own, documented under Backups.

Google Search Console

Organization admins connect a Google account once (Organization settings → Integrations) and editors link each site to one of its Search Console properties. It needs an OAuth client in a Google Cloud project of yours; until one is configured, the Search Console pages show the setup steps and the redirect URI.

VariableTypeDefaultDescription
SEARCH_CONSOLE_CLIENT_IDstringunsetAn OAuth client for Search Console only, set together with its secret. Unset: Search Console uses the Google sign-in client (GOOGLE_CLIENT_ID) if that one is set. Setting only these never turns on Google sign-in.
SEARCH_CONSOLE_CLIENT_SECRETstringunsetIts secret.

Redirect URI: ${PUBLIC_URL}/api/v1/search-console/oauth/callback, for example https://analytics.example.com/api/v1/search-console/oauth/callback. Google compares it exactly (scheme, host, port, path, no trailing slash). When Search Console shares the sign-in client, add this URI to that client next to its sign-in redirect URI.

One-time setup in the Google Cloud console:

Enable the API. APIs & Services → Library → "Google Search Console API" → Enable, or gcloud services enable searchconsole.googleapis.com. Without it every sync fails with "The Google Search Console API is not enabled in the Google Cloud project".

Configure the consent screen. Google Auth platform → Branding: app name, support email, contact email. Under Audience, Internal if every Google account that will connect belongs to your Google Workspace organization, otherwise External. Under Data Access, add https://www.googleapis.com/auth/webmasters.readonly, openid and .../auth/userinfo.email.

Publish an External app. While its publishing status is Testing, Google's refresh tokens expire after 7 days, so the connection would need reconnecting every week. Add test users while you try it, then Publish app.

Create the OAuth client. Google Auth platform → Clients → Create client → Web application, with the redirect URI above under Authorized redirect URIs. Put the client id and secret in .env as SEARCH_CONSOLE_CLIENT_ID and SEARCH_CONSOLE_CLIENT_SECRET, then run docker compose up -d --wait.

Refresh tokens are stored encrypted with a key derived from BETTER_AUTH_SECRET; changing that secret turns the connections to "Reconnect". What Search Console shows and who can link what is in Search Console.

Maps

VariableTypeDefaultDescription
MAP_PROVIDERmaplibre, mapboxmaplibreThe web app's default map provider.
MAPLIBRE_STYLE_URLURLhttps://tiles.openfreemap.org/styles/positronThe MapLibre style of the light theme. Browsers load it and its tiles directly.
MAPLIBRE_STYLE_URL_DARKURLhttps://tiles.openfreemap.org/styles/darkThe MapLibre style of the dark theme.
MAPBOX_PUBLIC_TOKENstring starting with pk.unsetA Mapbox public token, which is sent to browsers. Secret sk. tokens are refused. Required with MAP_PROVIDER=mapbox.
MAPBOX_STYLE_URLURLmapbox://styles/mapbox/dark-v11The Mapbox style.
BOUNDARIES_PMTILESpath/data/maps/boundaries.pmtilesThe region boundary tiles archive. Missing, the boundary tile requests answer 404. Compose sets /data/maps/boundaries.pmtiles (the maps volume).

The boundary archive is built from the repository (tools/boundaries/dist/boundaries.pmtiles) and copied into the maps volume: see Deploy.

Ingest and queries

VariableTypeDefaultDescription
DATA_DIRpath/dataLocal data: the event writer's spool (spool/, events kept on disk while ClickHouse is unavailable) and the temporary files of backup runs, such as the dump a restore downloads (backups/). Compose sets /data (the api-data volume).
INGEST_MAX_BODY_BYTESinteger, at least 132768The largest body the tracker's event endpoint accepts, in bytes.
INGEST_RATE_PER_IPN/s burst M30/s burst 120The token bucket of each visitor address (an IPv6 /64 counts as one) on the tracker's event endpoints. Past it: 429.
QUERY_CACHE_TTL_SECONDSinteger, 0 to 30030How long dashboard query results are cached in Valkey. 0 turns the cache off.
QUERY_MAX_EXECUTION_SECONDSinteger, 1 to 30030ClickHouse's time limit for each dashboard query.

Per-organization limits

These keep one organization from taking what the whole instance has. On an instance that serves a single organization, raise them as noted.

VariableTypeDefaultDescription
API_KEY_MAX_PER_ORGinteger, 1 to 1000050Active (unexpired) API keys one organization may hold. One more is refused with 409 until one is deleted. Each key has its own request budget, so the cap bounds what one organization's keys can send.
LIVE_MAX_STREAMS_PER_ORGinteger, 1 to 100000100Realtime streams (one per open browser tab) that one organization's members and API keys hold on its sites. The API holds at most 2 000 in all and 5 per user, API key or share link. Set 2000 on a single-organization instance to use every slot.
LIVE_MAX_STREAMS_PER_OWNERinteger, 1 to 10000020Realtime streams that one person holds across every organization: their own (each signed-in tab or screen) and those of the API keys they created, which count against them even after they leave the key's organization, until the key is deleted. Owning several organizations does not multiply LIVE_MAX_STREAMS_PER_ORG. Set 2000 on a single-organization instance too, or when one admin creates the keys of many screens.
LIVE_MAX_PUBLIC_STREAMS_PER_SITEinteger, 1 to 10000050Realtime streams of guests on one site: share-link viewers, visitors of a public dashboard, and users or keys of other organizations reading a public site. Guests never count against an organization.
LIVE_MAX_GUEST_STREAMSinteger, 1 to 1000001000Realtime streams of guests on all sites together. Raise it, and the per-site value, for a busy public dashboard.
EXPORT_MAX_CONCURRENTinteger, 1 to 322Raw-event exports streaming at once in the API process. One more answers 503.
EXPORT_MAX_CONCURRENT_PER_ORGinteger, 1 to 321Of those, the exports of one organization. At most EXPORT_MAX_CONCURRENT. One more answers 429. Set it equal to EXPORT_MAX_CONCURRENT on a single-organization instance.
IMPORT_MAX_UPLOADS_PER_ORGinteger, 1 to 42Import uploads one organization streams at once, of the 4 the API streams. One more answers 429. Set 4 on a single-organization instance.
IMPORT_MAX_PENDING_PER_ORGinteger, 1 to 10003Imports one organization may have queued or running when it uploads another. One more answers 409, before the upload is read. One import runs at a time for the whole instance.

A refused realtime stream leaves the realtime view without live updates; the dashboard's "Online" count, which polls, is unaffected. The three live-stream settings accept up to 100 000, but above 2 000 they change nothing.

Data retention

VariableTypeDefaultDescription
EVENTS_RETENTION_MONTHSinteger, at least 00Months of events ClickHouse keeps. 0 keeps them forever.
BOT_EVENTS_RETENTION_DAYSinteger, at least 190Days of bot events (the Bots report) ClickHouse keeps.
REPLAY_RETENTION_DAYSinteger, at least 130Days of session replays: the index in ClickHouse and the recordings in R2 (an R2 lifecycle rule on replay/ and a daily cleanup job).

The API applies these at every start: it changes a table's expiry only when the setting differs from what the table has.

Backups

Read by the backup jobs and the backup CLI when they run, never at start, so a bad value cannot stop the API: Admin → Backups then shows the problem and refuses to start runs, and the CLI exits 2 naming the key. Set them in .env: the CLI, started with docker compose run ... api, reads that file and ignores values exported in the shell. See Backups and restore.

VariableTypeDefaultDescription
BACKUP_TIMEOUT_SECONDSinteger, 1 to 604800 (7 days)10800The deadline of each backup CLI command and of each step of a run from the web UI (a web UI run also stops after 24 hours in all, the job queue's limit). Raise it if a healthy backup takes longer than 3 hours.
BACKUP_RETENTION_DAYSinteger, 1 to 365030R2 deletes everything under backups/ after this many days. Compose also hands it to pgBackRest as its retention in days: Postgres's continuous backups reach at least this far back.

Compose settings

Read by docker compose from .env (or the shell), not by the API. All are optional.

VariableDefaultDescription
ENV_FILE.env (next to docker-compose.yml)The env file handed to the API container. Compose still reads .env for its own settings.
API_IMAGEmine-api:localThe API image. --build builds it from backend/apps/api/Dockerfile and tags the build with this name, so never use --build with a release image here (Upgrades).
APP_NET_PREFIX10.89.240The first three octets of the API's network to the internet (.0/24). Change it if the subnet collides with one on your host.
TUNNEL_NET_PREFIX10.89.242The first three octets of the network cloudflared shares with the API (.0/24); cloudflared is .2, the only peer the API believes (TRUST_PROXY follows it). Change it if the subnet collides with one on your host.
API_MEM_LIMIT3gThe API container's memory limit (no swap).
API_NODE_OPTIONSemptyNODE_OPTIONS for the API, such as --trace-warnings. Never --max-old-space-size: it overrides the replay decode workers' own heap limits.
CLICKHOUSE_MEM_LIMIT2gClickHouse's container memory limit (no swap). ClickHouse caps itself at 0.8 times this.
CLICKHOUSE_QUERY_MAX_MEMORY1073741824Memory cap of each dashboard query or export, in bytes or with a decimal suffix (512M is 512 000 000). Never 0, which ClickHouse reads as unlimited; a value such as -1 or 1GiB makes ClickHouse fail to start. Keep it well under ClickHouse's own cap.
PGBACKREST_REPO1_S3_ENDPOINT<R2_ACCOUNT_ID>.r2.cloudflarestorage.comThe host Postgres's continuous backups go to. Set it whenever R2_ENDPOINT is set, to that URL's host (such as <R2_ACCOUNT_ID>.eu.r2.cloudflarestorage.com): pgBackRest takes a host, over HTTPS on port 443.
PGBACKREST_ARCHIVE_PUSH_QUEUE_MAX4GiBThe most write-ahead log that waits for R2 while it cannot be reached. Beyond it, pgBackRest drops the waiting log instead of filling the disk, and point-in-time restore cannot cross that gap until the next base backup (Backups and restore). With MiB or GiB; lower it on a small disk.

Sizing advice for these is in Requirements.

Secrets

setup.sh generates these (64 lower-case hex characters each). Compose passes them to the stores and builds the API's connection URLs to Postgres and Valkey from them. The tunnel token is the one secret compose reads from the shell instead.

VariableUsed byDescription
POSTGRES_PASSWORDpostgres, api (DATABASE_URL)Postgres's app user. Postgres reads it when its volume is first created: changing it later also needs the password changed inside Postgres (The database).
PGBACKREST_REPO1_CIPHER_PASSpostgres, pgbackrestThe passphrase that encrypts Postgres's continuous backups in R2. It can never change once pgBackRest has created its repository, and nothing in it can be read without it. Compose blanks it for the API.
CLICKHOUSE_PASSWORDclickhouse, apiClickHouse's app user (also an API setting, above). ClickHouse reads it when its volume is first created: changing it later also needs the password changed inside ClickHouse.
CLICKHOUSE_READER_PASSWORDclickhouse, apiThe read-only app_ro user. ClickHouse reads it at every start, so a new value needs only up -d.
VALKEY_PASSWORDvalkey, api (REDIS_URL)Valkey's password, given to it in a config file. A new value needs both containers recreated (up -d does it).
CLOUDFLARE_TUNNEL_TOKENcloudflaredThe tunnel's token, from the environment of the docker compose command (Doppler's compose-doppler.sh puts it there), copied into the cloudflared container only as a compose secret. Never in .env: the API container gets every line of it. Without it cloudflared cannot start (Cloudflare).

Set by the compose file

The api service of docker-compose.yml sets these itself; they win over the env file.

VariableSet by compose to
NODE_ENVproduction
HOST0.0.0.0
PORT3001
TRUST_PROXY${TUNNEL_NET_PREFIX}.2, cloudflared's address
CLIENT_IP_HEADERcf-connecting-ip
GEOIP_FALLBACKcloudflare
READY_LOOPBACK_ONLYtrue
DATABASE_URLpostgres://app:${POSTGRES_PASSWORD}@postgres:5432/app
DATABASE_SSL_MODErequire
PGBACKREST_REPO1_CIPHER_PASSempty: the passphrase's line of the env file never reaches the API
CLICKHOUSE_URLhttp://clickhouse:8123
CLICKHOUSE_USERapp
CLICKHOUSE_DBanalytics
CLICKHOUSE_READER_USERapp_ro
CLICKHOUSE_READER_PASSWORD${CLICKHOUSE_READER_PASSWORD}
REDIS_URLredis://:${VALKEY_PASSWORD}@valkey:6379
DATA_DIR/data
GEOIP_DIR/data/geoip
BOUNDARIES_PMTILES/data/maps/boundaries.pmtiles
UV_THREADPOOL_SIZE16 (Node's thread pool for DNS lookups, compression and file access)
NODE_OPTIONS${API_NODE_OPTIONS}

Keeping this page in sync

The tables of API settings on this page are checked against the code by the backend's unit tests (backend/apps/api/src/config/env-docs.test.ts, run by bun run test:unit in backend/): every setting of apps/api/src/config/env.ts must have exactly one row here, with the default the code applies and, for a setting with a closed list of values, a number range or a boolean, the Type the code enforces. The backup settings' defaults are checked against the backup CLI, every variable docker-compose.yml interpolates and every key of .env.example must appear on this page, and Set by the compose file must list exactly the api service's environment: keys. A change to any of them fails the backend's tests until this page follows.

On this page