Standalone + Dockerfile
Deploy Next.js and NestJS as separate containers
The default deployment model. A single root Dockerfile with multiple build targets produces three containers (web, api, and migrate) orchestrated by docker-compose.yml. Next.js builds with output: "standalone", and /api/* requests are proxied server-side to NestJS.
This works on any container platform: Railway, Fly.io, Render, AWS ECS, and others.
What gets scaffolded
Dockerfile # multi-stage build: web, api, migrate
docker-compose.yml # orchestrates all services + postgresRunning it
cp .env.example .env
# set BETTER_AUTH_SECRET and POSTGRES_PASSWORD
docker compose up --buildThe app will be available at http://localhost:3000.
Environment variables
BETTER_AUTH_SECRET= # generate with: openssl rand -base64 32
POSTGRES_PASSWORD= # any strong passwordBoth are forwarded to all services via a YAML anchor in docker-compose.yml.
Services
| Service | Description |
|---|---|
db | PostgreSQL 17 |
migrate | Runs database migrations on startup, then exits |
api | NestJS backend on port 3001 |
web | Next.js frontend on port 3000 |
migrate completes before api starts. api starts before web.
How the build works
The Dockerfile uses a single multi-stage build shared across all services:
build: installs all dependencies, compilesweb,api, andpackages/authwithpnpm build, then runspnpm deploy --filter=api --prodto produce a self-contained api bundleweb: copies only the Next.js standalone output frombuildapi: copies only thepnpm deployoutput frombuildmigrate: reuses thebuildimage and runspnpm db:migrate
The web and api stages are lean production images with no dev dependencies. The migrate stage is heavier but only runs once on startup.
Dockerfile
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS build
WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
ARG API_HOST=localhost
ENV API_HOST=$API_HOST
RUN pnpm build --filter=web --filter=api --filter=@my-app/auth
RUN pnpm deploy --filter=api --prod /deploy/api
# --- web ---
FROM base AS web
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
# --- api ---
FROM base AS api
ENV NODE_ENV=production
COPY --from=build /deploy/api /app
WORKDIR /app
EXPOSE 3001
CMD ["node", "dist/main"]
# --- migrate ---
FROM build AS migrate
WORKDIR /app
CMD ["pnpm", "db:migrate"]docker-compose.yml
x-app-env: &app-env
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres
services:
web:
build:
context: .
target: web
args:
- API_HOST=api
ports:
- "3000:3000"
environment:
<<: *app-env
depends_on:
- api
migrate:
build:
context: .
target: migrate
environment:
<<: *app-env
depends_on:
db:
condition: service_healthy
restart: "no"
api:
build:
context: .
target: api
ports:
- "3001:3001"
environment:
<<: *app-env
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
db:
image: postgres:17-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db_data:Migrations
The migrate container runs pnpm db:migrate, applying both Better Auth's tables and your app's Kysely migrations. DATABASE_URL is injected from the docker-compose environment, so no .env file is needed inside the container.
To run migrations locally:
pnpm db:migrateCORS
The Next.js rewrite proxy forwards /api/* server-side to NestJS via the API_HOST build arg (set to api in docker-compose), so the browser never makes a cross-origin request. CORS is not required for the normal request path.