Skip to content

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:

bash
cp docker-compose.prod.example.yml docker-compose.prod.yml
$EDITOR docker-compose.prod.yml

Create a .env next to it (never commit it). At minimum:

bash
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:

bash
docker compose -f docker-compose.prod.yml up -d --build

The 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 / → the web container (port 80) for the SPA;
  • /api and the /ws upgrade → the api container (port 3000);
  • /mcp → the mcp container (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:

  • closedPOST /api/auth/register always returns 403. Add users by inviting them to boards.
  • invite — registration requires REGISTER_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:

bash
# 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 kanban

Also 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:

bash
git pull
docker compose -f docker-compose.prod.yml up -d --build

The 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.

Released under the AGPL-3.0 license.