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*.json → RUN npm ci → COPY . . 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.