MENU
Docker
Containerizing a Next.js app packages the same self-hosted Node.js server into a portable image that runs identically on a laptop, a CI runner, or any container platform -- Kubernetes, ECS, Cloud Run, Fly.io, or a plain docker run on a VPS. The build relies on the same output: 'standalone' mode used for self-hosting, since copying an entire node_modules folder into every image layer would make builds slow and images unnecessarily large.
Why a Multi-Stage Dockerfile
A naive Dockerfile that runs npm install and next build in a single stage ships the final image with the full development dependency tree, the source files, and the build cache all baked in -- often hundreds of megabytes larger than necessary, and with a bigger attack surface. A multi-stage build splits the work into separate stages and discards everything except the final runtime output:
- deps -- installs dependencies from the lockfile only, so this layer can be cached and reused as long as package.json doesn't change.
- builder -- copies in the source on top of the installed dependencies and runs next build.
- runner -- starts from a fresh, minimal base image and copies in only the traced .next/standalone output, .next/static, and public. Nothing from the deps or builder stages -- no source, no dev dependencies, no build cache -- makes it into the final image.
The result is typically a final image in the tens of megabytes range on top of the base image, versus several hundred megabytes for a single-stage build.
Dockerfile:
# ---- deps ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---- builder ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---- runner ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
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"]Running as a dedicated non-root user (nextjs above) rather than the container's default root user is a small addition that meaningfully reduces the impact of a container escape or dependency compromise.
Building and Running
terminal:
docker build -t my-nextjs-app .
docker run -p 3000:3000 --env-file .env.production my-nextjs-appA docker-compose Example
For local development against dependent services (a database, a cache, a reverse proxy) or a simple single-host production setup, wrapping the same image in a compose file keeps the whole stack declarative.
docker-compose.yml:
services:
web:
build: .
ports:
- "3000:3000"
env_file:
- .env.production
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:Environment variables that need to be baked into the client bundle at build time (anything prefixed NEXT_PUBLIC_) must be available as build arguments during the builder stage, not just at container run time -- by the time docker run executes, next build has already finished and those values are already compiled into the JavaScript sent to the browser.