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.

Next.js:3000NestJS:3001/api/*

What gets scaffolded

Dockerfile          # multi-stage build: web, api, migrate
docker-compose.yml  # orchestrates all services + postgres

Running it

cp .env.example .env
# set BETTER_AUTH_SECRET and POSTGRES_PASSWORD
 
docker compose up --build

The app will be available at http://localhost:3000.

Environment variables

BETTER_AUTH_SECRET=   # generate with: openssl rand -base64 32
POSTGRES_PASSWORD=    # any strong password

Both are forwarded to all services via a YAML anchor in docker-compose.yml.

Services

ServiceDescription
dbPostgreSQL 17
migrateRuns database migrations on startup, then exits
apiNestJS backend on port 3001
webNext.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:

  1. build: installs all dependencies, compiles web, api, and packages/auth with pnpm build, then runs pnpm deploy --filter=api --prod to produce a self-contained api bundle
  2. web: copies only the Next.js standalone output from build
  3. api: copies only the pnpm deploy output from build
  4. migrate: reuses the build image and runs pnpm 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:migrate

CORS

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.