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 -dStop and remove data with:
docker-compose down -vBuilding 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 byprisma migrate deploy- All
NEXT_PUBLIC_*variables: inlined into the JavaScript bundle by Next.js (NEXT_PUBLIC_STORAGE_S3_CDN_URLis also evaluated innext.config.tsat 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=1Then 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:latestYour .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 deployThe 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.
Deploy to Vercel
Step-by-step guide to deploying Next Starter on Vercel, including all required environment variables, Stripe webhook configuration, custom domain setup, and preview deployment best practices.
SEO
How Next Starter handles metadata, Open Graph tags, Twitter Cards, sitemaps, robots.txt, and JSON-LD structured data through a single configuration layer in lib/config.ts.