How to Deploy a Next.js App with Docker
By Pawan Singh · Jun 2026
Deploying Next.js with Docker gives you a portable, reproducible artifact instead of depending on a specific hosting platform's build environment — the tradeoff is you own the operational pieces (SSL, process management, scaling) a managed platform would otherwise handle for you.
Enable Standalone Output
Next.js's default build output assumes node_modules will be present at runtime. For Docker, use standalone output instead — it traces and copies only the dependencies actually used, producing a dramatically smaller final image:
// next.config.js
module.exports = {
output: 'standalone',
};
A Multi-Stage Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
Each stage exists for a reason: deps installs dependencies in a layer that's cached and reused as long as package-lock.json hasn't changed; builder runs the actual build; runner is the final, minimal image — it never contains the full node_modules, source TypeScript files, or build tooling, only the standalone output next.config.js's output: 'standalone' setting produces.
Why the Build Stage Can't Reach Other Services
A Next.js build runs npm run build, which pre-renders any page that can be statically generated — and it runs entirely inside the isolated build stage, with no network path to other containers (a database, a headless CMS API) that only exist once the full stack is actually running via Docker Compose. Any route that fetches from another service using a timed revalidate (Incremental Static Regeneration) gets pre-rendered at build time with whatever the fetch resolved to — typically an empty result from a failed fetch inside a try/catch — and that snapshot can stick, since ISR only regenerates on a live request after the container is already running with real network access.
// Forces this route to render per-request at runtime instead of being
// statically snapshotted at build time with no access to other services
export const dynamic = 'force-dynamic';
Add this to any page/route whose data genuinely must be fresh and depends on a service unavailable during the build.
Environment Variables: Build Time vs Runtime
This trips up almost everyone deploying Next.js in Docker for the first time: variables prefixed NEXT_PUBLIC_ are inlined into the client JavaScript bundle at build time — setting them only at container runtime (via docker run -e or a Compose environment: block) has no effect, because the bundle was already produced with whatever value (or lack of one) was present during docker build. Pass these as Docker build arguments instead:
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com .
Server-only environment variables (no NEXT_PUBLIC_ prefix) behave the opposite way — they're read at runtime by the running Node process, so those are fine to set via normal container environment variables without rebuilding the image.
Docker Compose for a Full Stack
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://db:5432/app
depends_on:
- db
db:
image: postgres:16
depends_on only waits for the container to start, not for the database inside it to actually be ready to accept connections — for anything that queries the database immediately on boot, add explicit retry/wait logic in the app rather than assuming depends_on alone guarantees readiness.
Common Issues
- "Cannot find module" at runtime despite a successful build — missing
output: 'standalone', or a stage not copying the standalone output correctly. - A NEXT_PUBLIC_ variable is empty/undefined in the browser — it was set at container runtime instead of as a build argument; rebuild the image with it passed via
--build-arg. - Pages showing stale or empty data fetched from another service — the build stage had no network path to that service; add
force-dynamicor switch that fetch to run genuinely per-request. - Huge image size — not using standalone output, or copying the full repository (including
.git, dev dependencies, source maps) into the final stage instead of only the built artifacts.
Need a Next.js app containerized and deployed properly? Get in touch — I work as a freelance Next.js developer.