</>StackKit
</>StackKit

Developer tutorials & guides

Dockerfile best practices for smaller, secure production images

A practical guide to dockerfile best practices for smaller, secure production images.

N

Nitheesh DR

Founder & Full-Stack Engineer

4 min read666 words
#docker#tutorial#guide

Dockerfile Best Practices for Smaller, Secure Production Images

Your CI pipeline takes 6 minutes to push a Docker image because it's 1.8GB, and half of that is apt package caches and dev headers nobody needs at runtime. Then a security scan flags 40 CVEs, most of them in packages your app never touches. Both problems trace back to the same root cause: treating the Dockerfile like a shell script instead of a build spec.

Order matters for cache efficiency

Docker caches each layer and invalidates everything after the first changed layer. Copying your entire source tree before installing dependencies means every code change reinstalls every dependency:

# Bad: any source change invalidates the dependency install layer
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
# Good: dependencies only reinstall when package files change
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

That reordering alone can cut a typical rebuild from minutes to seconds when only application code changed.

Multi-stage builds to cut the fat

Build tools, dev dependencies, and compilers don't belong in your final image:

FROM node:20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
CMD ["node", "dist/server.js"]

The builder stage has the full toolchain; the final stage only has what's needed to run. This routinely takes images from 1GB+ down to 150-300MB for a typical Node service.

Pin your base image, don't chase latest

# Bad — breaks reproducibility, "latest" changes under you
FROM node:latest

# Good — pin to a specific digest for full reproducibility
FROM node:20.11.1-slim@sha256:9f6b...

Pinning by digest (not just tag) means the exact same bytes get pulled every time, everywhere — no surprise upgrades breaking prod on a Friday because latest moved.

Run as a non-root user

Most official images run as root by default, which is a real risk if a container is ever compromised — root inside the container often maps to more privilege than you'd want:

FROM node:20-slim
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["node", "server.js"]

Use .dockerignore

Without one, COPY . . drags .git, node_modules, local .env files, and test fixtures into your build context, slowing builds and occasionally leaking secrets into image layers:

node_modules
.git
.env*
*.md
dist
coverage

Common mistakes

  • Running apt-get update && apt-get install in a separate RUN from cleanup — the layer still contains the full apt cache even if you rm -rf it in a later layer. Combine install and cleanup in one RUN.
  • Copying the whole repo before installing dependencies (breaks caching, covered above).
  • Using ADD when COPY would do — ADD has surprising behavior with remote URLs and archive auto-extraction that's rarely what you actually want.
  • Leaving build secrets (npm tokens, API keys) as ENV or ARG values baked into layers, where anyone with image access can extract them with docker history. Use --secret mounts (BuildKit) instead.

What I'd actually use

-slim or -alpine variants of official images as your base, multi-stage builds by default (even for simple apps — the discipline pays off), and BuildKit's --mount=type=cache for package manager caches so repeated builds stay fast without bloating the final image. Skip Alpine specifically for anything using native Node addons or glibc-dependent binaries — the musl libc mismatch causes just enough weird bugs that -slim (Debian-based) is often the better trade for those cases.

Next steps

Run docker history <your-image> on your current production image and look at layer sizes — the biggest layer is usually your best optimization target. Then add a multi-stage build if you don't already have one; it's the single highest-leverage change most Dockerfiles are missing.

Tagged

#docker#tutorial#guide
N

Written by

Nitheesh DR

Founder & Full-Stack Engineer

Nitheesh is a full-stack software engineer based in Tamil Nadu, India, with hands-on experience building production SaaS applications using Next.js, TypeScript, React, Node.js, and cloud infrastructure. He founded StackKit to share the practical knowledge he uses every day — not just theory, but the real-world techniques that help developers ship better software faster.