Skip to content
These docs describe Tradr v0.14.0. Running an older release? Check the release notes for what changed.

Environment variables

Tradr reads 72 environment variables, of which 3 are required — everything else has a working default or turns a feature off when unset.

This page is generated from .env.example, the file you copy to .env. CI regenerates it and fails if the two disagree, so it cannot fall behind the template.

An instance does not start without these three. docker/quickstart.sh generates all of them for you — see Install with Docker Compose.

Variable How to generate one
POSTGRES_PASSWORD openssl rand -hex 24 — hex avoids the URL-reserved characters that would corrupt the connection string
SESSION_SECRET openssl rand -base64 24 — signs session cookies, minimum 32 characters
ENCRYPTION_KEY openssl rand -hex 32 — a 32-byte key that encrypts stored provider API keys

Of the 72 variables, 44 ship unset — either commented out in the template or present with an empty value. An unset optional variable means the feature is absent, not broken: nothing logs an error and no outbound call is made.

Default column Meaning
Required No default. Supply a value or the instance will not start.
Optional Ships unset. The capability it controls is off.
A value The template ships this default; override it only if you need to.
Variable Default Notes
POSTGRES_USER tradr Single source of DB credentials for the compose stack. docker-compose.yml builds DATABASE_URL from these in api.environment (the URL is NOT read from .env), and postgres is initialised with them.
POSTGRES_PASSWORD Required Generate a URL-safe password (hex avoids URL-reserved chars like @ : / ?): openssl rand -hex 24
POSTGRES_DB tradr
DATABASE_URL postgresql://postgres:postgres@localhost:5433/tradr_dev For NON-compose runs (e.g. local dev against an external postgres), set DATABASE_URL directly. In the compose stack this value is IGNORED — compose overrides it in api.environment.
DB_POOL_SIZE 10
MIGRATE_TEST_DATABASE_URL postgresql://postgres:postgres@localhost:5433/tradr_test_migrate Migration test DBs (used only by pnpm test --project=migrations)
MIGRATE_TEST_USER_DATABASE_URL postgresql://tradr_test_user:tradr_test_user@localhost:5433/tradr_test_migrate
Variable Default Notes
SESSION_SECRET Required Generate (≥32 chars): openssl rand -base64 24
DISABLE_REGISTRATION false Close public sign-up. true makes POST /api/auth/register refuse with 403 REGISTRATION_DISABLED, whatever the client. Sign-in, password reset and every existing account are unaffected — only account creation is gated. Use it to run a private instance once your own accounts exist. Reopening sign-up is this variable plus an api restart; nothing is rebuilt. The SPA reads the same posture and shows a closed-signups notice on /register instead of a form it cannot submit; NEWSLETTER_URL (Web section) optionally adds a link to it.
Variable Default Notes
PORT 3100
NODE_ENV development
TRUSTED_PROXIES 172.28.0.0/24 Rate limiting behind the reverse proxy. Keep in sync with the compose network subnet (TRADR_SUBNET, default 172.28.0.0/24 in docker-compose.yml).
WEEK_START_DAY 0 Performance charts — 0 = Sunday, 1 = Monday (week-start bucket boundary)
SKIP_POST_MIGRATIONS false Opt-out for hosted operators with large positions tables (run pnpm migrate:post out-of-band instead)

Web (consumed by docker-compose.yml / nginx, NOT the api)

