CCelestiallines.com
← Back to blog

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-dynamic or 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.

Comments