Next Starter Logo
Deployment

Docker Deployment

Self-host Next Starter with Docker: a multi-stage Dockerfile, Next.js standalone output, and the build args versus runtime env split explained.

How it works

next.config.ts sets output: "standalone". When you run next build, Next.js traces the Node modules the app actually reaches and copies just those next to a generated .next/standalone/server.js, so the final image never runs an install. The Dockerfile is a three-stage build that wraps a small production image around that output.

Everything is in your repo. Read Dockerfile, .dockerignore, and docker-compose.yml directly. This page covers the design and the two things people get wrong: the build args versus runtime env split, and what a stray .env.production does to your image.

The Dockerfile stages

All three stages run the same Node 24 slim image. The tag lives in one ARG NODE_VERSION above the first FROM, so you bump it in a single place.

StageWhat it does
dependenciesCopies the manifest and lockfile, then installs. It picks the package manager from whichever lockfile is present; this repo ships pnpm, and the npm and yarn branches are template fallbacks. BuildKit cache mounts speed up repeat installs.
builderCopies node_modules from dependencies, copies the source, sets NODE_ENV=production, and runs the build. The build script is prisma generate && next build && prisma migrate deploy, so database migrations run during the build.
runnerThe final production image. Copies only the standalone output, .next/static, and public/. Runs as the non-root node user, exposes port 3000, and starts with node server.js.

The runner stage sets no secrets of its own, only NODE_ENV, PORT, HOSTNAME, and NEXT_TELEMETRY_DISABLED.

Build args vs runtime env

A variable can be needed while the image builds, while the container runs, or both. Get that wrong and the build fails before it produces an image.

Next.js inlines every NEXT_PUBLIC_* value into the JavaScript bundle at build time, so the value is hard-copied into the shipped code. Set one only at runtime and the bundle still holds whatever was there when you built. The app uses four: NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_BETTER_AUTH_URL, NEXT_PUBLIC_STORAGE_S3_CDN_URL, and NEXT_PUBLIC_TURNSTILE_SITE_KEY. That last one feeds the Turnstile widget in the browser (see Turnstile). next.config.ts itself reads the CDN URL a second time, for the CSP and the image remotePatterns.

Everything else in the schema is server-side, and you would expect to pass it at runtime only. You can't. lib/validations/env.ts calls envSchema.parse(process.env) at import, lib/auth.ts imports it, and next build evaluates that module graph, so every key the schema marks required has to reach the builder stage as well or the build throws before it emits anything. DATABASE_URL has to point at a real reachable database on top of that, because prisma migrate deploy runs at the end of the build script.

So the two lists overlap almost completely: everything the schema requires at build, everything the running server reads at runtime.

Declare the build args

A --build-arg value only reaches a stage that declares a matching ARG, and the shipped Dockerfile declares nothing but NODE_VERSION. Add a pair per variable to the builder stage, after COPY . . and before ENV NODE_ENV=production:

ARG DATABASE_URL
ARG NEXT_PUBLIC_APP_NAME
ARG NEXT_PUBLIC_BETTER_AUTH_URL
ARG NEXT_PUBLIC_STORAGE_S3_CDN_URL
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV DATABASE_URL=$DATABASE_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
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_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY

Then repeat the pair for every other key the schema requires. Read lib/validations/env.ts for that list rather than copying one from here. It is the only thing that decides what is required.

Build

Before the first build, set APP_CONFIG.production.baseUrl in lib/config.ts to your real domain. Canonical URLs, the sitemap, Open Graph tags and the Server Actions origin allowlist in next.config.ts all derive from it, and nothing warns you while it still holds the placeholder.

.dockerignore keeps .env, .env.development, .env.test, and anything matching .env*.local out of the build context. .env.production is not on that list, and the builder stage sets NODE_ENV=production, so next build reads a .env.production sitting in your project root. Then, when Next.js writes the standalone output, it copies any .env and .env.production it loaded into .next/standalone, and the runner stage copies that whole directory into the final image. A .env.production left in the build context ships inside your image. Add it to .dockerignore before your first build.

Pass build-time values explicitly instead:

# plus one --build-arg for every other key lib/validations/env.ts requires
docker build \
  --build-arg DATABASE_URL=postgresql://user:password@your-db-host:5432/next_starter \
  --build-arg NEXT_PUBLIC_APP_NAME=your-app-name \
  --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_TURNSTILE_SITE_KEY=your-site-key \
  -t next-starter:latest .

Run

Pass every runtime value when you start the container:

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

--env-file reads that file from your host at start, not from the image, so keeping .env.production in .dockerignore costs you nothing here. Start from .env.example for its contents; Environment Variables has the full reference. The NEXT_PUBLIC_* values have to match the build args you used, since the browser is already reading the copies inlined into the bundle.

Database

The included docker-compose.yml runs PostgreSQL only, for local development. It does not run the Next.js app. Use pnpm dev for that:

docker-compose up -d        # start Postgres
docker-compose down -v      # stop and wipe data

To self-host the full stack on one server, add an app service next to Postgres. Point DATABASE_URL at the postgres service by its hostname, and load your env file with env_file:

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

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

volumes:
  postgres_data:
# in .env.production
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"

Note the volume target: /var/lib/postgresql, not /var/lib/postgresql/data. The postgres:18 image sets PGDATA to /var/lib/postgresql/18/docker and declares its volume at the parent directory. Mount /var/lib/postgresql/data instead and the entrypoint exits with an error pointing you at the right layout. The shipped docker-compose.yml already uses it.

Migrations

prisma migrate deploy is the last step of the build script, so it applies pending migrations at the end of the image build, after next build and long before the app starts. Running it on every deploy is safe. It does nothing when the database is already up to date, and you do not need a separate migration container.

The runner image carries no Prisma CLI, schema, or migration files, so you cannot run migrations from inside the container. To apply one by hand against a live database, run it from your project directory, locally or in CI:

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

What you can change

  • Node version. ARG NODE_VERSION in Dockerfile. Use a current LTS.
  • Port. ENV PORT and EXPOSE in the runner stage, plus the -p mapping when you run.
  • Build cache. Dockerfile sketches two options for .next/cache, and they pull against each other. A --mount=type=cache on the build step makes rebuilds faster but keeps the cache out of the image. The commented-out copy in the runner stage does the opposite, shipping the build's fetch cache so responses are warm on startup. Pick one.
  • Build context. Edit .dockerignore to change what the build excludes.

On this page