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 Starter is ready to self-host with Docker. next.config.ts sets output: "standalone". When you run next build, Next.js emits a self-contained .next/standalone/server.js. It bundles only the Node modules the app actually uses (this is called output tracing), so the final image needs no node_modules. 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 explains the design and the one thing people get wrong: build args vs runtime env.
The Dockerfile stages
All three stages use a pinned Node 24 image, set with ARG NODE_VERSION so you bump the version in one place. See Dockerfile.
| Stage | What it does |
|---|---|
dependencies | Copies the manifest and lockfile, then installs deps. It auto-detects npm / yarn / pnpm (this repo ships pnpm; the npm and yarn branches are template fallbacks). BuildKit cache mounts speed up repeat installs. |
builder | Copies node_modules from dependencies, copies the source, and runs the build. The build script is prisma generate && next build && prisma migrate deploy, so database migrations run during the build. |
runner | The 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 bakes in no secrets. It only sets NODE_ENV, PORT, HOSTNAME, and NEXT_TELEMETRY_DISABLED. Everything else is passed at runtime.
Build args vs runtime env
This is the part that trips people up. A variable is needed at one of two moments, and the two are not interchangeable.
Build time (--build-arg). Values that must exist while the build runs:
- The four
NEXT_PUBLIC_*vars. Next.js inlines these into the client JavaScript bundle when it builds. "Inline" means the value is hard-copied into the shipped code. If you set them only at runtime, the bundle still holds the old (or empty) value. DATABASE_URL. The build runsprisma migrate deploy, which needs a real database connection.NEXT_PUBLIC_STORAGE_S3_CDN_URLis read a second time innext.config.tswhen that file loads (for the CSP and imageremotePatterns), so it also has to be present at build.
Runtime (--env-file / -e). Everything else: server secrets like STRIPE_SECRET_KEY, BETTER_AUTH_SECRET, TURNSTILE_SECRET_KEY, plus SMTP and S3 credentials. The running server reads these. They are never baked into the image.
Rule of thumb: if the browser or the build step needs it, it is a build arg. If only the server needs it while running, it is runtime env.
The four build-time NEXT_PUBLIC_* vars:
| Variable | Used for |
|---|---|
NEXT_PUBLIC_APP_NAME | App name shown in the UI |
NEXT_PUBLIC_BETTER_AUTH_URL | Client-side auth base URL |
NEXT_PUBLIC_STORAGE_S3_CDN_URL | R2 CDN host (also read in next.config.ts) |
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Cloudflare Turnstile widget (see Turnstile) |
The shipped Dockerfile does not declare these ARGs. By default the build runs with empty NEXT_PUBLIC_* values, and prisma migrate deploy has no DATABASE_URL. Add the declarations to the builder stage (shown next) before you build. Otherwise the client bundle ships blank public vars.
Declare the build args
Add these lines to the builder stage in your Dockerfile, right 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_KEYEach ARG accepts the value from --build-arg. The matching ENV makes it visible to pnpm build.
Build
.dockerignore excludes .env (and the .local env files), so build-time values are not read from disk. Pass them as build args:
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
The runner stage holds no secrets. Pass every runtime value when you start the container:
docker run --env-file .env.production -p 3000:3000 next-starter:latestA trimmed .env.production with the runtime variables the server reads:
# Core
DATABASE_URL="postgresql://user:password@your-db-host:5432/next_starter"
PUBLIC_URL="https://yourdomain.com"
# Auth (Better Auth)
BETTER_AUTH_SECRET="your-32-character-secret"
BETTER_AUTH_URL="https://yourdomain.com"
NEXT_PUBLIC_BETTER_AUTH_URL="https://yourdomain.com"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# Billing (Stripe)
STRIPE_SECRET_KEY="sk_live_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
STRIPE_PRICE_PLUS_MONTHLY="price_..."
STRIPE_PRICE_PLUS_ANNUAL="price_..."
STRIPE_PRICE_PRO_MONTHLY="price_..."
STRIPE_PRICE_PRO_ANNUAL="price_..."
# Email (SMTP2GO)
SMTP2GO_API_KEY="your-api-key"
SENDER_EMAIL="noreply@yourdomain.com"
# Storage (Cloudflare R2)
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"
# Captcha (Cloudflare Turnstile)
NEXT_PUBLIC_TURNSTILE_SITE_KEY="your-site-key"
TURNSTILE_SECRET_KEY="your-secret-key"
# Misc
NEXT_PUBLIC_APP_NAME="your-app-name"The NEXT_PUBLIC_* values here must match the build args you used. They are already inlined in the bundle. Listing them at runtime keeps any server-side reads consistent. See Environment Variables for the full reference.
Database options
The included docker-compose.yml runs only PostgreSQL, 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 dataTo 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 .env.production 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/data
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}"Migrations
prisma migrate deploy runs inside the build script, so pending migrations are applied during the image build, before the app starts. This is safe on every deploy: it does nothing if the database is already up to date. You do not need a separate migration container.
The runner image contains only the standalone output. It has no Prisma CLI, schema, or migration files, so you cannot run migrations from inside it. To apply a migration 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 dlx prisma migrate deployWhat you can change
- Node version: bump
ARG NODE_VERSIONinDockerfile(use a current LTS). - Port: change
ENV PORTandEXPOSEin therunnerstage, and the-pmapping when you run. - Package manager: the build auto-detects from the lockfile. This repo uses pnpm.
- Build cache: add the
.next/cachemount to the build step and uncomment the.next/cachecopy line inDockerfileto speed up rebuilds (note the fetch-cache trade-off in the comments). - Build context: edit
.dockerignoreto change what is excluded from the build.
Deploy to Vercel
Deploy Next Starter to Vercel: import the repo, set environment variables, run build-time migrations, and wire up the domain, Stripe webhook, and Turnstile.
Testing
Run end-to-end tests with Playwright in Next Starter: a setup project signs in once so every test reuses the session, plus how to add and run new specs.