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.
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.
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
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.
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.
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.
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.
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
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.
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.
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.
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>';
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)).
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.
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.
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.
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.