Contents

How a Docker image is structured

An image is a stack of layers. Each instruction in the Dockerfile creates a new layer. Layers are cached: if a file hasn't changed, Docker doesn't rebuild that layer. (New to Docker? Start with the container basics guide.)

This is critical for build speed. Wrong instruction order — and the cache gets invalidated on every code change.

All the main Dockerfile instructions

# FROM — base image. Always the first instruction
FROM node:22-alpine

# WORKDIR — working directory inside the container
# Better to set it explicitly than work in root
WORKDIR /app

# COPY — copy files from host into the container
COPY package*.json ./

# RUN — execute a command at build time
RUN npm ci --only=production

# COPY the rest of the code (after installing dependencies!)
COPY . .

# ENV — environment variables
ENV NODE_ENV=production
ENV PORT=3000

# EXPOSE — documents the port (doesn't open it by itself!)
EXPOSE 3000

# USER — run as a non-privileged user
USER node

# CMD — default command when starting the container
CMD ["node", "index.js"]

# ENTRYPOINT — fixed entry point (not overridden at run time)
# ENTRYPOINT ["node"]

The right order for caching

Main rule: what changes less often goes higher, what changes more often goes lower. In CI this rule pays off the most — see BuildKit cache in GitHub Actions.

# ❌ Bad — any code change reinstalls dependencies
FROM node:22-alpine
WORKDIR /app
COPY . .              # Copy everything at once
RUN npm install       # This layer is invalidated by ANY file change
CMD ["node", "index.js"]
# ✅ Good — dependencies cached separately from code
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./   # Only package.json — rarely changes
RUN npm ci              # This layer is cached until package.json changes
COPY . .                # Code changes often, but dependencies are already cached
CMD ["node", "index.js"]

Multi-stage builds — shrinking the image

A classic problem: you need dev tools (compilers, tests) for the build, but in production you don't. Multi-stage solves this:

# Stage 1: build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                   # Install ALL dependencies including devDependencies
COPY . .
RUN npm run build            # Build the project

# Stage 2: production image
FROM node:22-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production  # Production dependencies only
COPY --from=builder /app/dist ./dist  # Take only built files from stage 1

USER node
CMD ["node", "dist/index.js"]

💡 A multi-stage image can be 5-10x smaller. A Node.js project: 1.2 GB → 120 MB.

Image size optimization

Use alpine images — they're minimal:

# ❌ 1.1 GB
FROM node:22

# ✅ 180 MB
FROM node:22-alpine

Clean the package manager cache in the same RUN instruction:

# ❌ Cache stays in the layer
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# ✅ All in one layer, cache removed
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

Don't copy junk — use .dockerignore:

node_modules
.git
.env
*.log
dist
coverage

Useful commands during development

# Build an image with a tag
docker build -t myapp:1.0 .

# Inspect layers and their sizes
docker history myapp:1.0

# Check image size
docker images myapp

# Run and shell in for debugging
docker run -it myapp:1.0 sh

# Run with code mounted (for development)
docker run -p 3000:3000 -v $(pwd):/app myapp:1.0

⚠️ Never run a container as root in production. Always add USER node (or another non-privileged user) before CMD.

Share: