civil-and-structural-engineering
A Step-by-step Guide to Building Multi-stage Docker Images
Table of Contents
What Are Multi-Stage Docker Builds?
Multi-stage Docker builds are a feature introduced in Docker 17.05 that allows you to use multiple FROM statements within a single Dockerfile. Each FROM instruction begins a new stage, which can have its own base image, dependencies, and commands. Artifacts can be selectively copied from one stage to another, while the final image only retains what is strictly necessary for running the application. This approach eliminates the need to manually chain Dockerfiles or rely on complex build scripts, and it dramatically reduces image size by excluding compilers, development libraries, and intermediate files.
The core idea is to separate the build environment from the runtime environment. In a typical single-stage build, a developer installs all build tools (e.g., compilers, package managers, test frameworks) in the same image that will be used for production. That bloats the image and increases the attack surface. Multi-stage builds solve this by using a thick, feature-rich image for compilation and then copying only the resulting artifacts into a minimal runtime image such as alpine or distroless.
Benefits of Multi-Stage Builds
The advantages of adopting multi-stage builds go beyond simple size reduction. They have a profound impact on security, maintainability, and deployment speed.
1. Reduced Image Size
By discarding build-time dependencies, multi-stage builds often shrink images by 50% to 90%. For example, a Node.js application built using the full node:14 image (over 300MB) can be slimmed down to under 20MB by copying only the built dist folder into an nginx:alpine base. This storage savings directly translates to faster pull times, less network bandwidth, and lower registry costs.
2. Improved Security
Every installed package or tool in a container image is a potential vulnerability. Multi-stage builds allow you to exclude compilers, debuggers, and development libraries from the final image, significantly reducing the attack surface. You can even use images like scratch or gcr.io/distroless for the runtime stage, which contain only the bare minimum to execute the application binary.
3. Streamlined Build Process
All build steps are defined in a single Dockerfile, making the process self-contained and easy to version. CI/CD pipelines benefit from a single entry point: the Dockerfile. There is no need to maintain separate build scripts or manual cleanup steps.
4. Enhanced Reproducibility and Consistency
Because the entire build is captured in one Dockerfile, any developer or system can reproduce the exact same layers. The use of specific version tags for base images further guarantees consistent builds across environments.
Building a Multi-Stage Dockerfile: Step by Step
This walkthrough covers the creation of a production‑ready multi‑stage Dockerfile for a Node.js and React application. The same principles apply to any compiled language.
1. Plan Your Stages
Before writing code, map out the stages you need. A typical multi‑stage build has at least two stages:
- Builder stage – installs all build tools, installs dependencies, and runs the build command.
- Runtime stage – uses a minimal base image, copies only the built artifacts from the builder stage, and defines the runtime behavior.
For complex projects you might add intermediate stages for tests, static analysis, or asset compression.
2. Write the Builder Stage
Start with a base image that includes the required toolchain. Use named stages with AS to reference them later. For Node.js:
FROM node:14-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
Key points:
- Use
npm cifor deterministic, faster dependency installation. - Keep
RUNcommands as separate layers when possible to leverage caching. - Install build tools (like TypeScript, webpack) in this stage only.
3. Add an Intermediate Test Stage (Optional)
To enforce code quality, add a stage that runs tests. This stage can reuse the builder image or install additional tools. Because it is not the final stage, test failures will not be present in the final image.
FROM builder AS test
RUN npm run test
You can run this stage in your CI pipeline with docker build --target test -t myapp:test . to catch failures early without building the entire final image.
4. Define the Runtime Stage
For a React application, the runtime image can be an Nginx server. For a backend API, it might be a distroless base image or a minimal Alpine with the Node.js runtime. Copy only the essential artifacts using --from=builder.
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
If you need the Node.js runtime, avoid copying node_modules from the builder; instead reinstall production dependencies in the runtime stage:
FROM node:14-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
RUN npm ci --only=production
EXPOSE 3000
CMD ["node", "dist/server.js"]
5. Build and Test the Image
Build the final image using the standard command:
docker build -t myapp:latest .
To verify the size, run docker images and compare against a single‑stage build. Run the container and confirm the application responds correctly:
docker run -d -p 8080:80 myapp:latest
curl http://localhost:8080
Best Practices for Multi-Stage Builds
- Use specific base image tags – avoid
latestto prevent surprises. Prefernode:14-alpineorgolang:1.20-buster. - Optimize layer caching – copy
package.jsonandpackage-lock.jsonbefore the rest of the source code so that thenpm installlayer is only invalidated when dependencies change. - Leverage buildKit – enable BuildKit with
DOCKER_BUILDKIT=1for faster builds, inline caching, and better parallelism. - Create multiple final stages for different environments – for example, a development stage with debugging tools and a production stage with a hardened base image.
- Use
--targetfor development builds – in development you can stop at thebuilderstage to get live reload and source maps, then rebuild with--target runtimefor production. - Keep secrets out of images – use Docker BuildKit’s
--secretflag if you need to pass credentials during build; never include them in the final image.
Common Patterns and Use Cases
Compiled Languages (Go, Rust, C++)
For static binaries, the runtime stage can use scratch (empty base image). Only the binary and maybe a configuration file are copied. Example for Go:
FROM golang:1.20-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .
FROM scratch
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
Python Applications
Use a builder stage with python:3.11-slim to install dependencies and compile any C extensions, then copy only the installed packages to a runtime stage:
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
COPY . .
FROM python:3.11-slim
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app /app
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
Frontend with API Server
Build both frontend and backend in one Dockerfile. Use separate builder stages for each, then copy both artifacts into a single runtime image:
FROM node:14-alpine AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM node:14-alpine AS api-builder
WORKDIR /app
COPY api/package*.json ./
RUN npm ci
COPY api/ .
RUN npm run build
FROM node:14-alpine
WORKDIR /app
COPY --from=frontend-builder /app/build ./public
COPY --from=api-builder /app/dist ./dist
RUN npm ci --only=production
EXPOSE 3000
CMD ["node", "dist/server.js"]
Troubleshooting Multi-Stage Builds
- Layer caching not working – ensure
COPYcommands order dependencies before source code. Use.dockerignoreto exclude unnecessary files. - Artifact not found – verify the paths in the
COPY --fromstatement. The builder stage must produce output in the specified location. UseRUN ls /app/buildto debug. - Secret leakage – never copy entire directories that might contain
.envorcredentials.json. Explicitly copy only needed files. - Large final images despite multi-stage – check if you are accidentally copying
node_modulesor the entire source. Usedocker historyto see layer sizes.
Conclusion
Multi-stage Docker builds are a cornerstone of modern containerization. They enable you to ship lean, secure images while keeping the build process simple and documented in a single Dockerfile. By separating concerns between build and runtime, you can drastically reduce image size, improve security, and streamline CI/CD pipelines. The techniques shown here apply to nearly any stack—whether you are building Node.js, Go, Python, Java, or frontend applications. Start by converting your existing Dockerfiles to multi-stage builds, and you will immediately see improvements in build speed, deployment efficiency, and overall infrastructure costs.
For more details, refer to the official Docker multi-stage build documentation and the Dockerfile best practices guide. Real‑world examples are also available in the Docker Library documentation.