Dockerfile Best Practices

intermediate docker dockerfile multi-stage optimization

A Dockerfile is just a text file with instructions to build an image. Every line creates a layer. The order we write these instructions matters a LOT for build speed and image size.

The basic instructions

Here’s what the most common instructions do:

  • FROM — sets the base image (every Dockerfile starts here)
  • WORKDIR — sets the working directory inside the container
  • COPY — copies files from our machine into the image
  • RUN — executes a command during build (install packages, compile code)
  • EXPOSE — documents which port the app listens on (doesn’t actually publish it)
  • ENV — sets environment variables
  • CMD — the default command when the container starts
  • ENTRYPOINT — like CMD, but harder to override
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

CMD vs ENTRYPOINT

This trips people up. Both define what runs when the container starts, but they behave differently.

CMD — provides a default command that can be easily overridden by passing arguments to docker run.

ENTRYPOINT — sets the main executable. Arguments passed to docker run get appended to it.

In simple language, think of ENTRYPOINT as the verb and CMD as the default arguments. We usually combine them when we want a fixed command but flexible arguments.

# CMD only — can be fully overridden
CMD ["node", "server.js"]
# docker run my-app                  → node server.js
# docker run my-app node other.js    → node other.js (overridden)

# ENTRYPOINT + CMD — fixed command, default args
ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run my-app                  → node server.js
# docker run my-app other.js         → node other.js (args appended)

Layer caching — order matters

Docker caches layers from top to bottom. The moment a layer changes, everything below it is rebuilt. So we want to put things that change frequently at the bottom.

This is why we copy package.json first and run npm install before copying our source code. Dependencies don’t change often, but our code does. This way, npm install is cached on most builds.

# Good — dependencies cached separately from source code
COPY package*.json ./
RUN npm ci
COPY . .

# Bad — any code change invalidates npm install cache
COPY . .
RUN npm ci

Multi-stage builds

Multi-stage builds let us use one image for building and a different (smaller) one for running. The build artifacts are copied between stages, but build tools don’t end up in the final image.

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production (much smaller image)
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

The final image only has nginx and our built files. Node.js, npm, and all dev dependencies are left behind in the builder stage.

Reducing image size

Every megabyte matters. Smaller images mean faster pulls, faster deploys, and less attack surface.

# Use alpine base images (5MB vs 900MB+)
FROM node:20-alpine

# Combine RUN commands to reduce layers
RUN apk add --no-cache git curl && \
    rm -rf /var/cache/apk/*

# Use .dockerignore to exclude junk from COPY
# .dockerignore file:
# node_modules
# .git
# *.md
# .env

Security basics

Running as root inside a container is a bad idea. If an attacker breaks out, they’re root on the host too.

FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .

# Switch to non-root user
USER node

CMD ["node", "server.js"]

Other security rules:

  • Never put secrets in the Dockerfile — no ENV API_KEY=abc123. Use runtime env vars or secret managers instead.
  • Use specific image tagsnode:20.11.1-alpine not node:latest.
  • Scan imagesdocker scout cves my-image to find vulnerabilities.