Self-hosting with Docker
Build and run the node deploy target as a long-running container from the multi-stage Dockerfile, with your own database and cache.
The node deploy target ships a multi-stage Dockerfile per deployable app, built for any long-running container host - Render, Fly.io, Railway, Coolify, Dokploy, or a plain VPS. They all run the same image; the boilerplate has no platform-specific code.
The deploy artifact is the Dockerfile - in production you supply your own database and cache. infra/docker-compose.yml is the local development stack only (see below).
The image
The Dockerfile builds on node:24-alpine in four stages - prune, install, build, run - and the runner inherits FROM builder so drizzle-kit and the workspace tree are present at boot.
CMDispnpm start.- The CLI (
packages/cli/src/generators/deploy-scripts.ts) prepends the schema step onto the owner app'sstartscript, so it runs on every container restart. - For buyer builds the step is additive
pnpm -F @repo/database migrate; the--demoflag swaps it for a destructivereset && push --force. - The schema owner is the frontend app for
fullstack(itsDockerfile) orapps/backendforseparate(apps/backend/Dockerfile,EXPOSE 3010).
Never pass --demo for a real deployment - the schema step becomes a destructive reset && push --force that wipes the database on every boot.
Deploy the image
Build the image. Build the owner app's Dockerfile - the frontend app's for fullstack, apps/backend/Dockerfile for separate.
Point at your own database + cache. Production uses a database and cache you provision yourself - self-hosted Postgres/Redis or a managed provider. Set their connection env on the platform. See Database and Caching.
Set runtime env. Provide DATABASE_URL (required by the CMD-time schema step), the cache connection, BETTER_AUTH_SECRET, and the rest. See Environment variables.
Run the container. CMD pnpm start runs pnpm -F @repo/database migrate, then boots the server on its port (frontend 3000, backend API_PORT default 3010).
Local dependency stack (development only)
pnpm infra runs infra/docker-compose.yml to bring up local Postgres, Redis, Mailpit, and Inngest on your machine for development - it is not a production deployment step (pnpm infra:stop tears it down). init omits any service a managed provider already covers (Postgres for Neon/Supabase, Redis for Upstash) and includes Mailpit only when the email provider is smtp (omitted for resend/ses); if every service is managed, no compose file is generated at all.
| Service | Image | Default port | Purpose |
|---|---|---|---|
| postgres | postgres:18-alpine | 5432 (POSTGRES_PORT) | Local database; in production use your own - see Database |
| redis | redis:8-alpine | 6379 (REDIS_PORT) | Local cache + rate limiting; in production use your own - see Caching |
| mailpit | axllent/mailpit | 1025 SMTP / 8025 UI | Local email capture; see Email |
| inngest | inngest/inngest:v1.17.4 | 8288 (INNGEST_PORT) | Background job dev server; see Background jobs |
Postgres credentials default to postgres / postgres, database saas. Postgres and Redis persist to named volumes (postgres_data, redis_data).
Health check
Every image declares a HEALTHCHECK that probes the listening port, with a start-period long enough to cover the CMD-time schema step - so orchestrators like Dokploy or Traefik hold traffic until the server is actually ready. The probe differs by owner type:
| Image | Probe | Passes on | start-period |
|---|---|---|---|
Frontend (fullstack) | wget against PORT (default 3000) | 2xx only | 30s |
Backend (separate) | node http.get against API_PORT (default 3010) | any HTTP response (incl. 404), fails on connection refused | 90s |
The backend's longer start-period covers the slower reset && push --force of --demo builds on a cold database.