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 compose file starts the server but does not run Prisma migrations automatically (so you can pick the right strategy for your branch). On the first boot, in a second terminal:

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

For a fresh schema during development you can use migrate dev instead — that creates a new migration from your current schema.prisma:

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 runs prisma migrate deploy at boot, so migrations apply automatically against the configured DATABASE_URL.

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