Section titled “Web (consumed by docker-compose.yml / nginx, NOT the api)”
Variable Default Notes
WEB_PORT 8080 Host port the web container publishes (maps to container :80).
API_BASE_URL Optional Upstream API base URL for the nginx reverse proxy. Leave blank to use the compose default (the internal api service).
NEWSLETTER_URL Optional Where /register sends people when DISABLE_REGISTRATION closes sign-up. The web entrypoint writes it into /config.js, so the SPA reads it at container start and changing it needs no rebuild. Optional, and UNSET IS THE DEFAULT: with no URL the closed-signups notice still appears, it just carries no link. Set it only if you actually run a mailing list your visitors should join.
APP_VERSION Optional Optional version string (e.g. v0.1.0). LEAVE THIS COMMENTED OUT. The release workflow bakes APP_VERSION into the api image, and GET /api/health reports it. The api reads this file through compose’s env_file:, and env_file OVERRIDES the image’s baked value — so an uncommented, blank APP_VERSION here erases the released version and /api/health silently drops its version field. Commented out, the image’s own stamp survives. Set it only to override the stamp deliberately.
CSV_IMPORT_NGINX_PROXY_TIMEOUT 300s nginx proxy read timeout for the dedicated /api/csv-import/ route. A large within-cap synchronous import can exceed nginx’s default 60s; this raises the timeout for that route only (other routes unchanged). Keep CSV_IMPORT_CLAIM_TIMEOUT_SECONDS (api) ≥ 2 × this value — the two live in different containers and are hand-synced.
MAX_UPLOAD_SIZE 20m nginx client_max_body_size for uploads. Must accommodate the largest upload a single request can carry, or large uploads 413. OPERATOR CONSTRAINT: keep MAX_UPLOAD_SIZE ≥ CSV_IMPORT_MAX_FILE_BYTES (below), or oversized CSV imports are rejected by nginx before the api can size them.
Variable Default Notes
METRICS_ENABLED false Prometheus text-format metrics for the api container, served as GET /metrics on a listener of its own — separate from the application port, so the exposition is never reachable through the app’s routes. It carries HTTP request counters and latency, database connection-state and probe gauges, Node.js process metrics, and a build-info gauge. OFF BY DEFAULT: the surface is absent, not broken, when unset. Set this to true to arm it. Only the literal values true and false parse; anything else is a startup error rather than a silent default. DO NOT PUBLISH THE METRICS PORT. The exposition is unauthenticated by design — network isolation is the control, because a bearer token copied into every scrape config is a worse posture than a private bind. It exposes no user data, only aggregate operational counters and the build version. Every scrape is an ACTIVE DATABASE PROBE: the connection-state and probe gauges are collected on demand, so the scrape interval you configure on the Prometheus side is a database load knob — on top of the SELECT 1 the Fly health check already issues every 15 seconds.
METRICS_PORT 9464 Port the metrics listener binds inside the api container. Chosen to collide with neither the api port (3100), WEB_PORT, nor Fly’s internal_port. Nothing publishes it: docker-compose.yml deliberately adds no ports: mapping for the api service — only web publishes a host port — and that omission is what keeps the surface off the public internet. Scrape it from another container on the same compose network, or over 6PN on Fly.
METRICS_HOST 0.0.0.0 Address the metrics listener binds to. The default is deliberate, not lazy: 127.0.0.1 would bind the CONTAINER’s own loopback and be unreachable from a Prometheus elsewhere on the compose network, and a Fly scrape arrives over 6PN rather than loopback. Binding all interfaces is already private in both documented deployments precisely because nothing publishes the port. Residual threat model: the listener becomes reachable from outside only if an operator adds a ports: mapping for the api service or runs it with host networking. This variable exists so those operators can narrow the bind to a specific interface instead of widening their exposure.
Variable Default Notes
ENCRYPTION_KEY Required Generate the active key: openssl rand -hex 32
ENCRYPTION_KEY_PREVIOUS Optional Previous key, kept during rotation so existing ciphertext can still decrypt.
ENCRYPTION_KEY_FINGERPRINT Optional Safety check: sha256 of the raw key bytes. If set, the app aborts on startup if it doesn’t match the loaded ENCRYPTION_KEY — guards against deploying with the wrong key. Generate from your key: openssl rand -hex 32 | xxd -r -p | openssl dgst -sha256 -binary | xxd -p -c 32
Variable Default Notes
CSV_IMPORT_MAX_FILE_BYTES 10485760 All optional with defaults — the feature works out of the box. Two operator constraints span containers and are HAND-SYNCED (no auto-coupling): 1. MAX_UPLOAD_SIZE (nginx, above) ≥ CSV_IMPORT_MAX_FILE_BYTES below. && 2. CSV_IMPORT_CLAIM_TIMEOUT_SECONDS ≥ 2 × CSV_IMPORT_NGINX_PROXY_TIMEOUT. && CSV_IMPORT_NGINX_PROXY_TIMEOUT is a web/nginx var (default 300s) — the two && timeouts live in DIFFERENT containers, so keep them in sync by hand. Authoritative per-CSV byte cap (Node Gate 2), counting actual bytes read.
CSV_IMPORT_MAX_REQUEST_BYTES 65536 Cap on the multipart request JSON part (mapping + options blob).
CSV_IMPORT_MAX_ROWS 10000 Max data rows per import (sized to the nginx import-route timeout).
CSV_IMPORT_STAGING_TTL_MINUTES 30 Staged preview row lifetime, in minutes.
CSV_IMPORT_MAX_STAGED_BYTES 25165824 Hard cap on the staged result JSONB; exceeded → 413.
CSV_IMPORT_CLAIM_TIMEOUT_SECONDS 600 Age (seconds) past which an orphaned committing row is deleted on recovery.
Variable Default Notes
STRIPE_SECRET_KEY Optional ALL optional. When unconfigured the billing feature is ABSENT (not broken): no purchase UI. Both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are required to enable the purchase + webhook path. Stripe secret key (server-side). Required for purchases. Optional.
STRIPE_WEBHOOK_SECRET Optional Stripe webhook signing secret. Required for the webhook path. Optional.
STRIPE_PUBLISHABLE_KEY Optional Stripe publishable key. Unused by the redirect checkout flow (reserved). Optional.
Variable Default Notes
STRIPE_PRO_PRICE_ID Optional Pro subscription Price id — the Price the subscription checkout sells RIGHT NOW. Pro is purchasable only when STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and this var are ALL set (‘’ is read as unset). Optional. SETUP: 1. In the Stripe Dashboard, create ONE Product for your Pro plan. && 2. On that Product, create a recurring monthly Price. && 3. Set STRIPE_PRO_PRICE_ID to that Price id (price_...). && 4. To change the price you sell: create a new Price on the same Product, && point this var at it and restart the api container — a CONFIGURATION && change, never a deploy. && 5. Grandfathering is Stripe-natural: existing subscriptions keep the Price && they subscribed at; only NEW checkouts sell the configured Price.
MIN_RESERVATION_CREDITS 1 Minimum available credits required to pass the pre-stream gate. Optional (default 1).
RESERVATION_TTL_MS 600000 Reservation hold TTL (ms) for crash-recovery. MUST be strictly greater than the per-turn wall-clock budget (480000ms). Optional (default 600000).
PRICING_MARKUP 1.2 Pricing markup over raw provider cost, as a decimal string (1.2 = 20%). Optional.
Variable Default Notes
FEATURE_GATING false Feature gating. Default OFF — self-hosters are unrestricted unless this is explicitly enabled. Must be exactly ‘true’ or ‘false’.
SEED_ADMIN_EMAIL Optional First-admin bootstrap: register normally, set SEED_ADMIN_EMAIL to that account’s email, then restart (docker compose up -d re-creates the container). At startup, IF the instance has zero admins, this email is promoted to admin. It is a bootstrap mechanism, not a standing override — a later demotion is not undone. SQL fallback (emails are stored lowercase; use the lowercase form): UPDATE users SET is_admin = true WHERE email = '<you>';
Variable Default Notes
CHANGELOG_GITHUB_REPO Optional GitHub repo whose Releases feed the in-app changelog, as an owner/repo slug — point a fork here. Optional (default madmatt112/tradr).
CHANGELOG_GITHUB_BASE_URL Optional GitHub API base URL — test/E2E seam; leave unset in production. Optional (default https://api.github.com).

Symbol search + delayed quotes (api container)

Section titled “Symbol search + delayed quotes (api container)”
Variable Default Notes
STOCK_QUOTE_API_KEY Optional Symbol AUTOCOMPLETE needs NO key — it is backed by the public SEC ticker file. Only the delayed LAST-PRICE lookup needs a key. Absent ⇒ that affordance is simply absent for everyone (isStockQuoteConfigured false); a fresh clone + docker compose up runs identically with it unset. api-only: the frontend learns configuredness from an authed config endpoint, not from a web env var. Platform-global delayed-quote provider key (API Ninjas). Optional.
STOCK_QUOTE_BASE_URL Optional Quote-provider base URL — test/E2E seam; leave unset in production. Optional (default https://api.api-ninjas.com).
SEC_USER_AGENT Optional SEC contact User-Agent for the ticker-file fetch (SEC asks for a contact). Override to your own contact if you like; the default is a safe non-personal value. Do NOT point it at a github.com URL — SEC 403s those, and the symbol table then silently stays empty. Optional (default tradr (+https://tradr.cloud)).
SEC_TICKERS_URL Optional SEC ticker file URL — test/E2E seam; leave unset in production. Optional (default https://www.sec.gov/files/company_tickers_exchange.json).
Variable Default Notes
SMTP_HOST Optional ALL seven vars OPTIONAL. Email (self-service password reset + verification emails) turns on ONLY when ALL THREE of SMTP_HOST + EMAIL_FROM + WEB_BASE_URL are set (all-or-nothing); leave every var unset/empty to run without email (reset falls back to the operator CLI tradr reset-password; verification is not required). A PARTIAL set — any email var set while that trio is incomplete, or SMTP_USER without SMTP_PASS (and vice versa) — FAILS LOUD at startup naming the offending vars. SMTP server hostname. Presence anchor of the required trio.
SMTP_PORT Optional SMTP port. Optional (default 587).
SMTP_TLS_MODE Optional TLS mode. One of: ‘implicit’ (TLS from the first byte, usually port 465), ‘starttls’ (plaintext upgrade on port 587 — the default), or ‘none’ (cleartext — local relays/mail catchers only). Optional (default starttls).
SMTP_USER Optional SMTP credentials — an OPTIONAL PAIR for auth-optional local relays: set both or neither.
SMTP_PASS Optional
EMAIL_FROM Optional From address for outgoing mail (bare address; case preserved). Part of the required trio.
EMAIL_FROM_NAME Optional Optional display name for the From header (no CR/LF).
WEB_BASE_URL Optional Public web origin emailed links land on — ORIGIN ONLY (scheme + host + optional port; no path/query/fragment; a trailing slash is stripped). Part of the required trio. Single-container compose: the site’s public URL (e.g. https://tradr.example.com). Split-origin (hosted): the WEB origin (where the reset/verify pages live), NEVER the API origin — it names the same web origin CORS_ALLOWED_ORIGINS allows, but the two serve different mechanisms and are not cross-checked.
Variable Default Notes
POSTHOG_API_KEY Optional ALL four vars are OPTIONAL. Each PostHog surface is ABSENT when its key is unset — a fresh clone + docker compose up runs identically with none set (no SDK loaded, no outbound calls, no errors). Configure only what you want. PRIVACY POSTURE (what is / isn’t sent): • PostHog (frontend) uses MEMORY-ONLY persistence (cookieless); autocapture, && session replay, and the SDK's surveys are DISABLED. Web vitals ARE && sent (performance timings only). && THE FEEDBACK TAB: when POSTHOG_PUBLIC_FEEDBACK_SURVEY is configured, the && in-app feedback form sends the rating and the optional free-text answer as && an anonymous event — value-scrubbed for secrets and e-mail addresses, never && for filenames. The SDK's own surveys UI is never loaded. The one && localStorage key capture() writes for a survey event is removed in the same && call, so no key persists past the capture (a write-then-remove still fires a && storage event). && PAGE URLS ARE SENT AS-IS. Pageviews carry the RESOLVED url, path, and host, && so in-app record identifiers in a path (e.g. /positions/<id>) DO reach your && PostHog instance. This is deliberate — PostHog's web analytics needs real && host/path values — and it REPLACES an earlier route-pattern masking. If you && do not want resolved paths leaving the app, either point POSTHOG_PUBLIC_KEY && at a self-hosted PostHog or leave the frontend key unset entirely. && URL FRAGMENTS ARE STRIPPED, at every depth. The password-reset and && email-verification links carry their token after the '#' && (/reset-password#token=...) so it stays in the browser; that fragment is && removed before any event is sent. The SDK copies the raw entry URL into && more than one property — one rides EVERY event of a session, one nests && inside each web-vitals metric — so the strip applies to every own-origin && URL in the payload, as a value and as an object key. && Query strings ARE sent — no route puts a secret in one. && Referrers are DROPPED, at every depth: $referrer, $initial_referrer, && $session_entry_referrer, and the search keyword parsed out of them. External && origins are never sent. The referring DOMAIN (hostname only, no path and no && query) IS kept, because channel reporting reads it. && FEATURE FLAGS ARE DISABLED in the SDK, so the browser makes no /flags && request. That request is the one call the send-time scrubber cannot reach: && its body carries the raw entry URL and referrer. The app reads no flags. && Geo enrichment is suppressed in-SDK via $geoip_disable, and any $geoip_* && value is dropped. && REQUIRED OPERATOR ACTION: enable PostHog project-side "Discard client IP && data" (Project settings). The SDK CANNOT do this — PostHog reads the IP && from the connection, not the payload, so it is stored unless you turn that && setting on. This is a prerequisite, not defence in depth. && • PostHog (backend) sends deliberate, non-financial product events keyed by an && OPAQUE user surrogate — no PII, no trade data, in any payload. Event values && pass a redaction guard that masks secrets/emails/upload-filenames. Structured logs go to stdout (captured by your container orchestrator); Tradr ships no logs to a third-party sink of its own. SHUTDOWN FLUSH: on SIGTERM the PostHog flush is bounded at 3s, comfortably under Docker’s default ~10s SIGTERM→SIGKILL grace. If you need longer to drain, raise stop_grace_period for the api service in a compose override. — Backend (api container) — PostHog project API key. Absent ⇒ backend PostHog surface OFF. Optional.
POSTHOG_HOST Optional PostHog ingestion host. Optional (default https://us.i.posthog.com); ‘’ is read as the default (does not need a key set to be valid).
POSTHOG_ENVIRONMENT Optional Deployment label stamped on every backend event/person as an environment property. Optional — absent ⇒ events are unstamped, which is the right default for a single deployment. Set it only if you run more than one (e.g. a staging copy) and want to tell them apart within one PostHog project.
POSTHOG_PUBLIC_KEY Optional — Frontend (web container; read by the nginx entrypoint into /config.js) — PostHog browser project key. Absent ⇒ frontend PostHog OFF. Optional.
POSTHOG_PUBLIC_HOST Optional PostHog browser ingestion host. Optional (default https://us.i.posthog.com).
POSTHOG_PUBLIC_ENVIRONMENT Optional Deployment label for frontend events — the browser-side mirror of POSTHOG_ENVIRONMENT. Optional; absent ⇒ events are unstamped.
POSTHOG_PUBLIC_FEEDBACK_SURVEY Optional Feedback-tab survey ids, “<surveyId>:<ratingQuestionId>:<textQuestionId>” (the three ids from a PostHog survey created with the API presentation). Optional and needs POSTHOG_PUBLIC_KEY set; absent, empty, or malformed ⇒ no feedback tab renders and the SDK’s surveys UI is never loaded. See the privacy posture above for what the form sends.
Variable Default Notes
REDIS_URL Optional ALL optional — SELF-HOSTERS LEAVE EVERYTHING BELOW UNSET. Each capability is a no-op when its config is absent/empty: with none configured the stack behaves EXACTLY as today (uploaded images inline base64-in-JSONB, process-local rate limiter, same-origin SameSite=Lax cookies, migrations over DATABASE_URL, prepared statements on). These are only needed to run the managed offering. An empty value is treated as unset. PRIVACY POSTURE: object storage and split-origin add NO third-party trackers — uploaded images move from one operator-controlled store (Postgres) to another (the operator’s own bucket), never to a shared/third-party surface. Object- storage credentials and REDIS_URL are SECRETS: server-side env only, never emitted to the frontend /config.js seam. Centralized rate-limit store (REQ-7). Set to a redis:// URL to share one rate limit across N API containers; unset ⇒ process-local in-memory limiter. Optional.
DIRECT_DATABASE_URL Optional Non-pooled direct DB URL for migrations + CLI (REQ-9.1). Required only when the app runs behind a transaction-mode pooler; unset ⇒ migrations use DATABASE_URL. Optional.
DB_TRANSACTION_POOLER Optional App-pool prepared-statement mode. Set ‘true’ behind a transaction-mode pooler so the driver uses prepare:false (REQ-9.2); unset/‘false’ ⇒ prepared statements on, exactly as today. Must be exactly ‘true’ or ‘false’. Optional (default false).
OBJECT_STORAGE_ENDPOINT Optional Object storage for uploaded images (REQ-2). All FOUR of ENDPOINT + BUCKET + ACCESS_KEY_ID + SECRET_ACCESS_KEY are required to enable it; partial/unset ⇒ images stay inline base64-in-JSONB as today. S3-compatible (Cloudflare R2 / AWS S3 / MinIO). All optional.
OBJECT_STORAGE_BUCKET Optional
OBJECT_STORAGE_REGION Optional
OBJECT_STORAGE_ACCESS_KEY_ID Optional
OBJECT_STORAGE_SECRET_ACCESS_KEY Optional
OBJECT_STORAGE_FORCE_PATH_STYLE Optional Path-style addressing (needed for MinIO). Must be ‘true’ or ‘false’. Optional (default true).
CORS_ALLOWED_ORIGINS Optional Split-origin CORS allow-list (REQ-5), comma-separated exact origins (never ‘*’ for credentialed requests). Non-empty ⇒ CORS + SameSite=None;Secure cookies + anti-CSRF turn on together; unset ⇒ same-origin only, SameSite=Lax, exactly as today. Implies TLS on both origins. Optional.