dockerdockerfiledevopscontainers

How to write a modern Dockerfile: layers, multistage and non-root user

Anatomy of a well-built Dockerfile for Node, Next.js, Python, Go and Rust: layer caching, multistage builds, Alpine vs Slim images and non-root users.

August 23, 2026·8 min read

Most Dockerfiles floating around work, but they leave on the table the three optimizations that matter in a real project: images several hundred MB heavy when they could weigh 20, builds that recompile all dependencies on every code change, and containers running as root for no reason. Let's build one properly from scratch.

The ORDER of instructions IS the caching strategy

Docker caches each instruction as a layer. If a layer's inputs don't change (the files it copies, the commands it runs), Docker reuses it. This fact turns line order into a critical decision:

FROM node:22-alpine
WORKDIR /app

RUN addgroup --system app && adduser --system --ingroup app app

COPY package*.json ./
RUN npm ci

COPY . .

USER app
EXPOSE 3000
CMD ["npm", "start"]

Look at the COPY package*.jsonRUN npm ciCOPY . . sequence. Package manifests only change when you add or update dependencies (a few times a month); your code changes constantly. By copying manifests first and installing dependencies before touching source code, any code edit reuses the intact node_modules layer. The minutes-long npm ci becomes an instant cache hit.

The classic mistake — starting with COPY . . — invalidates the install cache with every file save.

Multistage: build in one image, ship another

A Next.js build needs devDependencies, compilers and tooling. The final artifact needs almost nothing. Multistage builds separate both worlds using multiple FROM statements, where each stage starts clean and copies from previous stages only what it needs:

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:22-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
CMD ["node", "server.js"]

Three stages: deps installs dependencies, builder compiles the application, runner is the only one reaching production. The final image does not even contain TypeScript source code or devDependencies — just Next.js standalone output and its runtime. Important detail: this pattern requires "output": "standalone" in your next.config.

Alpine, Slim or Bookworm: which variant to pick

Official images publish several variants; choosing wrong costs space or compatibility:

  • Alpine (~50 MB base): uses musl libc instead of glibc. Ideal for static binaries and simple projects. Known risk: native packages compiled against glibc may fail, and some teams have reported performance differences under DNS or crypto-heavy loads due to musl's implementation.
  • Slim (~75 MB): trimmed Debian, keeps glibc. The safe default when using native dependencies (bcrypt, sharp, canvas).
  • Bookworm (~150+ MB): full Debian. Only when you need system tooling inside the container (compilers, debuggers).

Rule of thumb: start with Alpine, and if npm install fails compiling native binaries, drop to Slim rather than installing chains of -dev packages.

Never run as root

By default, everything in the container runs as root. If someone escapes the application through a vulnerability, they have root inside the container — and although containers isolate, privilege escalation is the first rung of every exploitation ladder. The mitigation costs two lines:

RUN addgroup --system app && adduser --system --ingroup app app
# ... COPY ...
USER app

Go solves it even better with distroless images (gcr.io/distroless/static-debian12:nonroot) that have no shell and no package manager: your static binary and literally nothing else.

The Rust cache trick

Rust deserves special mention because compiling dependencies is slow and the "copy manifest first" pattern above requires a detour: Cargo cannot compile dependencies alone without an existing main.rs. The standard solution is creating a fake one, building, deleting artifacts and rebuilding with real code:

FROM rust:1-bookworm AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src target/release/deps/app*
COPY . .
RUN cargo build --release

The first build compiles twice. From the second one onward, changing your code only recompiles the last line: dependencies stay cached in earlier layers. On mid-sized projects this cuts builds from 10 minutes to under 1.

Do not forget the .dockerignore

Without .dockerignore, COPY . . drags your local node_modules, .git history and .env files into the build context. A reasonable minimum:

node_modules
.git
.env*
*.log
Dockerfile
.dockerignore

It is not just about context size: copying a local node_modules over the one installed inside the image (possibly compiled for another architecture) is a classic source of impossible-to-reproduce bugs.

Generate your Dockerfile from templates

If you would rather start from a good example than write from scratch, our Dockerfile Generator produces complete configurations for Node, Next.js, React/Vite, Python, Go and Rust, with multistage, non-root user, package manager selection and the matching .dockerignore ready to copy.

FAQ

Should I pin the exact image version? Using node:22-alpine gets you automatic patches; pinning by digest (node@sha256:...) gives total reproducibility at the cost of manual updates. For serious production, pin digests and automate updates with Dependabot or Renovate.

COPY --chown or chown afterwards? --chown inside COPY is better: a RUN chown -R duplicates the files into a new layer, inflating the image.

Does Healthcheck go in the Dockerfile or Compose? Both are valid. In Compose/Kubernetes it is usually managed outside the image because the health endpoint depends on the deployment environment.


Generate a complete Dockerfile for your stack with the Dockerfile Generator, multistage and best practices included.

Try it without code

Dockerfile Generator

Multi-stage, non-root, Alpine.

Open Dockerfile Generator

Built by

Miguel Ángel Colorado Marin (MACM)

Full-Stack Developer · Guadalajara, España

I develop web apps, digital tools and full projects — from design to deployment.

Contact me