Self-Host Next.js in Docker Properly
07 Jan 2026Most 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
- deps: Installs dependencies in isolation so Docker’s layer cache holds until
package-lock.jsonchanges, not every time you edit a component. - builder: Has the full source and
node_modules, runs the build, and is discarded — none of it ships. - runner: Only the standalone output, running as a non-root user.
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.