IAnswerable

Self-Host Next.js in Docker Properly

07 Jan 2026

Most Next.js Dockerfiles floating around are either bloated (full node_modules copied into the runner) or broken in some subtle way (running as root, no caching, wrong build args). Here’s one that’s correct for production.

The full Dockerfile

# syntax=docker/dockerfile:1
FROM node:20-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
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"]

This assumes output: 'standalone' is set in next.config.js. If it isn’t, add it — see the deps stage above, which stays lean because a separate deps layer means dependency installs are cached independently of your source code changes.

Why each stage exists

The non-root user matters

Running as root inside a container isn’t catastrophic on its own, but combined with a container escape vulnerability it turns a contained bug into a host compromise. The adduser/USER nextjs block costs four lines and removes a whole class of risk. Don’t skip it because it “probably doesn’t matter for a side project” — it’s four lines, not an afternoon.

Build args vs runtime env

NEXT_PUBLIC_* variables get inlined into the JavaScript bundle at build time. If you set them only at container runtime (docker run -e NEXT_PUBLIC_API_URL=...), they will not show up in the client bundle — the build already happened without them, and every NEXT_PUBLIC_API_URL reference in your compiled code is undefined.

Pass them as build args instead:

docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com -t myapp .

Server-only env vars (database URLs, API secrets) work the opposite way — set those at runtime via docker run -e or your orchestrator’s secrets, never baked into the image, or you’ll leak them into every layer of the image history.

Healthcheck for orchestrators

If you’re running under Docker Swarm, Kubernetes, or Dokploy, add a route your platform can poll:

// app/api/health/route.js
export async function GET() {
  return Response.json({ status: 'ok' })
}
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD wget -qO- http://localhost:3000/api/health || exit 1

Without this, your orchestrator considers the container “up” the moment the process starts, not when Next.js is actually ready to serve — and during a rolling deploy you’ll route traffic into a container that isn’t listening yet.

The takeaway

Multi-stage build, standalone output, non-root user, build args for NEXT_PUBLIC_*, runtime env for secrets, and a real healthcheck. Skip any one of these and you’ll find out in production, not in docker build.