Multi-Stage Dockerfile: where the image bloats and slows down deployment
A typical build runs in one stage: base image, installing compiler, package manager, header files and utilities for native modules, copying sources and dependency cache. All this remains in the final layer, even if at the end of the Dockerfile there is cleanup.
Deleting a file in the next layer doesn't reduce image size: the layer is immutable, and rm only writes a label on top, while the bytes themselves continue to lie in the parent layer.
Two consequences follow. Image size grows several times relative to runtime: compilers, debug symbols, test libraries and service utilities are extra dependencies in runtime that don't participate in application operation but are pulled on every node.
Build time grows for another reason: a change in code above dependency installation resets cache, and packages are installed anew on every run.
Deployment slows not because of code, but because of transport: each node pulls the image entirely, the container takes longer to become ready, autoscaling under load lags.
Additionally, the attack surface expands — compiler, debugger and package manager in runtime give a ready set of tools to whoever gained access inside the container.
Multi-stage Dockerfile separates these roles: one stage builds the artifact, the second gets only it and a minimal launch environment. The final layer includes neither build tools nor cache — we analyze how this works and what it gives in practice.
What splitting the build into stages gives: time, layers, rollbacks
Splitting the build into stages (multi-stage Dockerfile) removes from the final image everything needed only for compilation: compilers, header files, package managers and intermediate artifacts. Only what's needed to run remains: the built application and its runtime libraries.
In practice 60–90% of the contents leave the image — that's dozens of packages that simply shouldn't be in a working container.
Fewer packages — smaller attack surface: build dependencies stop getting into the working container along with their vulnerabilities.
Smaller size — faster deployment: a node pulls the image from the registry in seconds, not minutes, and a service restart fits into the update window.
Reproducibility rests on two things: the base image is pinned by digest, and the order of instructions is arranged so that rarely changing layers come first — dependencies, then code.
Docker reuses the layer cache until the input changes: editing one file rebuilds one layer, not the entire image. A typical rebuild after a commit takes seconds versus minutes for a single-stage build.
For rollbacks this gives predictability: the previous image remains in the registry with the same tag, rollback is a tag switch and restart, without rebuilding.
The failure class "deployed a different source state than was tested" goes away: the verified artifact is the artifact in prod.
Comparison of approaches: regular Dockerfile and multi-stage build
The choice between multi-stage Dockerfile and single-stage build is not about "right and wrong", but about what exactly you optimize: iteration speed, image size, or reproducibility.
Single-stage build is what almost all projects on Docker start with, and below you can see at which step it stops being beneficial.
| Option | When suitable | What limits it |
|---|---|---|
| Single-stage build | Small service, dependencies from ready packages | Compilers and package manager cache go into the final image: size grows, and attack surface with it |
| Multi-stage Dockerfile | Compiled stack, image size and deployment speed matter | Artifacts must be explicitly transferred between stages, and dependency cache must be moved into a separate layer |
| Separate build image | Multiple services with a shared toolchain, isolated environment | Build images must be versioned and updated — that's a separate regular process |
| Build on the build agent side | Local edits, fast development cycle | The result depends on the agent environment, build reproducibility is lower |
The key effect of multi-stage is working with dependency cache. The layer with package installation is placed before copying sources, so code edits don't reset it: a repeat build takes seconds, not from scratch.
Only artifacts and runtime go into the final image — service utilities and sources remain in the intermediate stage.
Limitations are real too. If the base runtime pulls extra libraries, multi-stage won't save size — you need a different base image.
A separate build image is justified when there are many builds and their toolchain is shared: then compiler update is done in one place, not in every file.
Work order: from layer audit to handover of procedures
Before changing the build, we analyze what the current image consists of: which layers give the main volume, what pulls extra dependencies, which files get into the artifact by oversight.
An image audit shows where multi-stage Dockerfile will give a real gain and where the bottleneck lies in instruction order or caching.
- Image audit — capture layer composition, find the heaviest ones, mark packages and files not needed at runtime: package manager caches, compilers, test data, service directories.
- Build stage scheme — separate build and runtime, decide what is transferred to the final layer: built artifact, configurations, certificates, and what remains only at the compilation stage.
- Build migration — rewrite instructions into a new file, pin base images on the current branch, set a non-privileged user and working directory.
- Reproducibility check — build twice on an empty cache and compare the result: the build must not depend on local files, network order, or build machine state.
- Before/after comparison — compare image size, build time, number of layers, and container startup time, make sure the service passes its health checks.
- Build procedure handover — hand over the file, stage scheme, base image update rules, and rollback procedure to the previous tag.
What the client gets: configurations, layer schemes, procedures
The result of work on multi-stage Dockerfile is not one file, but a set of artifacts by which the team builds and updates images without our participation.
Everything goes into your repository, is checked on your stand, and is accompanied by a breakdown: what changed and why exactly so.
The set closes the cycle from build context to rollback: we record which layers are cached and where the image starts to grow.
- Dockerfile with explicit stage separation — dependency build, intermediate processing, final image; with comments on what goes into each layer.
- Build stage scheme: what is copied between stages and which files don't make it to the final image.
- Ignore file and build context rules — unnecessary stuff isn't sent to the build from the very beginning.
- Base image update procedure: frequency, responsible person, compatibility check, and rollback procedure.
- Team instruction: how to add a dependency without bloating the final layer, and how to build the image locally.
- Check procedure: size comparison before and after, layer composition breakdown, startup and health check.
- Configured build pipeline with layer caching and tag publication.
- Breakdown recording with the team: where bottlenecks are, what is left intentionally, and what headroom for growth.
Describe the task — we'll come back with an estimate and plan and show which artifacts your environment needs and what is already covered by your processes.
Case: how image rework reduced deployment time
Before the rework, the service was built with one Dockerfile: a base image with a full set of build tools, on top — sources, compilers, and package manager caches.
The image weighed about 1.2 GB, each node pulled it entirely during deployment, and editing one line in dependencies reset the cache and started the build from scratch.
What was changed
We split the process into stages. First — build: installs dependencies, compiles, runs tests. Second — runtime: a thin base, where only the result is copied via COPY --from.
Added .dockerignore, pinned the base image tag and running as a non-privileged user — stages are built on the current Docker branch, this is enough for a predictable layer cache.
What became
Build shrank from eleven minutes to four: the dependency layer is cached separately and isn't rebuilt when code changes.
Image size dropped from 1.2 GB to 190 MB — a node pulls it in seconds, so deployment stopped being a waiting window, and rollback to the previous version became a regular operation.
Stability grew not because of speed, but because of image composition: runtime no longer includes compilers, header files, and package cache, where "floating" dependencies lived.
The share of deployments that had to be rolled back manually fell from 18% to zero over a quarter, and registry size stopped growing linearly with every release.
What to do if the base image is already used in prod
You can migrate a service to multi-stage Dockerfile without stopping work: the new scheme is built in parallel with the current one, from the same repository and by the same build trigger, only the result gets a separate tag.
Prod continues running on the old image while the new one passes checks on the stand and metric comparison.
The first step in multi-stage Dockerfile is to move dependency installation and build into a separate stage, leaving only artifacts and runtime libraries in the final one.
We don't change the base image at this step and pin it by digest so the result matches prod: then rollback is returning the previous tag.
Gradual rollout goes through the test environment and canary deployment to part of the instances. We compare not only image size, but also startup time, memory consumption, and certificate handling — the new tag becomes the main branch only when metrics match.
Updating the base image is a separate, later step: changing the runtime layer usually breaks not code, but system libraries and root certificates, so the image is pinned by digest, not a floating tag.
The main risks during migration are mismatched paths and permissions between stages, loss of layer cache due to reordered instructions, and build garbage that previously got into the image.
Therefore a check is added to the pipeline: running under a non-privileged user and comparing the list of files and ports with prod.
Technical concerns: cache, dependencies, base images
Before start, engineers ask the same questions: what happens to cache when code changes, how to pass access to private registries, and how often to rebuild the image so the base layer doesn't get stale. Below is how this works in our builds.
How does instruction order affect layer cache?
The cache reuses layers until the first changed instruction. Dependency manifests are copied before source code — editing one module doesn't invalidate package installation, and a repeat build takes minutes, not tens of minutes. Separating build and runtime stages removes compilers and intermediate files from the final image.
How to pass private dependencies without leaving them in the image?
Credentials must not be passed as build arguments — they are recorded in layer history. Access to a private registry is granted by mounting a secret during installation: the temporary config lives inside the step and doesn't get into the image. We verify this by analyzing the ready image — no tokens or keys remain in it.
How often to update the base image and rebuild?
The base image is pinned by digest: the build is reproducible, but vulnerabilities aren't pulled automatically. We update on schedule — see what changed in the base layer, change the pin, run tests.
Rebuild happens on every commit and additionally on schedule, so security updates reach prod within a day.
If you have a private registry behind the perimeter, several hardware architectures, or strict reproducibility requirements — we discuss these constraints at the design stage, before the build goes to production.Let's discuss the task: we'll analyze your image and come back with an estimate
To come back with a timeline estimate, not general wording, we need minimum input data: the current Dockerfile with all stages, the dependency file, the build command from the pipeline, and the log of the last run with the final image size.
From them it's immediately clear which build artifacts remain in the final layer, how much compilation tools weigh, and where space is lost.
Then the analysis proceeds step by step: at each one it's clear what we hand over and what is required from your side.
- Context collection: Dockerfile, dependency manifests, environment variables, run command.
- Current build check: size distribution by layers and build time on clean cache.
- Constraints: target platforms, requirements for debugging tools in the production image, vulnerability scanning rules.
- Work plan: which stages we separate, what we move to the build stage, what we leave in the final one.
- What we hand over: reworked multi-stage Dockerfile, stage scheme, and explanation for each change.
- Timeline estimate: breakdown by stages, points for return to approval, and result verification procedure.
Describe the task and attach what you already have at hand — we'll come back with an estimate and work plan. If there's little data so far, we'll suggest what to collect first so the analysis is based on facts, not assumptions.







