Docker: Writing Better Dockerfiles and Multi-Stage Builds
Edited by Dev Kilaru, Senior Data Analyst. How we make our content
Introduction
A Dockerfile that works is not necessarily a good one. Poor ordering makes builds slow, careless copying makes images huge, and running as root makes them risky. This lesson applies the practices from Docker's own guidance: caching, a .dockerignore file, multi-stage builds, and safe runtime defaults.
Prerequisites
- Docker Container Basics lesson
- Docker installed
- A small Node.js project to practice on
Step-by-Step Guide
Step 1: Order instructions for layer caching
Each instruction creates a layer, and when a layer changes every later layer is rebuilt. Copy the files that change rarely, such as dependency manifests, and install dependencies first. Copy your frequently changing source code last, so code edits do not re-download dependencies.
FROM node:22-alpine
WORKDIR /app
# 1. dependencies first (changes rarely)
COPY package.json package-lock.json ./
RUN npm ci
# 2. source code last (changes often)
COPY . .
CMD ["node", "server.js"]Step 2: Keep the build context small with .dockerignore
Docker sends the build context to the builder, so excluding unneeded files speeds builds and keeps secrets out of images. A .dockerignore file at the root of the context lists patterns, one per line, with ! for exceptions. Always exclude version control data and local environment files.
# .dockerignore
node_modules
.git
.env
*.md
!README.mdStep 3: Use a multi-stage build
A multi-stage build uses one stage with all the build tools, then copies only the finished output into a small final image. The final image never contains compilers, source code, or dev dependencies. Name stages with AS and copy between them with COPY --from.
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80Step 4: Build, tag, and stop at a stage
Tag every image so you know exactly what you built, and use --target to build only up to a given stage, which is handy for debugging. Add --pull to refetch the latest base image so security fixes are included.
docker build -t myapp:1.0 .
docker build --target build -t myapp:build .
docker build --pull -t myapp:1.0 .
docker image lsStep 5: Run as a non-root user with a health check
Create or use an unprivileged user and switch to it with USER, so a compromise of the app does not give root inside the container. Pin your base image to a specific version rather than a moving tag, and add a HEALTHCHECK so Docker can tell whether the app is actually responding. Use the exec form of CMD so signals reach your process.
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "server.js"]FAQs
- Why not use the latest tag for base images?
- The latest tag moves, so builds are not repeatable and a new base release can break your image unexpectedly. Pin a specific version, and update it deliberately, for example with Dependabot.
- Does a multi-stage build make the build slower?
- Not noticeably, and BuildKit builds only the stages the target needs. The result is a much smaller image that downloads and starts faster and has a smaller attack surface.
Summary
Order Dockerfile steps for caching, ignore unneeded files, build in one stage and ship in another, and run as a non-root user with a health check. These habits give smaller, faster, safer images.
Comments
Log in or create a free account to join the discussion.