Next Starter Logo
Deployment

Docker Deployment

Self-host Next Starter with Docker using the included Dockerfile, docker-compose, and Next.js standalone output mode. Covers building, environment variables, and running database migrations.

Overview

Next Starter is configured for self-hosted Docker deployment. The output: "standalone" setting in next.config.ts produces a minimal production image, and the included Dockerfile uses a multi-stage build to keep image size small.

Standalone Output Mode

In next.config.ts, the project sets:

const nextConfig: NextConfig = {
  output: "standalone",
  // ...
};

This tells Next.js to produce a self-contained server.js file in .next/standalone that includes only the Node.js modules required to run the app. You do not need node_modules in the final image, only the files Next.js explicitly traces as needed.

Dockerfile Overview

The included Dockerfile uses three stages, all built on node:24.13.0-slim (Debian slim — pinned via an ARG NODE_VERSION so you can bump it in one place). BuildKit cache mounts speed up subsequent installs.

dependencies: Copies the package manifest plus every supported lockfile and installs dependencies (npm, yarn, or pnpm — pnpm is what this repo actually ships, the other branches are template fallbacks).

builder: Pulls the installed node_modules from the previous stage, copies the source, and runs the build. The build script in package.json also runs prisma generate and prisma migrate deploy before next build.

runner: The production image. It runs as the built-in node user, copies only the standalone output, static assets, and public/, and exposes port 3000.

# ============================================
# Stage 1: Dependencies Installation Stage
# ============================================
ARG NODE_VERSION=24.13.0-slim

FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./

RUN --mount=type=cache,target=/root/.npm \
    --mount=type=cache,target=/usr/local/share/.cache/yarn \
    --mount=type=cache,target=/root/.local/share/pnpm/store \
  if [ -f package-lock.json ]; then \
    npm ci --no-audit --no-fund; \
  elif [ -f yarn.lock ]; then \
    corepack enable yarn && yarn install --frozen-lockfile --production=false; \
  elif [ -f pnpm-lock.yaml ]; then \
    corepack enable pnpm && pnpm install --frozen-lockfile; \
  else \
    echo "No lockfile found." && exit 1; \
  fi

# ============================================
# Stage 2: Build Next.js application in standalone mode
# ============================================
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

ENV NODE_ENV=production

RUN if [ -f package-lock.json ]; then \
    npm run build; \
  elif [ -f yarn.lock ]; then \
    corepack enable yarn && yarn build; \
  elif [ -f pnpm-lock.yaml ]; then \
    corepack enable pnpm && pnpm build; \
  else \
    echo "No lockfile found." && exit 1; \
  fi

# ============================================
# Stage 3: Run Next.js application
# ============================================
FROM node:${NODE_VERSION} AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED=1

COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static

USER node
EXPOSE 3000
CMD ["node", "server.js"]

docker-compose.yml for Local Development

The included docker-compose.yml runs PostgreSQL locally. It is intended for development, not for running the Next.js app itself in Docker during development (use pnpm dev for that).

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: next_starter
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Start it with:

docker-compose up -d

Stop and remove data with:

docker-compose down -v

Building the Docker Image

The build script (pnpm build) runs prisma generate, prisma migrate deploy, and the Next.js production build in sequence inside the container. Because .dockerignore excludes .env files (only .env.example is kept), you must pass build-time variables explicitly using --build-arg. The variables required at build time are:

  • DATABASE_URL: needed by prisma migrate deploy
  • All NEXT_PUBLIC_* variables: inlined into the JavaScript bundle by Next.js (NEXT_PUBLIC_STORAGE_S3_CDN_URL is also evaluated in next.config.ts at import time)

Build-time vs Runtime Variables

Variables prefixed with NEXT_PUBLIC_ are inlined into the JavaScript bundle at build time. DATABASE_URL is also required at build time because prisma migrate deploy runs during the build step.

The included Dockerfile does not declare these build arguments. Before building, add the following ARG and ENV lines to the builder stage in your Dockerfile (between COPY . . and ENV NODE_ENV=production):

FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

ARG DATABASE_URL
ARG NEXT_PUBLIC_BETTER_AUTH_URL
ARG NEXT_PUBLIC_STORAGE_S3_CDN_URL
ARG NEXT_PUBLIC_APP_NAME
ARG NEXT_PUBLIC_RECAPTCHA_SITE_KEY
ENV DATABASE_URL=$DATABASE_URL
ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL
ENV NEXT_PUBLIC_STORAGE_S3_CDN_URL=$NEXT_PUBLIC_STORAGE_S3_CDN_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_RECAPTCHA_SITE_KEY=$NEXT_PUBLIC_RECAPTCHA_SITE_KEY

ENV NODE_ENV=production

Then build with:

docker build \
  --build-arg DATABASE_URL=postgresql://user:password@your-db-host:5432/next_starter \
  --build-arg NEXT_PUBLIC_BETTER_AUTH_URL=https://yourdomain.com \
  --build-arg NEXT_PUBLIC_STORAGE_S3_CDN_URL=https://your-r2-cdn-domain.com \
  --build-arg NEXT_PUBLIC_APP_NAME=your-app-name \
  --build-arg NEXT_PUBLIC_RECAPTCHA_SITE_KEY=your-site-key \
  -t next-starter:latest .

Environment Variables in Docker

The runner stage does not bake environment variables into the image. Pass them at runtime using --env-file or individual -e flags.

docker run \
  --env-file .env.production \
  -p 3000:3000 \
  next-starter:latest

Your .env.production file should contain all required variables:

DATABASE_URL="postgresql://user:password@your-db-host:5432/next_starter"
BETTER_AUTH_SECRET="your-32-character-secret"
BETTER_AUTH_URL="https://yourdomain.com"
NEXT_PUBLIC_BETTER_AUTH_URL="https://yourdomain.com"
PUBLIC_URL="https://yourdomain.com"
STRIPE_SECRET_KEY="sk_live_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
STRIPE_PUBLISHABLE_KEY="pk_live_..."
SMTP2GO_API_KEY="your-api-key"
SENDER_EMAIL="noreply@yourdomain.com"
STORAGE_S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
STORAGE_S3_BUCKET="your-bucket"
STORAGE_S3_REGION="auto"
STORAGE_S3_KEY="your-access-key-id"
STORAGE_S3_SECRET="your-secret-access-key"
NEXT_PUBLIC_STORAGE_S3_CDN_URL="https://your-r2-cdn-domain.com"
NEXT_PUBLIC_APP_NAME="your-app-name"
NEXT_PUBLIC_RECAPTCHA_SITE_KEY="your-site-key"
RECAPTCHA_SECRET_KEY="your-secret-key"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"

Production docker-compose Example

For self-hosting the full stack (app + database) on a single server:

services:
  postgres:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - internal

  app:
    image: next-starter:latest
    restart: unless-stopped
    ports:
      - "3000:3000"
    env_file:
      - .env.production
    depends_on:
      - postgres
    networks:
      - internal

networks:
  internal:

volumes:
  postgres_data:

In .env.production, set DATABASE_URL to point at the postgres service using its service name as the hostname:

DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"

Migrations in Production

Migrations run automatically as part of pnpm build (via prisma migrate deploy), so they are applied during the Docker build step before the app starts. No separate migration container is needed.

If you need to run migrations manually against a live database (for example, applying a schema change before deploying a new image), run the command from your project directory (locally or in CI) where the Prisma schema and migration files are available:

DATABASE_URL="postgresql://user:password@your-db-host:5432/next_starter" pnpm dlx prisma migrate deploy

The production Docker image (runner stage) only contains the Next.js standalone output. It does not include the Prisma CLI, schema, or migration files, so you cannot run migrations from inside it.

prisma migrate deploy applies pending migrations without prompting. It is safe to run on each deploy: it is a no-op if the database is already up to date.

On this page