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) beforeCMD.
Discussion
No comments yet. Be the first.