SaaS Starter
Deploy

Docker

Bring up the full stack (Postgres, Redis, server, client) with one command for local development or production-like builds.

The repo ships two Compose files. Pick the one that matches what you need:

FilePurposeWhat it includes
docker-compose.dev.ymlLocal development. Hot reload, source mounted, ephemeral secrets.Postgres 16 · Redis 7 · server (Bun + --hot) · client (next dev)
docker-compose.ymlProduction-like build. Standalone images, no source mount, no DB.server image · client image (you bring your own Postgres + Redis)

If you want to try the boilerplate end to end without provisioning anything, use docker-compose.dev.yml — it boots the entire stack.

Quick start (development)

From the repo root:

docker compose -f docker-compose.dev.yml up

That single command builds nothing — it pulls public images and mounts your working tree. Wait ~30 seconds for bun install to finish inside both containers.

You get:

ServiceURL / PortNotes
Clienthttp://localhost:3004Next.js with hot reload
Serverhttp://localhost:3005Express on Bun, hot reload via bun --hot
Postgrespostgresql://postgres:postgres@localhost:5432/boilerplateUser/pass/db are seeded by the compose file
Redisredis://localhost:6379Used by BullMQ + rate-limit store

The server reads its config from environment variables baked into the compose file — no .env required to boot.

[!NOTE] The client container needs two API URLs in compose: NEXT_PUBLIC_API_URL=http://localhost:3005 for the browser, and INTERNAL_API_URL=http://server:3005 for Next.js middleware / RSC fetches that run inside the container. Single-host setups (Vercel, bare-metal) only need NEXT_PUBLIC_API_URL — the middleware falls back to it when INTERNAL_API_URL is unset.

First run — apply migrations

The dev compose applies pending migrations automatically on boot — the server's command runs prisma migrate deploy before starting, so a fresh checkout comes up with an up-to-date schema. No manual step needed on the first run.

When you change schema.prisma during development, create a new migration with migrate dev:

docker compose -f docker-compose.dev.yml exec server \
  bunx --cwd apps/server prisma migrate dev

First run — seed roles + permissions

The boilerplate ships with a seed script that creates the default owner / admin / member roles for the IAM module. Run it once:

docker compose -f docker-compose.dev.yml exec server \
  bun apps/server/prisma/seed.ts

You're done. Sign up at http://localhost:3004/register and you'll land on the dashboard.

Common commands

# Tail server logs (most useful one)
docker compose -f docker-compose.dev.yml logs -f server

# Open a shell inside the server container (debug Prisma, run scripts, etc.)
docker compose -f docker-compose.dev.yml exec server sh

# Connect to Postgres with psql
docker compose -f docker-compose.dev.yml exec postgres \
  psql -U postgres -d boilerplate

# Stop everything (containers stay around — `up` next time is faster)
docker compose -f docker-compose.dev.yml stop

# Tear it all down INCLUDING the Postgres volume (destroys data)
docker compose -f docker-compose.dev.yml down -v

Optional services

The dev compose intentionally keeps the surface small. If you need the optional adapters that ship with the boilerplate, add them yourself:

  • BullMQ Bull Board — boots inside the server container automatically when REDIS_URL is set; visit http://localhost:3005/admin/queues.
  • Mailpit / MailHog for catching dev emails — add a service block, then set EMAIL_FROM=dev@local and (if using Resend) leave RESEND_API_KEY unset so the LogEmailProvider falls through.
  • MongoDB — the compose file has a commented-out mongodb block. Uncomment it, follow ADR-002 to switch the Prisma provider, and update DATABASE_URL.

Production-like build

The other compose file (docker-compose.yml) builds immutable server and client images — useful for verifying that your code passes the same build pipeline CI uses, or for pushing to a registry.

# Provision your own Postgres + Redis first, then export their URLs:
export DATABASE_URL=postgresql://...
export REDIS_URL=redis://...
export BETTER_AUTH_SECRET=$(openssl rand -hex 32)

docker compose up --build

This does not include a database — the production compose assumes you're running against managed Postgres (Supabase, Neon, RDS, etc.) and managed Redis (Upstash, ElastiCache, etc.). See the Supabase guide for a one-vendor setup.

The server image (the runner stage) runs prisma migrate deploy at boot, before the HTTP server starts, so migrations apply automatically against the configured DATABASE_URL. A failed migration aborts the boot instead of serving against a schema the code no longer matches. The worker stage does not migrate, so a single process owns schema rollout.

Build targets: server vs. worker

apps/server/Dockerfile exposes two runtime targets that share the same build:

TargetProcessHealthcheckUse when
runnerHTTP server (dist/index.js) — defaultHTTP probe on /healthThe API. A build with no --target resolves here.
workerBullMQ worker (src/bootstrap/worker.ts)noneThe background-jobs process (emails, webhooks, deletions).

The BullMQ worker runs no HTTP server, so it must not inherit the server's HTTP healthcheck — an orchestrator (Docker Swarm, Dokploy) would probe /health, never get a response, mark the task unhealthy, and recycle it every ~70 seconds. The worker target sets HEALTHCHECK NONE for exactly this reason.

Build it explicitly:

docker build --target worker -f apps/server/Dockerfile -t myapp-worker .

[!NOTE] On platforms that deploy from a Dockerfile (Dokploy, Railway), point the worker service's Build Stage / target at worker. If you leave it empty it builds runner (the default last stage) and the worker will crash-loop on the inherited HTTP healthcheck.

Troubleshooting

port is already allocated — something on your host is using 3004, 3005, 5432, or 6379. Either stop it or remap the host-side port in the compose file ("3104:3004").

Server boots but every request 500s with "Cannot find module '@prisma/client'" — Prisma client wasn't generated. The dev container runs bun install on boot which triggers the postinstall hook; if it raced the first request, restart the server: docker compose -f docker-compose.dev.yml restart server.

Hot reload doesn't pick up changes on macOS — Docker Desktop's filesystem polling is slow. The compose file mounts the whole repo with volumes: - .:/app; if you see lag, switch Docker Desktop to VirtioFS (Settings → General → Virtual Machine Manager).

Migrations fail with "database does not exist" — the Postgres healthcheck reports ready before the boilerplate database is created on the very first boot. Wait 5 seconds and retry the migrate command.

bun install runs every time and is slow — that's expected: the compose mounts a volume for node_modules so it persists across restarts, but the first install pays full network cost. Subsequent boots reuse the volume.

A container disappeared mid-run and came back — server, worker, and client all set restart: unless-stopped in the dev compose. This is intentional: heavy E2E suites can OOM-kill the Bun process under concurrent auth flows + BullMQ enqueues, and auto-restart spares you from running docker start by hand. If a container restarts repeatedly, follow the logs (logs -f <service>) — you have a real crash, not a one-off.

On this page