Containerizing Next.js With Docker: Production Guide
On this page
Next.js is one of the most popular React frameworks for building production web applications, and Docker is the de facto standard for packaging those applications into portable, reproducible units. Putting the two together sounds simple — write a Dockerfile, run docker build, ship it — but a naive setup produces bloated images, slow builds, and runtime surprises. This guide walks through a production-grade approach that yields small, secure, fast-starting containers.
Why Containerize Next.js at All?
Before diving into configuration, it's worth being clear about what you gain. A containerized Next.js app gives you:
- Reproducibility — the same image runs identically on a laptop, in CI, and in production. No more "works on my machine."
- Isolation — your Node version, system libraries, and dependencies are pinned inside the image, insulated from the host.
- Portability — the image runs on any container platform: Kubernetes, ECS, Cloud Run, Fly.io, or a plain VM with Docker installed.
- Predictable scaling — orchestrators can spin up and tear down identical replicas on demand.
The catch is that Next.js has both a build step and a runtime, and it ships far more than you need to actually serve traffic. The whole game is separating what's needed to build from what's needed to run.
Enable Standalone Output First
The single most important optimization happens in your next.config.js, not your Dockerfile. Next.js can trace exactly which files your app needs at runtime and emit a self-contained standalone folder:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;
With this enabled, next build produces .next/standalone, which includes a minimal server.js and only the node_modules files actually referenced. This lets you skip copying the full node_modules directory into your final image — often shrinking it by hundreds of megabytes.
A Production-Ready Multi-Stage Dockerfile
Multi-stage builds are the key to lean images. You use a heavy stage with all your build tooling, then copy only the finished artifacts into a slim runtime stage. Everything from the build stage — dev dependencies, source files, caches — is discarded.
# syntax=docker/dockerfile:1
# 1. Install dependencies only when needed
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# 2. Build the application
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 3. Minimal production runtime
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Run as a non-root user
RUN addgroup --system --gid 1001 nodejs \
&& 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"]
A few things worth calling out in this file:
- Three stages.
depsinstalls packages,buildercompiles, andrunnerholds only what serves traffic. Therunnernever sees your source code or dev dependencies. npm ciovernpm install. It's faster in CI and installs exactly what's in your lockfile, guaranteeing reproducible builds.- The standalone copy. Note how
.next/standalone,.next/static, andpublicare copied separately. Standalone output does not bundle static assets or the public folder — you must copy them explicitly or you'll get missing CSS and images. - Non-root user. Running as
nextjsinstead of root is a baseline security practice; if the container is compromised, the blast radius is smaller. HOSTNAME=0.0.0.0. Without this, the standalone server may bind to localhost inside the container and be unreachable from outside.
Use a Thorough .dockerignore
Your build context is everything Docker sends to the daemon before building. If you don't exclude junk, you'll ship a slow, cache-busting context every time. Create a .dockerignore:
node_modules
.next
.git
Dockerfile
.dockerignore
npm-debug.log
README.md
.env*.local
.vscode
coverage
Excluding node_modules and .next matters most — you're rebuilding those inside the image anyway, and including them can invalidate layer caches and leak stale artifacts.
Handling Environment Variables Correctly
Next.js splits environment variables into two categories, and Docker forces you to be deliberate about the difference.
NEXT_PUBLIC_*variables are inlined at build time. They get baked into the JavaScript bundle duringnext build. If you need them, they must be present as build args in thebuilderstage — setting them at runtime does nothing.- Server-only variables are read at runtime. Database URLs, API secrets, and the like should be injected when the container starts, via
docker run -eor your orchestrator's secret management. Never bake secrets into the image.
To pass build-time public variables, use ARG and ENV in the builder stage:
FROM node:22-alpine AS builder
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# ...
RUN npm run build
Then build with docker build --build-arg NEXT_PUBLIC_API_URL=https://api.yourdomain.com .
This distinction trips up nearly everyone at least once. If a public variable shows up as undefined in the browser, it almost certainly wasn't available at build time.
Speed Up Builds With Cache Mounts
BuildKit supports cache mounts that persist between builds, dramatically speeding up dependency installation:
RUN --mount=type=cache,target=/root/.npm npm ci
The npm cache survives across builds even when the layer is invalidated, so re-downloading packages becomes near-instant. Make sure BuildKit is enabled (it's the default in modern Docker) and the # syntax=docker/dockerfile:1 directive is present at the top of your file.
Ordering your COPY statements from least- to most-frequently-changed also maximizes cache hits. Copying package.json and installing dependencies before copying source code means a code change doesn't force a full reinstall.
Add a Health Check
Orchestrators need to know when your container is actually ready to serve traffic. Add a simple health endpoint to your app (for example app/api/health/route.ts returning a 200), then wire up a Docker healthcheck:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
In Kubernetes you'd typically use readiness and liveness probes instead, but a HEALTHCHECK is valuable for docker run, Compose, and simpler platforms.
Local Development With Docker Compose
For a full local stack — app plus a database, say — Docker Compose keeps everything reproducible:
services:
web:
build:
context: .
args:
NEXT_PUBLIC_API_URL: http://localhost:3000
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=app
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Note that for hot-reloading development you'd want a different, simpler Dockerfile that mounts your source as a volume and runs next dev. The production image above is optimized for shipping, not iteration.
Production Checklist
Before you push to a registry and deploy, confirm:
output: 'standalone'is set and the standalone artifacts are being copied.- The image runs as a non-root user.
- No secrets are baked into any layer — verify with
docker historyanddocker inspect. .dockerignoreexcludesnode_modules,.next, and.git.- Image size is reasonable — a well-optimized Next.js image typically lands around 150–250 MB.
- You've scanned the image for vulnerabilities (
docker scout cvesor Trivy). - Layer caching is ordered so dependencies install before source is copied.
Frequently Asked Questions
Why is my Docker image so large?
The usual culprit is not using standalone output and copying the entire node_modules into the final stage. Enable output: 'standalone', use a multi-stage build, and base your runtime on node:22-alpine. Also verify your .dockerignore isn't letting .next or node_modules into the build context.
Should I use Alpine or Debian-based Node images?
Alpine images are much smaller and fine for most apps. However, Alpine uses musl instead of glibc, which can cause issues with native modules like sharp or certain database drivers. If you hit cryptic native errors, switch to node:22-slim (Debian-based) as a first troubleshooting step.
My NEXT_PUBLIC_ variables are undefined in the browser. Why?
Those variables are inlined at build time, not runtime. You must pass them as --build-arg values and expose them with ARG/ENV in the builder stage before npm run build. Setting them at docker run time has no effect on client-side code.
Do I need to copy the public and .next/static folders separately?
Yes. Standalone output deliberately excludes static assets and the public directory to keep the traced bundle minimal. You must copy .next/static and public into the runner stage explicitly, or your CSS, JavaScript chunks, and images will 404.
Can I run next dev in the same production image?
You can, but you shouldn't. The production image strips dev dependencies and source files. Use a separate, simpler Dockerfile with a bind mount for development so you get hot reloading, and reserve the multi-stage image for shipping.
How do I handle database migrations?
Keep migrations out of the container's CMD. Run them as a separate one-off job or init container before the app starts, so scaling to multiple replicas doesn't trigger concurrent migration runs.
Wrapping Up
A production Next.js container comes down to a handful of deliberate choices: enable standalone output, use a multi-stage build, run as non-root, be precise about build-time versus runtime environment variables, and cache aggressively. Get those right and you'll have images that build fast, start quickly, stay small, and behave identically everywhere you deploy them.