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.ymlhands.envto theapicontainer (env_file), so every line reaches it, passwords included, but forPGBACKREST_REPO1_CIPHER_PASS, which compose blanks for it. Thepostgres,pgbackrest,clickhouseandvalkeycontainers only get the values compose names for them, andcloudflaredonly the tunnel token. -
Compose reads the same file for its own settings (the store passwords, the backup passphrase, memory limits): run every
docker composecommand from the repository root, where it finds bothdocker-compose.ymland.env. -
Some API settings are fixed by compose. The
apiservice'senvironment:block wins over the env file, so a value you put in.envfor 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") whoseerr.messageholds oneKEY: reasonline per invalid variable, never the values;docker compose logs apishows it, and Docker keeps restarting the container until the setting is fixed. For example, withTRUST_PROXY=true,SIGNUP_POLICY=closed,LIVE_MAX_GUEST_STREAMS=0, onlyR2_BUCKETof the fourR2_*values, andLOG_LEVEL=tracewithNODE_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), rundocker 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
| Variable | Type | Default | Description |
|---|---|---|---|
PUBLIC_URL | origin | required | The 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_SECRET | string, at least 32 characters | required | The 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_ENV | string | development | Compose sets production. With production, LOG_LEVEL=trace is refused. |
APP_ROLE | all, api, worker | all | Which 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). |
PORT | integer, 1 to 65535 | 3001 | The HTTP port. Compose sets 3001. |
HOST | string | 0.0.0.0 | The address to listen on. Compose sets 0.0.0.0. |
DASHBOARD_ORIGINS | comma-separated origins | unset | Further 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_PROXY | false, or comma-separated IPs, CIDRs, loopback, linklocal, uniquelocal | loopback | The 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_HEADER | x-forwarded-for, cf-connecting-ip | x-forwarded-for | Which 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_ONLY | boolean | false | true: /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_LEVEL | fatal, error, warn, info, debug, trace, silent | info | Never trace or debug in production: they log request details. trace is refused with NODE_ENV=production. |
MCP_ENABLED | boolean | true | Serves the MCP server at /api/mcp and its OAuth endpoints. Booleans accept true, false, 1, 0, yes, no, on and off. |
Databases and Valkey
| Variable | Type | Default | Description |
|---|---|---|---|
DATABASE_URL | URL, postgres:// or postgresql:// | required | Postgres: 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_MODE | disable, require, verify | disable | require: 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_CERT | PEM text, or the path of a readable file | unset | The CA for DATABASE_SSL_MODE=verify, where it is required. Not used with compose. |
CLICKHOUSE_URL | URL, http:// or https:// | http://localhost:8123 | ClickHouse's HTTP interface. Compose sets http://clickhouse:8123. |
CLICKHOUSE_USER | string | app | The user that writes: events, deletes and erasure, imports and migrations. Compose sets app. |
CLICKHOUSE_PASSWORD | string | required | Its password. setup.sh generates it; ClickHouse takes it when its volume is first created. |
CLICKHOUSE_DB | string | analytics | The database, created at start when it is missing. Compose sets analytics. |
CLICKHOUSE_READER_USER | string | unset | The read-only user for dashboard queries and exports, set together with its password. Unset, they use CLICKHOUSE_USER. Compose sets app_ro. |
CLICKHOUSE_READER_PASSWORD | string | unset | Its password. setup.sh generates it and compose hands it to both ClickHouse and the API. |
REDIS_URL | URL, redis:// or rediss:// | required | Valkey: 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
| Variable | Type | Default | Description |
|---|---|---|---|
SIGNUP_POLICY | invite_only, open | open | open 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_EMAIL | unset | The 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_PASSWORD | string, at least 12 characters | unset | That 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_NAME | string | unset | The name of that admin's organization; unset, Default. |
GITHUB_CLIENT_ID | string | unset | GitHub sign-in, set together with the secret. The OAuth app's callback URL is ${PUBLIC_URL}/api/auth/callback/github. |
GITHUB_CLIENT_SECRET | string | unset | Its secret. |
GOOGLE_CLIENT_ID | string | unset | Google 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_SECRET | string | unset | Its secret. |
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.
| Variable | Type | Default | Description |
|---|---|---|---|
CLOUDFLARE_ACCOUNT_ID | string, 32 hexadecimal characters | unset | The 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_TOKEN | string | unset | An API token of that account with the Email Sending: Edit permission (an account-owned token is recommended). |
SMTP_URL | URL, smtp:// or smtps:// | unset | smtp://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_FROM | an address or Name <address> | unset | The 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_TO | an address or Name <address> | unset | Where replies go (the Reply-To of every mail), such as hello@pilotfox.app. Unset: replies go to MAIL_FROM. |
AUTH_MAIL_RECIPIENT_PER_15_MIN | integer, at least 1 | 5 | Password-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_HOUR | integer, at least 1 | 100 | Requests 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_HOUR | integer, at least 1 | 50 | Invitations one user sends in an hour, re-sent ones included. Past it: 429. |
AUTH_MAIL_INVITE_CLIENT_PER_HOUR | integer, at least 1 | 150 | Invitations sent from one client address in an hour. Past it: 429. |
AUTH_MAIL_INVITEE_PER_15_MIN | integer, at least 1 | 5 | Invitations to one address in 15 minutes. Past it: 429. |
AUTH_MAIL_EMAIL_CHANGE_PER_HOUR | integer, at least 1 | 5 | Email-change requests of one account in an hour. Past it: 429. |
AUTH_MAIL_NEW_EMAIL_PER_15_MIN | integer, at least 1 | 3 | Email-change requests to one new address in 15 minutes. Past it: 429. |
AUTH_MAIL_ALERT_PER_HOUR | integer, at least 1 | 500 | Not 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-Afterheader 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_HOURunder your mail provider's sending limit and alert on its log line instead. - Raise
AUTH_MAIL_CLIENT_PER_HOURif more than about 50 people sign in with emailed codes from one network address (an office behind one NAT) in the same hour, andAUTH_MAIL_INVITER_PER_HOURbefore 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).
| Variable | Type | Default | Description |
|---|---|---|---|
MAXMIND_ACCOUNT_ID | string | unset | Your MaxMind account id, set together with the licence key. |
MAXMIND_LICENSE_KEY | string | unset | A licence key of that account. |
GEOIP_AUTO_DOWNLOAD | maxmind, off | maxmind | maxmind: 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_DIR | path | /data/geoip | Where the databases are read from and downloaded to. Compose sets /data/geoip. |
GEOIP_EDITIONS | comma-separated MaxMind editions | GeoLite2-City,GeoLite2-ASN | What the built-in updater downloads. The API only reads these two editions, so others only take space. |
GEOIP_FALLBACK | off, cloudflare | off | cloudflare: 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.jsIt 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.
| Variable | Type | Default | Description |
|---|---|---|---|
R2_ACCOUNT_ID | string | unset | Your Cloudflare account id. All four R2_* values or none. |
R2_ACCESS_KEY_ID | string | unset | The R2 API token's access key id. |
R2_SECRET_ACCESS_KEY | string | unset | Its secret access key. |
R2_BUCKET | string | unset | The 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_ENDPOINT | URL, http:// or https:// | unset | Unset: 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_STYLE | boolean | false | Path-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.
| Variable | Type | Default | Description |
|---|---|---|---|
SEARCH_CONSOLE_CLIENT_ID | string | unset | An 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_SECRET | string | unset | Its 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
| Variable | Type | Default | Description |
|---|---|---|---|
MAP_PROVIDER | maplibre, mapbox | maplibre | The web app's default map provider. |
MAPLIBRE_STYLE_URL | URL | https://tiles.openfreemap.org/styles/positron | The MapLibre style of the light theme. Browsers load it and its tiles directly. |
MAPLIBRE_STYLE_URL_DARK | URL | https://tiles.openfreemap.org/styles/dark | The MapLibre style of the dark theme. |
MAPBOX_PUBLIC_TOKEN | string starting with pk. | unset | A Mapbox public token, which is sent to browsers. Secret sk. tokens are refused. Required with MAP_PROVIDER=mapbox. |
MAPBOX_STYLE_URL | URL | mapbox://styles/mapbox/dark-v11 | The Mapbox style. |
BOUNDARIES_PMTILES | path | /data/maps/boundaries.pmtiles | The 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
| Variable | Type | Default | Description |
|---|---|---|---|
DATA_DIR | path | /data | Local 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_BYTES | integer, at least 1 | 32768 | The largest body the tracker's event endpoint accepts, in bytes. |
INGEST_RATE_PER_IP | N/s burst M | 30/s burst 120 | The token bucket of each visitor address (an IPv6 /64 counts as one) on the tracker's event endpoints. Past it: 429. |
QUERY_CACHE_TTL_SECONDS | integer, 0 to 300 | 30 | How long dashboard query results are cached in Valkey. 0 turns the cache off. |
QUERY_MAX_EXECUTION_SECONDS | integer, 1 to 300 | 30 | ClickHouse'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.
| Variable | Type | Default | Description |
|---|---|---|---|
API_KEY_MAX_PER_ORG | integer, 1 to 10000 | 50 | Active (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_ORG | integer, 1 to 100000 | 100 | Realtime 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_OWNER | integer, 1 to 100000 | 20 | Realtime 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_SITE | integer, 1 to 100000 | 50 | Realtime 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_STREAMS | integer, 1 to 100000 | 1000 | Realtime streams of guests on all sites together. Raise it, and the per-site value, for a busy public dashboard. |
EXPORT_MAX_CONCURRENT | integer, 1 to 32 | 2 | Raw-event exports streaming at once in the API process. One more answers 503. |
EXPORT_MAX_CONCURRENT_PER_ORG | integer, 1 to 32 | 1 | Of 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_ORG | integer, 1 to 4 | 2 | Import 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_ORG | integer, 1 to 1000 | 3 | Imports 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
| Variable | Type | Default | Description |
|---|---|---|---|
EVENTS_RETENTION_MONTHS | integer, at least 0 | 0 | Months of events ClickHouse keeps. 0 keeps them forever. |
BOT_EVENTS_RETENTION_DAYS | integer, at least 1 | 90 | Days of bot events (the Bots report) ClickHouse keeps. |
REPLAY_RETENTION_DAYS | integer, at least 1 | 30 | Days 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.
| Variable | Type | Default | Description |
|---|---|---|---|
BACKUP_TIMEOUT_SECONDS | integer, 1 to 604800 (7 days) | 10800 | The 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_DAYS | integer, 1 to 3650 | 30 | R2 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.
| Variable | Default | Description |
|---|---|---|
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_IMAGE | mine-api:local | The 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_PREFIX | 10.89.240 | The 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_PREFIX | 10.89.242 | The 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_LIMIT | 3g | The API container's memory limit (no swap). |
API_NODE_OPTIONS | empty | NODE_OPTIONS for the API, such as --trace-warnings. Never --max-old-space-size: it overrides the replay decode workers' own heap limits. |
CLICKHOUSE_MEM_LIMIT | 2g | ClickHouse's container memory limit (no swap). ClickHouse caps itself at 0.8 times this. |
CLICKHOUSE_QUERY_MAX_MEMORY | 1073741824 | Memory 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.com | The 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_MAX | 4GiB | The 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.
| Variable | Used by | Description |
|---|---|---|
POSTGRES_PASSWORD | postgres, 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_PASS | postgres, pgbackrest | The 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_PASSWORD | clickhouse, api | ClickHouse'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_PASSWORD | clickhouse, api | The read-only app_ro user. ClickHouse reads it at every start, so a new value needs only up -d. |
VALKEY_PASSWORD | valkey, 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_TOKEN | cloudflared | The 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.
| Variable | Set by compose to |
|---|---|
NODE_ENV | production |
HOST | 0.0.0.0 |
PORT | 3001 |
TRUST_PROXY | ${TUNNEL_NET_PREFIX}.2, cloudflared's address |
CLIENT_IP_HEADER | cf-connecting-ip |
GEOIP_FALLBACK | cloudflare |
READY_LOOPBACK_ONLY | true |
DATABASE_URL | postgres://app:${POSTGRES_PASSWORD}@postgres:5432/app |
DATABASE_SSL_MODE | require |
PGBACKREST_REPO1_CIPHER_PASS | empty: the passphrase's line of the env file never reaches the API |
CLICKHOUSE_URL | http://clickhouse:8123 |
CLICKHOUSE_USER | app |
CLICKHOUSE_DB | analytics |
CLICKHOUSE_READER_USER | app_ro |
CLICKHOUSE_READER_PASSWORD | ${CLICKHOUSE_READER_PASSWORD} |
REDIS_URL | redis://:${VALKEY_PASSWORD}@valkey:6379 |
DATA_DIR | /data |
GEOIP_DIR | /data/geoip |
BOUNDARIES_PMTILES | /data/maps/boundaries.pmtiles |
UV_THREADPOOL_SIZE | 16 (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.