Self-host with Docker Compose
This guide takes you from a fresh server to a running Tradr instance with Docker Compose, then covers verifying health, updating, and backing up. Self-hosting is a first-class path: the self-hosted build is the real product, and it runs as a complete manual trading journal with no optional keys configured — the AI advisor and external integrations are opt-in.
The three-container model
Section titled “The three-container model”Tradr ships as three services on one private bridge network:
| Service | Image | Role | Published port |
|---|---|---|---|
web |
built from docker/Dockerfile.web |
Nginx serving the static SPA + reverse-proxying /api to the api service |
${WEB_PORT:-8080} → container :80 |
api |
built from docker/Dockerfile.api |
Node.js Hono server; stateless, all state in Postgres | none (internal only) |
postgres |
postgres:16 |
PostgreSQL 16 with a persistent volume | none (internal only) |
Only web exposes a host port. postgres and api are reachable only inside
the compose network. This keeps the database and API off the public internet by
default.
Prerequisites
Section titled “Prerequisites”- Docker with Compose v2 — the
docker compose(space, not hyphen) subcommand. Check withdocker compose version. - ~2 GB RAM minimum (2–4 GB recommended). Postgres + Node + Nginx use ~500–700 MB under load; concurrent AI advisor requests add memory pressure. A 1 GB VPS risks the OOM killer under load.
- A small VPS or homelab box. Any host that runs Docker Compose works — Hetzner/DigitalOcean/Linode droplets, or Synology, Unraid, TrueNAS, Coolify, and Portainer setups.
gitandopenssl(for generating secrets).
Step 1 — Clone the repository
Section titled “Step 1 — Clone the repository”git clone https://github.com/madmatt112/tradr-v2.gitcd tradr-v2Step 2 — Create your .env
Section titled “Step 2 — Create your .env”Copy the annotated template. Every setting has a sensible default except the secrets, which ship blank on purpose.
cp .env.example .envThe compose stack reads secrets and optional settings from this file. One thing
that is not read from .env: the api’s DATABASE_URL. Compose builds it from
your POSTGRES_* values in api.environment at render time — do not rely on
DATABASE_URL in .env for the compose stack (compose overrides it).
Step 3 — Set the required secrets
Section titled “Step 3 — Set the required secrets”These are the only values Tradr cannot run without. Generate each and paste it
into .env.
Database credentials
Section titled “Database credentials”POSTGRES_USER=tradrPOSTGRES_PASSWORD= # generate belowPOSTGRES_DB=tradrGenerate a URL-safe password (hex avoids URL-reserved characters like @ : / ?):
openssl rand -hex 24 # -> POSTGRES_PASSWORDCompose derives the api’s connection string from these three values as
postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}.
Session secret
Section titled “Session secret”Signs auth session cookies. Generate at least 32 characters:
openssl rand -base64 24 # -> SESSION_SECRETEncryption key
Section titled “Encryption key”A 32-byte hex key used to encrypt provider API keys at rest (AES-256-GCM):
openssl rand -hex 32 # -> ENCRYPTION_KEYRecommended: pin the key with ENCRYPTION_KEY_FINGERPRINT. If set, the app
fails fast on startup when it is deployed with the wrong ENCRYPTION_KEY,
instead of silently failing to decrypt stored keys. Generate it from your actual
key (not a fresh random value):
openssl rand -hex 32 | xxd -r -p | openssl dgst -sha256 -binary | xxd -p -c 32ENCRYPTION_KEY_PREVIOUS exists only for key rotation — leave it blank
otherwise.
Feature gating — leave it off
Section titled “Feature gating — leave it off”FEATURE_GATING=falseFEATURE_GATING=false is the default and the right choice for self-hosters: it
disables all usage limits so your instance is unrestricted. Only the hosted
platform sets this to true.
That is everything required. You can start the stack now (Step 5) and add any of the optional integrations below later.
Step 4 — Optional integrations
Section titled “Step 4 — Optional integrations”All of these are optional. When an integration’s config is absent the feature is simply absent, not broken — a fresh clone with none set runs identically, makes no outbound calls, and logs no errors. Configure only what you want.
LLM keys for the AI advisor
Section titled “LLM keys for the AI advisor”Platform-provided keys let any user on your instance use the advisor without their own key. Absent, the advisor stays BYOK-only (each user pastes their own key in-app).
ANTHROPIC_API_KEY= # ClaudeOPENAI_API_KEY= # OpenAIWallet billing and Pro subscriptions (Stripe)
Section titled “Wallet billing and Pro subscriptions (Stripe)”Only needed if you want users to buy advisor credits or subscribe to Pro. Both the secret key and the webhook signing secret are required to enable the purchase
- webhook path; with them unset there is no purchase UI and the advisor stays BYOK-only.
STRIPE_SECRET_KEY=STRIPE_WEBHOOK_SECRET=STRIPE_PUBLISHABLE_KEY= # reserved; unused by the redirect checkout flowSTRIPE_PRO_PRICE_ID= # the Stripe Price id sold by the Pro subscription checkoutPro is purchasable only when STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and
STRIPE_PRO_PRICE_ID are all set. Full webhook-endpoint and Customer-Portal setup
is documented inline in .env.example.
Market data and delayed quotes
Section titled “Market data and delayed quotes”The advisor fetches market data when a tool-use-capable provider is selected;
UNUSUAL_WHALES_BASE_URL only exists to point the server at a test stub — leave
it unset in production to use the real host. Symbol autocomplete needs no key
(it is backed by the public SEC ticker file); only the delayed last-price lookup
needs one:
STOCK_QUOTE_API_KEY= # API Ninjas — enables the delayed last-price affordanceAnalytics (PostHog)
Section titled “Analytics (PostHog)”Off by default. Set a key to send product analytics only (no PII, no trade data — users are identified by an opaque id, payloads are redaction-scrubbed).
POSTHOG_API_KEY= # backend business events (api container)POSTHOG_HOST= # optional; default https://us.i.posthog.comPOSTHOG_PUBLIC_KEY= # frontend UI events (read by the web container)POSTHOG_PUBLIC_HOST= # optional; default https://us.i.posthog.comTransactional email
Section titled “Transactional email”Self-service password reset and email verification turn on only when all three
of SMTP_HOST, EMAIL_FROM, and WEB_BASE_URL are set (all-or-nothing). A
partial set fails loudly at startup naming the missing var. Without email, users
who forget a password rely on the operator CLI (tradr reset-password <email>),
and verification is not required.
# SMTP_HOST=# SMTP_PORT=587# SMTP_TLS_MODE=starttls # implicit | starttls | none# SMTP_USER= # optional pair — set both or neither# SMTP_PASS=# EMAIL_FROM=# WEB_BASE_URL=https://tradr.example.com # your instance's public originInteractive Brokers (IBKR) — roadmap, not yet shipped
Section titled “Interactive Brokers (IBKR) — roadmap, not yet shipped”IBKR is a planned read-only integration (view positions, live prices, import
positions). It is not yet a shipped runtime integration — there are currently
no IBKR_* settings in .env.example, and CSV import is today’s path for
getting broker data in. See Import trades from
CSV.
Cross-container settings to keep in sync
Section titled “Cross-container settings to keep in sync”A few limits live in different containers and are hand-synced — the compose stack does not couple them for you:
MAX_UPLOAD_SIZE(web/nginx) must be ≥CSV_IMPORT_MAX_FILE_BYTES(api), or oversized CSVs are rejected by nginx before the api can size them.CSV_IMPORT_CLAIM_TIMEOUT_SECONDS(api) must be ≥ 2 ×CSV_IMPORT_NGINX_PROXY_TIMEOUT(web).ADVISOR_NGINX_PROXY_TIMEOUT(web) must exceedADVISOR_STREAM_TIMEOUT_MS(api), or nginx cuts advisor streams early.
The defaults already satisfy these; only revisit them if you change one side.
Step 5 — Start the stack
Section titled “Step 5 — Start the stack”docker compose up -dCompose builds the images, starts postgres, waits for it to pass its
healthcheck, then starts api, then web.
Using pre-built images instead of building locally: the release workflow
publishes multi-arch images to GHCR. In docker-compose.yml, replace each
service’s build: block with a pinned image — this is documented inline in the
compose file:
image: ghcr.io/<owner>/tradr-api:1.2.3image: ghcr.io/<owner>/tradr-web:1.2.3First run: migrations apply automatically
Section titled “First run: migrations apply automatically”On the api container’s first start, database migrations run automatically —
there are no manual migration steps. The api acquires a PostgreSQL
session-level advisory lock (key 7064001) before invoking the migrator, so in
multi-container or restart scenarios only one process migrates at a time and the
others wait. Migrations are forward-only (no down/rollback). This is
implemented in apps/api/src/db/migrate.ts and runs inside the api’s
bootstrap() before it starts serving.
The api healthcheck has a start_period of 180s. A first-run
CREATE INDEX CONCURRENTLY post-migration on a large table can take a while; the
grace window keeps the container from being killed mid-migration. During this
window api shows health: starting and the SPA’s API calls may briefly 502 —
this is expected on first boot. restart: unless-stopped will not interrupt an
in-flight migration.
Step 6 — Verify it’s healthy
Section titled “Step 6 — Verify it’s healthy”Check liveness through the published web port:
curl -fsS http://localhost:8080/api/health# {"status":"ok"} (HTTP 200; 503 {"status":"error"} if the DB is unreachable)Then open the app in a browser at http://localhost:8080 (or your
WEB_PORT) and sign up — see Getting
started.
Check migration state at any time (read-only, uses its own connection):
docker compose exec api tradr migrate --status# exit 0 = schema current, 1 = pending migrations, 2 = cannot connectTLS is your responsibility. The shipped compose file does not terminate
HTTPS. Put your own reverse proxy / edge (Caddy, Traefik, nginx, Cloudflare) in
front of the web container. If you do, add that edge’s address to
TRUSTED_PROXIES in .env so per-IP rate limiting stays accurate.
Bootstrap the first admin (optional)
Section titled “Bootstrap the first admin (optional)”To get an admin account: register normally in the app, then set
SEED_ADMIN_EMAIL in .env to that account’s email and restart
(docker compose up -d re-creates the container). At startup, if the instance
has zero admins, that email is promoted. It is a one-time bootstrap, not a
standing override.
Updating
Section titled “Updating”The shipped compose file builds locally, so the default update path is to pull the newer source and rebuild:
git pulldocker compose up -d --buildIf instead you switched the compose services to pinned GHCR images (Step 5), pull the newer images and recreate:
docker compose pulldocker compose up -dMigrations for the new version apply automatically on api startup (same advisory lock as first run). Because migrations are forward-only, the policy is back up before every upgrade (next section).
Backups and where your data lives
Section titled “Backups and where your data lives”All persistent data lives in the pgdata named volume mounted at
/var/lib/postgresql/data in the postgres container. Docker prefixes the
volume with the compose project name, so it resolves to e.g. tradr-v2_pgdata
(confirm with docker volume ls). There is no proprietary format — a plain
pg_dump is a complete, portable backup.
Logical dump (recommended, hot)
Section titled “Logical dump (recommended, hot)”docker compose exec -T postgres \ pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" > backup-$(date +%F).sqlVolume snapshot (cold, stack stopped)
Section titled “Volume snapshot (cold, stack stopped)”docker compose downdocker run --rm -v tradr-v2_pgdata:/data -v "$PWD":/backup alpine \ tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .docker compose up -dRestore
Section titled “Restore”Restore a logical dump into a fresh stack (empty database):
docker compose up -d postgrescat backup-2026-01-01.sql | docker compose exec -T postgres \ psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"docker compose up -dTroubleshooting
Section titled “Troubleshooting”apistuckhealth: startingon first boot — likely a long first-run migration. Give it the full 180sstart_period; watch progress withdocker compose logs -f api.apicrash-loops complaining about the encryption key — you changedENCRYPTION_KEY(or set a mismatchedENCRYPTION_KEY_FINGERPRINT). Restore the original key and checkdocker compose logs api.- Uploads or CSV imports rejected with 413 — the cross-container size/timeout constraints in Step 4 are out of sync.
- Can’t reach the app — confirm the
webport mapping (WEB_PORT, default8080) and thatpostgrespassed its healthcheck (docker compose ps).
Next steps
Section titled “Next steps”- Environment variable reference — every key, its default, and generation recipe.
- Back up and restore — schedules and full restore procedures.
- Upgrade an instance — the upgrade policy in detail.
- Put TLS in front of the stack.
- Architecture overview — how the three containers fit together.