Why Image Size and Build Speed Matter
A 2 GB image takes minutes to pull on a slow connection and costs money in registry storage. A build that takes 10 minutes on every CI run is a developer productivity killer. Good Dockerfile practices can reduce both — often dramatically.
Layer Caching: The Right Order of Instructions
Docker caches each layer. When a layer changes, all layers after it are rebuilt. This makes instruction order critical.
Bad — cache invalidated on every code change:
FROM node:20-alpine
WORKDIR /app
COPY . . # Copies everything — any code change busts cache here
RUN npm ci # Re-runs full install every time
CMD ["node", "server.js"]
Good — dependencies cached separately from source code:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # Only these files affect npm ci
RUN npm ci # Cached unless package files change
COPY . . # Source changes don't bust the npm cache
CMD ["node", "server.js"]
General rule: Put instructions that change rarely (base image, system packages, dependency installs) before instructions that change frequently (source code copy).
Multi-Stage Builds
Multi-stage builds use multiple FROM instructions in one Dockerfile. Only the final stage is included in the image — intermediate stages are discarded.
Node.js example — TypeScript compiled in a build stage:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # Compiles TypeScript to dist/
# Stage 2: Production image (no dev dependencies, no source)
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # Install only production dependencies
COPY --from=builder /app/dist ./dist # Copy only compiled output
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
The production image contains only the compiled JavaScript and production dependencies — no TypeScript compiler, no source files, no dev tools. This can reduce image size by 60–80%.
Python example — compiled dependencies dropped:
FROM python:3.12-slim AS builder
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.12-slim AS production
COPY --from=builder /root/.local /root/.local
COPY . .
CMD ["python", "app.py"]
Choosing Base Images
| Base Image | Size (approx.) | Use When |
|---|---|---|
node:20 | ~1 GB | Rarely — too large |
node:20-slim | ~230 MB | General purpose |
node:20-alpine | ~55 MB | Production images, size matters |
gcr.io/distroless/nodejs20 | ~120 MB | Maximum security, no shell |
Alpine is based on musl libc instead of glibc. Most Node.js packages work fine. A small number of native addons (like bcrypt or sharp) may need compilation flags — check your dependencies first.
Distroless images contain only the runtime and your app — no shell, no package manager, no /bin/sh. Extremely small attack surface, but you can’t exec into them for debugging.
.dockerignore — What to Exclude
Just like .gitignore, .dockerignore prevents files from being sent to the Docker build context. Excluding node_modules can speed up builds by seconds:
# .dockerignore
node_modules/
.git/
.gitignore
Dockerfile*
docker-compose*.yml
*.log
*.md
.env*
coverage/
dist/
.DS_Store
Why it matters: Without .dockerignore, every COPY . . instruction sends everything to the Docker daemon first — even node_modules with its 200,000 files.
Security: Running as Non-Root
By default, processes in containers run as root. If an attacker exploits your app, they have root access inside the container. Run as a non-privileged user instead:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . . # Set correct ownership
USER node # Switch to the built-in node user
EXPOSE 3000
CMD ["node", "server.js"]
The node:20-alpine image includes a built-in node user (UID 1000). For other base images, create one:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
HEALTHCHECK Instruction
A health check lets Docker know whether your container is functioning, not just running:
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
Parameters:
--interval— How often to run the check (default: 30s)--timeout— How long to wait for a response before marking as failed--start-period— Grace period after startup before failures count--retries— How many consecutive failures before marking unhealthy
Health checks integrate with docker compose’s condition: service_healthy and orchestrators like Kubernetes (though Kubernetes uses its own liveness/readiness probes).
Optimize For Rebuild Speed First
Small images matter, but rebuild speed is what developers feel every day. Put dependency manifests before source code in the Dockerfile so dependency installation is cached until the lockfile changes. Copy frequently changing files later.
For Node projects, that usually means copying package.json and the lockfile, installing dependencies, then copying the rest of the app. For Python, copy dependency files first, install, then copy application code. This pattern keeps tiny code edits from reinstalling the world.
Keep Build Tools Out Of Runtime Images
Compilers, package managers, and test tools often belong in the build stage, not the runtime stage. Multi-stage builds let you compile or bundle in one image and copy only the output into a smaller final image.
This reduces image size and attack surface. It also makes runtime behavior clearer because the final image contains only what the app needs to start.
Scan And Pin
Pin base image versions so rebuilds are predictable. node:22-alpine is more predictable than node:latest, but a digest is even stricter when reproducibility matters. Balance strictness with your update process: pinned images still need security updates.
Run image scanning in CI if the project is deployed beyond local development. A scan is not a substitute for patching, but it makes dependency risk visible before deployment.
What I Would Do In Practice
I would optimize Dockerfiles in this order: correct behavior, cache-friendly layers, smaller runtime image, non-root user, then health checks and scanning. Premature micro-optimization is not worth it if the image is hard to understand.
The best Dockerfile is boring: predictable layers, explicit versions, no secrets, no unnecessary files, and a final image that does exactly one job.
Optimization Mistakes To Avoid
Do not shrink an image by making it impossible to debug. Extremely minimal images are useful in production, but local development images can include a few diagnostic tools if they save time. Use separate targets or stages when development and runtime needs differ.
Do not copy the whole repository before installing dependencies unless you are comfortable invalidating the cache on every code edit. Dependency manifests should usually be copied first, then installed, then application source should be copied later.
Finally, do not confuse a smaller image with a safer image. Size helps, but security also depends on patched dependencies, non-root execution, limited secrets exposure, and a clear update process.