Multistage builds are one of Docker's most powerful features—they let you compile code in a fat environment and copy only the runtime artifacts to a slim final image. But teams often find that what looks clean on paper turns into a debugging nightmare: images that are still huge, builds that break on CI, or layers that rebuild every time for no reason. This guide covers five specific pitfalls we've seen derail teams, along with concrete fixes and decision frameworks to keep your builds fast and your images lean.
Who Should Choose Multistage—and When
Multistage builds shine when you need to separate build-time dependencies from runtime. Think of compiling a Go binary: you need the full Go toolchain and maybe C libraries during compilation, but the final image only needs the binary and maybe a minimal base like alpine. Without multistage, you'd either ship a bloated image with the toolchain or write complex scripts to clean up after the build. Multistage solves this elegantly with multiple FROM statements in one Dockerfile, each stage building on the previous, and the final stage copying only what's needed.
But not every project benefits equally. If your language doesn't compile to a standalone binary (like Python or Ruby where you need the interpreter at runtime), the savings are smaller. In those cases, multistage might still help by installing build-only packages in an earlier stage and copying only the installed site-packages, but the complexity may not be worth it for small projects. The decision really comes down to three factors: the size of your build dependencies, how often they change, and whether your runtime base can be tiny.
For teams new to multistage, we recommend starting with a single service that has a clear compile step (Go, Rust, Java with a fat jar, or a Node app that needs node_modules pruned). Once you see the size reduction—often 80-90%—you'll have the motivation to refactor other services. But beware: multistage adds a new layer of indirection, and if you don't understand how Docker's layer caching works across stages, you'll hit pitfall number one.
When to Stick with Single-Stage
Single-stage builds are simpler and easier to debug. If your team is small, your CI is fast enough, and image size isn't a bottleneck (e.g., internal tools deployed to a local registry), you don't need multistage. Also, if your runtime image already contains the build tools (like a Python image with pip), multistage adds complexity without much gain. The rule of thumb: if your final image is under 200 MB and you're not pushing it over slow connections, the effort to refactor may not pay off.
Three Common Build Strategies Compared
Once you decide to go multistage, you have several ways to structure your Dockerfiles. We'll compare three approaches teams commonly use: the inline Dockerfile, the separate build image pattern, and external build tools like BuildKit or Docker's buildx.
The inline Dockerfile is the default: you put all stages in one file, each starting with FROM. It's straightforward and easy to version—everything lives in one place. But it can become unwieldy for complex projects, and if you need to share build logic across multiple Dockerfiles, you end up copying the same stages.
The separate build image pattern creates a dedicated "builder" image that contains all the build dependencies, and then your service Dockerfiles just copy from that builder. This works well in monorepos where multiple services share the same build toolchain. You update the builder image once, and all downstream images pick up the changes. The downside: you now have an extra image to maintain and push, and the build order becomes a dependency graph.
External build tools like BuildKit's --frontend or docker buildx bake let you define build stages in a declarative file (like docker-bake.hcl) and share cache mounts across stages. This is the most flexible approach—you can parallelize builds, use remote caching, and even build for multiple architectures. But it's also the most complex, requiring your team to learn new configuration syntax and CI integration.
Which Strategy Fits Your Team?
Start with inline Dockerfiles. They're the easiest to understand and debug. If you find yourself repeating the same build steps across multiple Dockerfiles, extract them into a shared builder image. Only move to external build tools if you need advanced caching or cross-platform builds—most teams don't need that complexity from day one.
How to Compare Build Strategies: Key Criteria
When evaluating which approach to use, consider these five criteria: build speed, cache efficiency, image size, maintainability, and CI compatibility. Build speed matters because slow builds kill developer productivity. Cache efficiency determines whether a small change rebuilds the whole world or just the affected layer. Image size affects deployment time and storage costs. Maintainability covers how easy it is for a new team member to understand and modify the Dockerfile. CI compatibility means the approach works with your existing pipeline without custom plugins.
Let's break down how each strategy scores on these criteria. Inline Dockerfiles score high on maintainability and CI compatibility (everyone knows Dockerfile syntax), but cache efficiency can be tricky because COPY invalidation cascades across stages. Separate build images improve cache efficiency for shared dependencies (you rebuild the builder only when dependencies change), but they add an extra maintenance burden. External build tools offer the best cache efficiency and build speed through parallelization, but they're the hardest to maintain and may require CI changes.
We recommend scoring each criterion on a scale of 1-5 for your specific context. For example, if your team is small and values simplicity, inline Dockerfiles will score higher overall even if they're slightly slower. If you're a large team with a dedicated DevOps person, the complexity of external tools might be acceptable for the performance gains.
Avoiding the "Perfect Setup" Trap
One common mistake is trying to optimize everything from the start. Start with the simplest approach that meets your current needs, then iterate. Many teams spend weeks designing a complex build pipeline that they don't actually need, only to find that a simple inline Dockerfile with a few well-placed cache mounts works perfectly.
Trade-Offs at a Glance: Strategy Comparison Table
| Criterion | Inline Dockerfile | Separate Builder Image | External Build Tools |
|---|---|---|---|
| Build Speed | Moderate (serial stages) | Moderate (builder rebuilds sometimes) | Fast (parallel stages) |
| Cache Efficiency | Low–Moderate (COPY invalidates easily) | High (shared builder caches well) | Very High (cache mounts, remote caching) |
| Image Size | Good (if stages are clean) | Good (same as inline) | Good (same as inline) |
| Maintainability | High (standard Dockerfile) | Medium (extra image to manage) | Low (special config files) |
| CI Compatibility | High (works everywhere) | High (works everywhere) | Medium (requires buildx or plugins) |
This table shows the trade-offs clearly. For most teams, inline Dockerfiles offer the best balance of simplicity and performance. The separate builder image pattern is a good upgrade when you need shared dependencies. External tools are best reserved for large-scale projects where every second of build time counts.
One more nuance: cache efficiency isn't just about speed—it also affects reliability. If your cache is often invalidated, you'll get different results on each build, which can mask bugs. Inline Dockerfiles are more susceptible to this because a change in an early stage invalidates all downstream stages. Using buildkit's --cache-from and careful ordering of COPY commands can mitigate this, but it's something to watch for.
When the Table Doesn't Tell the Whole Story
These trade-offs depend heavily on your specific dependencies. For example, if your build stage installs system packages that rarely change, inline Dockerfiles cache very well. But if you frequently update npm packages, the COPY of package.json will invalidate the npm install layer, and you'll benefit from a separate builder that caches the node_modules tarball. Test your actual build patterns before committing to a strategy.
Implementation Path: From Decision to Working Build
Once you've chosen a strategy, follow these steps to implement it without breaking your existing pipeline. First, write a single multistage Dockerfile for one service. Start simple: a build stage that compiles your code, and a runtime stage that copies the artifact. Test it locally with docker build and docker run to verify the image works. Pay attention to the final image size—if it's still large, check if you're accidentally copying unnecessary files (like the .git directory or node_modules from the build stage).
Second, integrate the new Dockerfile into your CI. Most CI systems handle multistage builds natively—just run docker build as usual. But watch for two issues: CI environments often have limited cache (especially on ephemeral runners), so your first build after a cache miss will be slow. Use Docker's --cache-from to pull a previous image as a cache source if your CI supports it. Also, ensure your CI has enough disk space—multistage builds can temporarily use a lot of space because each stage is stored as an intermediate layer.
Third, optimize your COPY instructions. The most common pitfall is using COPY . . in the build stage, which copies everything (including the kitchen sink) and invalidates the cache whenever any file changes. Instead, copy only what's needed for each step: first COPY package.json and run npm install, then COPY the rest of the source. This way, dependency installation is cached unless the manifest changes. Similarly, in the final stage, use COPY --from=builder /app/dist /app to copy only the built artifacts, not the entire build context.
Common Implementation Mistakes
One mistake we see often is forgetting to clean up temporary files in the build stage. Even though the final stage only copies artifacts, the build stage itself can become large and slow down the build. Use RUN commands with cleanup (e.g., apt-get clean && rm -rf /var/lib/apt/lists/*) to keep intermediate layers small. Another mistake is using the same base image for both stages when you don't need to—for example, using node:18 for both build and runtime when the runtime only needs a minimal base like node:18-alpine. This defeats the purpose of multistage.
Risks When You Choose Wrong or Skip Steps
Choosing the wrong strategy or skipping implementation steps can lead to several problems. The most visible is a bloated final image. If you accidentally copy the entire build stage's filesystem (e.g., using COPY --from=builder / /), your final image will include compilers, headers, and other unnecessary files. This not only wastes space but also increases the attack surface—you're shipping tools that could be exploited in production.
Another risk is cache thrashing. If your Dockerfile is poorly structured, every commit triggers a full rebuild because the cache is invalidated early. This slows down CI and frustrates developers. For example, if you COPY the entire source code before running apt-get install, any source change will re-run the apt-get step, even though the system dependencies haven't changed. The fix is to order your Dockerfile from least-changing to most-changing layers: first install system packages, then copy dependency manifests, then install dependencies, then copy source code.
There's also the risk of build failures due to missing context. Multistage builds rely on the build context being available to all stages. If your CI passes a different context (e.g., only the service directory instead of the repo root), a stage that expects a shared file will fail. Always test your Dockerfile with the exact context your CI uses. A good practice is to use .dockerignore to exclude unnecessary files and ensure the context is consistent.
Finally, there's the human risk: complexity overload. If your Dockerfile has five stages and uses advanced features like buildkit secrets and cache mounts, new team members may struggle to understand it. This leads to fear of modifying the Dockerfile, which means it never gets optimized, and over time it becomes a black box. Keep it simple until you have a clear performance bottleneck.
Real-World Example: A Bloated Image Story
We once worked with a team that had a 1.2 GB image for a simple Go API. The Dockerfile was a single stage that installed Go, built the binary, and then didn't clean up. The fix was trivial: split into two stages, copy only the binary, and use alpine as the runtime base. The image dropped to 12 MB. The team had been living with the bloat for months because they assumed it was normal. That's the real cost of ignoring multistage—not just disk space, but the slow deployments and increased attack surface.
Mini-FAQ: Common Multistage Build Questions
Why does my multistage build still produce a large image?
Check your COPY --from instructions. If you're copying entire directories from the build stage, you might be pulling in unnecessary files. Also, verify that your runtime base image is truly minimal—using ubuntu:latest when alpine works will add hundreds of megabytes. Finally, run docker history on your image to see the size of each layer; the culprit is usually a layer that installs build dependencies and isn't cleaned up.
How do I cache dependencies across CI runs?
Use Docker's --cache-from flag to pull a previous image as a cache source. Many CI systems (GitHub Actions, GitLab CI) support this natively. Alternatively, use BuildKit's cache mounts with --mount=type=cache, which persist across builds on the same runner. Note that cache mounts are not copied to the final image—they only speed up the build. For ephemeral runners, you may need to push intermediate images to a registry and pull them as cache sources.
Should I use the same base image for all stages?
Not necessarily. The build stage often needs a full development environment (e.g., node:18 with build tools), while the runtime stage can use a slim variant (e.g., node:18-alpine). Using different base images is fine as long as the artifacts are compatible—for example, if you compile a Go binary on a glibc-based image, it won't run on a musl-based alpine unless you use CGO_ENABLED=0. Test your binary on the runtime base before committing.
When should I avoid multistage builds altogether?
Avoid multistage if your runtime image already includes the build tools (e.g., a Python image where you need pip at runtime) or if your image size is already acceptable (under 200 MB). Also, if your team is small and values simplicity over optimization, a single-stage build with a good .dockerignore and apt-get cleanup may be sufficient. Multistage adds complexity, so only use it when the benefits clearly outweigh the cost.
How do I debug a failing multistage build?
Start by building with --progress=plain to see each step's output. If a stage fails, you can run a shell in that stage by adding RUN sleep 9999 in the stage and then docker exec into the container. Alternatively, use docker build --target
These fixes and strategies should help your team avoid the most common multistage build pitfalls. Start small, test thoroughly, and iterate based on your actual build metrics. The goal is not the perfect Dockerfile, but one that is reliable, fast enough, and easy for your team to maintain.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!