Multi-stage Builds
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:
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
# 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 80How it works:
AS buildgives the first stage a name.- The second
FROMstarts a new, empty stage fromnginx:alpine. Nothing from the first stage comes along automatically. COPY --from=buildcopies files out of the named stage. Only/app/distenters the final image.- The last stage in the file is the image that gets tagged. The
buildstage is used during the build and then left out.
Compare the two images:
docker image ls timetable-webIMAGE 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.
# 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"]baseholds what both later stages share. A stage can startFROManother stage.depsinstalls only production packages.buildinstalls everything, including the TypeScript compiler, and compilessrcintodist.runtimetakes production packages fromdepsand compiled JavaScript frombuild. 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:
# 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:
# 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:
docker build --target build -t timetable-web:build .
docker run --rm timetable-web:build npm testCommon mistakes
- Forgetting that a new
FROMstarts empty. Environment variables, files and theWORKDIRfrom earlier stages do not carry over. Copy or set what the final stage needs. - Copying the whole build stage.
COPY --from=build /app /appbrings the source code and dev packages back. Copy only the output folder. - Using
scratchfor a program that needs system libraries. A binary built with C libraries (CGO) crashes with "no such file or directory". Build it statically or usealpineor a-slimbase. - 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
FROMlines; name stages withAS. COPY --from=stagecopies 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
--targetbuilds one chosen stage. - Front ends end on
nginx, compiled languages onscratchoralpine, and PHP apps can takevendorfrom 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
# 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?
Earlier stages are only used during the build.
2. What does COPY --from=build /app/dist /usr/share/nginx/html do?
--from reads files from another stage or image instead of the build context.
3. What is the scratch base image?
It suits static binaries that need nothing else.
4. Why can BuildKit build the deps and build stages at the same time?
BuildKit works out the dependency graph between stages and runs independent ones together.
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
