Self-hosting
A generic guide to running Tracker in production. Tracker is designed to be self-hosted: Postgres is the source of truth, Redis fans out events, and the app runs as three stateless containers (api, mcp, web) plus those two backing services. Bring your own TLS-terminating reverse proxy in front.
INFO
This guide is intentionally infrastructure-agnostic — it works on a single VM, a container host, or any orchestrator. Adapt the specifics to your platform.
1. Production Compose
The repo ships a docker-compose.prod.example.yml. Copy it and review every value:
cp docker-compose.prod.example.yml docker-compose.prod.yml
$EDITOR docker-compose.prod.ymlCreate a .env next to it (never commit it). At minimum:
SESSION_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 24)
PUBLIC_BASE_URL=https://tracker.example.com
MCP_PUBLIC_URL=https://tracker.example.com/mcp
CORS_ORIGINS=https://tracker.example.com
TRUST_PROXY=true
REGISTRATION_MODE=invite
REGISTER_INVITE_CODE=$(openssl rand -hex 16)See Configuration for every variable. Then:
docker compose -f docker-compose.prod.yml up -d --buildThe api applies its database migrations on start, so the first boot is also the schema install. The example file publishes the api, mcp, and web ports to loopback only — your reverse proxy terminates TLS and forwards to them.
2. TLS & reverse proxy
Put any TLS-terminating reverse proxy in front (Caddy, nginx, Traefik, or a cloud load balancer). Route:
- everything under
/→ thewebcontainer (port 80) for the SPA; /apiand the/wsupgrade → theapicontainer (port 3000);/mcp→ themcpcontainer (port 3001), if you expose MCP publicly.
Because the SPA's nginx already reverse-proxies /api and /ws to the api, the simplest topology is: proxy → web, and let web forward the api paths. Expose /mcp separately if remote MCP clients need it.
Set TRUST_PROXY
Behind any proxy or load balancer you must set TRUST_PROXY=true (or a hop count). Otherwise req.ip is the proxy's address and per-IP rate limiting — the login brute-force guard included — buckets every client together.
Long-lived MCP streams. The MCP server speaks Streamable HTTP, which can hold long Server-Sent-Event streams. Raise your proxy's idle/read timeout above the longest expected stream, and keep the app timeout longer than the proxy's to avoid spurious 502s.
A minimal Caddy example:
tracker.example.com {
reverse_proxy /mcp* mcp:3001
reverse_proxy web:80
}3. Registration policy
Self-registration is gated by REGISTRATION_MODE:
closed—POST /api/auth/registeralways returns403. Add users by inviting them to boards.invite— registration requiresREGISTER_INVITE_CODE(≥ 12 chars; the api refuses to boot if it is empty or too short). Share the code with people you want to let in.open— anyone can register.
Production (NODE_ENV=production) defaults to closed when unset.
4. Attachments
The default local storage driver writes attachment bytes to a Docker volume (ATTACHMENT_LOCAL_PATH, mounted at /data/attachments) — zero external dependencies, ideal for a single host. Back up that volume alongside the database.
For horizontally-scaled or durable object storage, set ATTACHMENT_STORAGE=s3 and the S3_* variables to point at any S3-compatible backend (AWS S3, MinIO, Cloudflare R2, …). In s3 mode the api issues presigned upload/download URLs, so attachment bytes never transit the api. See Configuration.
5. Backups
Postgres is the source of truth. Back it up with pg_dump:
# Dump (run from the host; adjust the service/DB names to your compose file)
docker compose -f docker-compose.prod.yml exec -T postgres \
pg_dump -U kanban kanban | gzip > backup-$(date +%F).sql.gz
# Restore into a fresh database
gunzip -c backup-YYYY-MM-DD.sql.gz | \
docker compose -f docker-compose.prod.yml exec -T postgres psql -U kanban kanbanAlso back up the attachments volume when using the local driver. Automate both on a schedule and test a restore periodically.
6. Upgrades
Migrations run automatically on api start, so upgrading is pull → rebuild → up:
git pull
docker compose -f docker-compose.prod.yml up -d --buildThe api applies any new Drizzle migrations idempotently as it boots; the mcp service runs no migrations (it only needs the schema to exist). Migrations are forward-only — take a pg_dump before a major upgrade so you can roll back by restoring.
7. Health checks
Both app services expose a health endpoint returning { "status": "ok" }:
- api:
GET /health(on port 3000) - mcp:
GET /health(on port 3001)
The Compose file already health-checks both. Point your uptime monitor and your proxy's upstream health probe at these. The api's health flips to ready only after migrations finish, so it doubles as a "ready to serve" signal.
8. Scaling
Every mutation fans out over Redis, so scaling the api or mcp from 1→N instances needs no code change — run more replicas pointed at the same Postgres and Redis. The MCP server runs stateless (no session affinity needed) and shares the api's per-token rate-limit buckets through Redis, so a token's budget is unified across REST and MCP regardless of which replica serves a request.