Stackboard ships two supported deploy shapes: the containerized stack in
docker-compose.prod.yml, and any plain Node.js host. Both are
platform-agnostic — nothing in the repo assumes a specific provider.
Dockerfile + docker-compose.prod.yml run the whole stack — web,
worker, Postgres, and MinIO — on a single host. Verify locally before
touching a server:
docker compose -f docker-compose.prod.yml up --buildThe compose file works as-is on any Docker-Compose-based host or PaaS (Coolify, Dokploy, a plain VM with Compose, …). Two notes that apply everywhere:
- Set every real value in your platform's env panel (or an env file kept
out of git):
POSTGRES_PASSWORD,SESSION_SECRET,APP_URL,RESEND_API_KEY,MINIO_ROOT_PASSWORD. Never edit the compose file with real secrets. See configuration.md for the full variable list. - The
ports:block onwebneeds no editing. It binds to127.0.0.1by default, which keeps the app off the public internet without TLS — a reverse proxy (Traefik, nginx, Caddy) should face the public. A PaaS that proxies containers over the Docker network ignores the mapping entirely. SetWEB_BIND=0.0.0.0only to deliberately expose the app unproxied.
If your platform offers both a "point at a Git repository" resource and a
"paste the compose YAML" resource, prefer the Git one pointed at
docker-compose.prod.yml — a pasted-YAML resource typically has no checkout
to run the build: contexts against.
The web image's start command runs drizzle-kit migrate and then starts
the server, so every container migrates before it serves. This is deliberate:
a container that starts serving (and passing its healthcheck) against an
unmigrated database returns 500s from every page that touches the DB.
Platform pre-deploy hooks were tried first and are not reliable here — it
can be ambiguous whether they run against the newly-built image or the
outgoing one. No deploy hook is needed.
GET /api/health reports whether the app can actually serve, not just
whether the process is up. It probes Postgres with a timed select 1 and
measures how long the oldest already-due job has been waiting — a web
container can be perfectly responsive while the worker is dead and no
invite email is going out.
{
"status": "ok",
"checks": [
{ "name": "database", "status": "ok", "durationMs": 3 },
{
"name": "jobs",
"status": "ok",
"durationMs": 2,
"detail": "0 due, oldest n/a"
}
],
"timestamp": "2026-08-19T21:00:00.000Z"
}status is ok, degraded (slow database, or the queue running late), or
down. The response is 200 for the first two and 503 for down, so an
uptime monitor or load balancer sees a failure without parsing the body.
Thresholds live in lib/domain/health.ts.
The endpoint is unauthenticated by design — the container healthcheck in
docker-compose.prod.yml calls it — so the body carries statuses and timings
only, never error text, which could leak the database host or user. Real
errors go to the server log. Point an external uptime monitor at it and alert
on non-200; to catch degraded too, match the body against
"status":"ok".
Changing POSTGRES_PASSWORD in your host's env panel is not enough, and
it fails in a way that's genuinely hard to diagnose. The Postgres image only
reads that variable when it initializes an empty data directory; once the
volume holds data, the real password lives inside the database and the
variable is ignored. Change only the variable and web/worker will
authenticate with the new value against a database that still expects the old
one — they can't connect, never pass their healthcheck, and the orchestrator
may delete the containers before you can read their logs.
Rotate in this order:
- Change the password in the database first, against the running container:
docker exec -it <db-container> psql -U stackboard -d stackboard \ -c "ALTER USER stackboard WITH PASSWORD 'the-new-value';"
- Then set
POSTGRES_PASSWORDto that same value in the env panel. - Redeploy.
The same applies to any other credential baked into a volume on first init.
Vercel, Fly, Render, a plain VM:
- Provision Postgres and set
DATABASE_URL. - Set
APP_URL(your public URL), andRESEND_API_KEY/EMAIL_FROMif you want real invite emails. (SESSION_SECRETis reserved for future use and read by nothing today — see configuration.md.) - Run
bun run db:migrateas a release step before starting new instances. bun run build && bun run start.- Run the jobs worker (
bun run db:jobs:work) as a second long-lived process — see architecture.md. - If you deploy to multiple instances/regions, set
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYto a stable value shared across them (see Next.js's Server Actions guide) so Server Action payloads stay decryptable across instances.
This app doesn't ship its own backup tooling — use standard Postgres tooling
against DATABASE_URL:
pg_dump "$DATABASE_URL" -Fc -f backup.dump # backup
pg_restore -d "$DATABASE_URL" --clean backup.dump # restoreTake a backup before running db:migrate against a production database, and
before any manual data-repair psql session.