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
| Property | Value |
|---|---|
| Base | node:24-alpine, four stages: prune, install, build, run |
CMD | pnpm start |
| Schema owner | The frontend app for fullstack; apps/backend for separate |
| Exposed port | 3000 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
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.
DATABASE_URL (the CMD-time schema step reads it), the cache connection, BETTER_AUTH_SECRET, and the rest. See Environment variables.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.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.
| 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 including 404; fails on connection refused | 90s |
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.
| Service | Image | Default port | Purpose |
|---|---|---|---|
| postgres | postgres:18-alpine | 5432 (POSTGRES_PORT) | Local database; user/password postgres, database saas |
| redis | redis:8-alpine | 6379 (REDIS_PORT) | Local cache and rate limiting |
| mailpit | axllent/mailpit | 1025 SMTP / 8025 UI | Local email capture |
| inngest | inngest/inngest:v1.17.4 | 8288 (INNGEST_PORT) | Background jobs dev server |
Postgres and Redis persist to named volumes (postgres_data, redis_data).
Render
Run the node deploy target on Render as a managed Web Service built from the owner app's Dockerfile, with attached Postgres and a Key Value cache.
Dokploy
Deploy the node build to a self-hosted Dokploy instance on your own VPS, with Traefik fronting it and the schema migrate riding the start command.