GenerateSaaS

Self-hosting with Docker

Build and run the node deploy target as a long-running container from its multi-stage Dockerfile, with a database and cache you provision yourself.

The node target ships one multi-stage Dockerfile per deployable app, built for any long-running container host - Render, Fly.io, Railway, Coolify, Dokploy, or a plain VPS. The deploy artifact is that Dockerfile; you supply the database and cache.

The image

PropertyValue
Basenode:24-alpine, four stages: prune, install, build, run
CMDpnpm start
Schema ownerThe frontend app for fullstack; apps/backend for separate
Exposed port3000 frontend / 3010 backend

The runner stage inherits FROM builder, so drizzle-kit and the workspace tree are present at boot. The CLI prepends pnpm -F @repo/database run deploy to the owner app's start script, so migrations apply on every container start.

Never build with --demo for a real deployment - its schema step resets the database and force-pushes the schema on every boot.

Deploy the image

Build the owner app's Dockerfile - the frontend app's for fullstack, apps/backend/Dockerfile for separate.

Bake the API URL into the frontend image. Next.js inlines NEXT_PUBLIC_* into the browser bundle at build time, so pass it as a build arg - a runtime env var cannot fix it later.

docker build --build-arg NEXT_PUBLIC_API_URL=https://yourdomain.com/api .

It defaults to http://localhost:3000/api, which only suits a locally run container. The backend image takes no build args.

Set runtime env on the host: DATABASE_URL (the CMD-time schema step reads it), the cache connection, BETTER_AUTH_SECRET, and the rest. See Environment variables.
Declare a CDN, and nothing else. A reverse proxy in front of the container needs no TRUSTED_PROXY: it reaches the app from a private address, which the backend reads as proof the request was forwarded. Set cf-connecting-ip only when a CDN fronts that proxy from a public address - without it every caller shares one rate-limit bucket, and the server logs a warning naming the header it is ignoring. See Client IP.
Run the container. pnpm start applies the schema, then binds the port.

Health check

Every image declares a HEALTHCHECK with a start-period long enough to cover the boot-time schema step, so orchestrators hold traffic until the server is actually ready.

ImageProbePasses onstart-period
Frontend (fullstack)wget against PORT (default 3000)2xx only30s
Backend (separate)node http.get against API_PORT (default 3010)Any HTTP response including 404; fails on connection refused90s

Local dependency stack (development only)

pnpm infra brings up infra/docker-compose.yml on your machine and pnpm infra:stop tears it down - never a deployment step. 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.

ServiceImageDefault portPurpose
postgrespostgres:18-alpine5432 (POSTGRES_PORT)Local database; user/password postgres, database saas
redisredis:8-alpine6379 (REDIS_PORT)Local cache and rate limiting
mailpitaxllent/mailpit1025 SMTP / 8025 UILocal email capture
inngestinngest/inngest:v1.17.48288 (INNGEST_PORT)Background jobs dev server

Postgres and Redis persist to named volumes (postgres_data, redis_data).

On this page