If you want to run Next.js somewhere other than Vercel, such as a VPS, Kubernetes, Fly.io or Railway, a Docker image is the most portable unit. The trick to keeping it small is output: "standalone".
Step 1: Turn on standalone output
// next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", }; export default nextConfig;
Now next build writes .next/standalone/ with a server.js and a trimmed node_modules containing only the files your app imports. You run it with node server.js, no next start needed.
Step 2: The Dockerfile
This version uses pnpm. For npm, swap the install commands.
# syntax=docker/dockerfile:1 FROM node:22-alpine AS base RUN corepack enable # 1. Install dependencies FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile # 2. Build FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ARG NEXT_PUBLIC_SITE_URL ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm build # 3. Run FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME=0.0.0.0 RUN addgroup -S nodejs && adduser -S nextjs -G nodejs 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 CMD ["node", "server.js"]
And a .dockerignore, which matters as much as the Dockerfile:
node_modules .next .git .env* *.log
Without it, COPY . . sends your local node_modules and .env files into the build context.
Step 3: Build and run
docker build --build-arg NEXT_PUBLIC_SITE_URL=https://example.com -t my-app . docker run -p 3000:3000 --env-file .env.production my-app
The three things people get wrong
1. Missing static files
.next/standalone does not include public/ or .next/static/. If your page loads without CSS, or images 404, check those two COPY lines.
2. Build-time vs runtime environment variables
NEXT_PUBLIC_*values are baked into the client JavaScript at build time. Changing them indocker run -edoes nothing for the browser. Pass them as--build-arg, or build one image per environment.- Server-only variables (
DATABASE_URL, API keys) are read at runtime. Pass them with-eor--env-file, and never bake them into the image withENV.
3. Binding to localhost
Inside a container, a server listening on localhost isn't reachable from outside. HOSTNAME=0.0.0.0 makes the standalone server listen on all interfaces.
Keeping the image small
- Multi-stage builds mean the final image has no dev dependencies, source files, or build cache.
node:22-alpineis small. If a native dependency fails on Alpine (musl), switch tonode:22-slim.- Check what's inside:
docker image ls my-appanddocker history my-app.
Image optimisation and caching
next/image works in standalone mode. Next.js uses sharp for optimisation in production; recent versions install it automatically, but if you see a warning about it, add sharp as a dependency.
If you run more than one container, each one has its own cache on disk. ISR and the data cache won't be shared across replicas unless you configure a shared cache handler. For a single container behind a reverse proxy, the default is fine.
Health check
HEALTHCHECK --interval=30s --timeout=3s \ CMD wget -qO- http://localhost:3000/api/health || exit 1
Add a tiny /api/health route that returns 200.