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 four stages:

base: A shared node:24-alpine base image reused by every subsequent stage to avoid re-pulling the base.

deps: Installs dependencies using whichever lockfile is present (yarn, npm, or pnpm) for a reproducible install.

builder: Copies source files and runs the build. Telemetry is disabled via NEXT_TELEMETRY_DISABLED=1. The build script also runs prisma generate and prisma migrate deploy automatically (via package.json).

runner: The production image. It copies only the standalone output and static assets, runs as a non-root system user (nextjs), and exposes port 3000.

# syntax=docker.io/docker/dockerfile:1

FROM node:24-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

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

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
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 the ENV NEXT_TELEMETRY_DISABLED=1 line):

FROM base AS builder
WORKDIR /app
COPY --from=deps /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 NEXT_TELEMETRY_DISABLED=1

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