// LOADING
// LOADING
// LOADING_ARTICLE
Running a Next.js application in Docker is straightforward until you look at the image size—naive builds routinely produce images over 1 GB that include dev dependencies, the Next.js build cache, and the full node_modules tree. This guide shows how to use multi-stage builds and Next.js standalone output to produce a lean, production-ready image.
Vercel is the path of least resistance for Next.js deployments, but containers give you portability: deploy to AWS ECS, Google Cloud Run, a bare VPS, or Kubernetes without depending on a specific hosting platform. Containers also make local development environments reproducible across a team.
If you're setting up automated builds for your containers, a CI/CD pipeline with GitHub Actions can build and push Docker images on every merge to main.
Next.js has a standalone output mode that traces only the files actually used by your app—no full node_modules tree, just the modules that were imported. Enable it in next.config.ts:
ts// next.config.ts import type { NextConfig } from 'next'; const config: NextConfig = { output: 'standalone', }; export default config;
After next build, the .next/standalone directory contains a minimal Node.js server you can run directly. Combine this with Docker multi-stage builds and you get images under 200 MB.
dockerfile# syntax=docker/dockerfile:1 # ──────────────────────────────────────────── # Stage 1: Install dependencies # ──────────────────────────────────────────── FROM node:20-alpine AS deps WORKDIR /app # Copy only lockfiles first for better layer caching COPY package.json package-lock.json ./ RUN npm ci --omit=dev # ──────────────────────────────────────────── # Stage 2: Build the application # ──────────────────────────────────────────── FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . # Build-time env vars (non-secret; NEXT_PUBLIC_ vars go here) ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build # ──────────────────────────────────────────── # Stage 3: Production runtime # ──────────────────────────────────────────── FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 # Create a non-root user for security RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs # Copy only what the standalone server needs COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"]
This three-stage pattern gives you:
Without a .dockerignore, the COPY . . instruction sends your entire working directory into the build context—including node_modules, .git, and .next. That bloats the build and breaks layer caching.
# .dockerignore
.git
.gitignore
.next
node_modules
npm-debug.log
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
Dockerfile
.dockerignore
README.md
Excluding .env* files is important—secrets should be injected at runtime via environment variables, not baked into the image.
Never embed secrets in the image. Pass them at container startup:
bash# docker run docker run -p 3000:3000 \ -e MONGODB_URI="mongodb+srv://..." \ -e NEXTAUTH_SECRET="your-secret" \ -e NEXTAUTH_URL="https://example.com" \ my-nextjs-app:latest
In production, use your platform's secret management—AWS Secrets Manager, GCP Secret Manager, or Kubernetes Secrets mounted as environment variables. The application reads them through process.env just as it would in any other deployment.
For the Next.js-side rules on which environment variables reach the client vs stay on the server, see Environment Variables and Secrets Management in Next.js.
For local development with a database alongside the app:
yaml# docker-compose.yml version: '3.9' services: app: build: context: . target: runner ports: - '3000:3000' environment: - NODE_ENV=production - MONGODB_URI=mongodb://mongo:27017/mydb - NEXTAUTH_SECRET=dev-secret - NEXTAUTH_URL=http://localhost:3000 depends_on: - mongo mongo: image: mongo:7 ports: - '27017:27017' volumes: - mongo_data:/data/db volumes: mongo_data:
For local development where you want hot reloading, mount your source code and run next dev instead of using the production runner stage:
yamlapp-dev: build: context: . target: deps command: npm run dev volumes: - .:/app - /app/node_modules # prevent host node_modules from overwriting ports: - '3000:3000'
A few additional levers:
node:20-alpine instead of node:20 (Alpine-based images are ~120 MB vs ~950 MB)npm ci --omit=dev in the deps stage to exclude devDependencies from layers that don't need themlibc6-compat to Alpine: RUN apk add --no-cache libc6-compatDOCKER_BUILDKIT=1 docker build .bash# Build with BuildKit enabled DOCKER_BUILDKIT=1 docker build -t my-nextjs-app:latest . # Tag and push to Docker Hub or ECR docker tag my-nextjs-app:latest your-registry/my-nextjs-app:$(git rev-parse --short HEAD) docker push your-registry/my-nextjs-app:$(git rev-parse --short HEAD)
Tagging images with the git commit SHA (rather than just latest) makes rollbacks trivial—you always know exactly which commit is running in production.
Once your app runs in containers, structured logging becomes more important—container stdout/stderr is what log aggregators ingest. Pair your containerized deployment with the observability practices in Monitoring and Observability for Web Apps to ensure logs and metrics flow to the right place.
For a fully automated build-and-push workflow on every merge, see the CI/CD pipeline guide linked above.
The key moves for a production-quality Next.js Docker image:
output: 'standalone' in next.config.ts.dockerignore to keep the build context cleanThe resulting image is small, secure, and portable across any container hosting environment.