Multi-stage Builds

Docker & DevOps · Lesson 12 of 26 7 min read Updated

Building an app often needs much more than running it. A React front end needs Node.js, npm and hundreds of packages to build, but the result is a folder of plain HTML, CSS and JavaScript that any web server can send. A Go program needs the Go compiler to build, but the result is a single file. If your image keeps the build tools, it is several times bigger than it should be, and every extra program in it is something an attacker could use.

Multi-stage builds solve this. One Dockerfile has several FROM lines. Early stages build the app with all the tools they need; the final stage starts fresh from a small image and copies in only the finished result.

The problem: one fat image

Kabir builds a Vite front end for a bus timetable. His first Dockerfile builds and serves the app in one stage:

Dockerfile
FROM node:26-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm install -g serve
CMD ["serve", "-s", "dist", "-l", "8080"]

The image contains Node.js, all development packages, the source code and the build output, even though only the dist folder is needed to serve the site.

The fix: build in one stage, run in another

Dockerfile
# syntax=docker/dockerfile:1

# Stage 1: build the static site
FROM node:26-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: serve it with Nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

How it works:

  • AS build gives the first stage a name.
  • The second FROM starts a new, empty stage from nginx:alpine. Nothing from the first stage comes along automatically.
  • COPY --from=build copies files out of the named stage. Only /app/dist enters the final image.
  • The last stage in the file is the image that gets tagged. The build stage is used during the build and then left out.

Compare the two images:

Terminal
docker image ls timetable-web
Output
IMAGE                ID             DISK USAGE   CONTENT SIZE   EXTRA
timetable-web:fat    3c7d9a2e1f40        512MB          128MB
timetable-web:slim   b81e4f0c2d97       76.1MB         22.1MB

The multi-stage image is about one-sixth of the size. It also has no Node.js, no npm and no source code, so there is far less to patch and far less for an attacker to use.

A TypeScript API with three stages

Server apps often need three stages: one for production dependencies, one for building, and a small final one for running.

Dockerfile
# syntax=docker/dockerfile:1
FROM node:26-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./

FROM base AS deps
RUN npm ci --omit=dev

FROM base AS build
RUN npm ci
COPY . .
RUN npm run build

FROM node:26-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/server.js"]
  • base holds what both later stages share. A stage can start FROM another stage.
  • deps installs only production packages.
  • build installs everything, including the TypeScript compiler, and compiles src into dist.
  • runtime takes production packages from deps and compiled JavaScript from build. The compiler and dev packages never reach it.

BuildKit sees that deps and build do not depend on each other and runs them at the same time, which makes the build faster too.

Go: from compiler to a tiny image

Compiled languages benefit the most. A Go program can be built as one static file that needs nothing else:

Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/fare-api .

FROM scratch
COPY --from=build /out/fare-api /fare-api
EXPOSE 8080
ENTRYPOINT ["/fare-api"]

scratch is a special, completely empty base image: no shell, no tools, no files. The final image contains just your program and is usually under 20 MB, while the golang build image is several hundred megabytes. CGO_ENABLED=0 makes Go build a binary that does not need system C libraries. One catch: an empty image has no CA certificates, so a program that calls HTTPS APIs needs them copied in, or a base such as alpine instead of scratch.

PHP: Composer in its own stage

You can copy from any image, not only from earlier stages. The official composer image is a convenient build stage for PHP apps:

Dockerfile
# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist --no-scripts

FROM php:8.5-apache
RUN docker-php-ext-install pdo_mysql
COPY --from=vendor /app/vendor /var/www/html/vendor
COPY . /var/www/html/

Composer and its cache stay in the first stage. The final image has PHP, Apache, the pdo_mysql extension and your code with its production packages. docker-php-ext-install is a helper script that the official PHP images provide for adding extensions. List vendor in .dockerignore, so the final COPY . does not overwrite the clean vendor folder with the one from your laptop.

Building a specific stage

--target stops the build at a named stage. This is useful for running tests in CI with the full tool chain, or for a development image:

Terminal
docker build --target build -t timetable-web:build .
docker run --rm timetable-web:build npm test

Common mistakes

  • Forgetting that a new FROM starts empty. Environment variables, files and the WORKDIR from earlier stages do not carry over. Copy or set what the final stage needs.
  • Copying the whole build stage. COPY --from=build /app /app brings the source code and dev packages back. Copy only the output folder.
  • Using scratch for a program that needs system libraries. A binary built with C libraries (CGO) crashes with "no such file or directory". Build it statically or use alpine or a -slim base.
  • Putting the build stage last. The last stage is the one that gets tagged, so it should be the small runtime stage.

Key points

  • A multi-stage Dockerfile has several FROM lines; name stages with AS.
  • COPY --from=stage copies only selected files from an earlier stage (or from another image) into the final one.
  • The last stage becomes the image; earlier stages and their tools are left out.
  • Independent stages build in parallel, and --target builds one chosen stage.
  • Front ends end on nginx, compiled languages on scratch or alpine, and PHP apps can take vendor from a Composer stage.

Exercise

Farhan has a Python app whose dependencies need gcc to compile, but the app itself only needs the compiled packages. Write a two-stage Dockerfile: the first stage, based on python:3.15, installs the packages from requirements.txt into a virtual environment at /venv; the second stage, based on python:3.15-slim, copies /venv and the code and runs app.py with the virtual environment's Python.

Show solution
Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.15 AS build
RUN python -m venv /venv
COPY requirements.txt .
RUN /venv/bin/pip install --no-cache-dir -r requirements.txt

FROM python:3.15-slim
COPY --from=build /venv /venv
WORKDIR /app
COPY . .
ENV PATH="/venv/bin:$PATH"
CMD ["python", "app.py"]

The full python:3.15 image includes gcc for compiling packages. Both images use the same Python version and install path, so the virtual environment works when copied. Putting /venv/bin first on PATH makes python mean the virtual environment's Python.

Quick Quiz

1. In a multi-stage Dockerfile, which stage becomes the final image?

2. What does COPY --from=build /app/dist /usr/share/nginx/html do?

3. What is the scratch base image?

4. Why can BuildKit build the deps and build stages at the same time?

Learn Docker & DevOps with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call