Docker Multi-stage Build: A Practical Way to Reduce Image Size and Improve Deployment
Writing a good Dockerfile is one of the first steps toward a proper production environment. It carries your application from your local…
Docker Multi-stage Build: A Practical Way to Reduce Image Size and Improve Deployment
Writing a good Dockerfile is one of the first steps toward a proper production environment. It carries your application from your local machine to production, so this step should not be neglected.
In this article, we will talk about multi-stage builds, which are one of the most widely used and efficient approaches for applications that aim to have a proper production environment.
What is a multi-stage build?
As the title suggests, it is a mechanism that helps you reduce image size, speed up builds, secure your image, and improve maintainability. The core idea is simple: in the final image, include only what the application needs to run, and exclude everything else such as runtime tools and developer-specific utilities.
Now let’s build something
Consider having this ASP.NET directory:
Server/ # Root server directory
├── ExpenseLedgerSolution.slnx # Solution file defining project structure
├── NuGet.config # Package manager feed configurations
├── src/ # Core application source code
│ ├── Domain/ # Enterprise logic & entities
│ │ └── Domain.csproj
│ ├── Application/ # Use cases & business logic
│ │ └── Application.csproj
│ ├── Infrastructure/ # external services
│ │ └── Infrastructure.csproj
│ └── Host/ # Web API / Entry point project
│ └── Host.csproj
└── Tests/ # Test suites
├── UnitTests/ # Fast unit test suite
│ └── UnitTests.csproj
└── IntegrationTests/ # End-to-end Integration tests suite
└── IntegrationTests.csproj
A properly illustrated multi-stage build could look like this:
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ExpenseLedgerSolution.slnx .
COPY NuGet.config .
COPY src/Domain/Domain.csproj src/Domain/
COPY src/Application/Application.csproj src/Application/
COPY src/Host/Host.csproj src/Host/
COPY src/Infrastructure/Infrastructure.csproj src/Infrastructure/
COPY Tests/UnitTests/UnitTests.csproj /Tests/UnitTests/
# .NET follows the project references and end up restoring every project except IntegrationTests.csproj
RUN dotnet restore src/Host/Host.csproj && \
dotnet restore /Tests/UnitTests/UnitTests.csproj
# Any other src file
COPY . .
RUN dotnet build \
src/Host/Host.csproj \
-c Release \
--no-restore
# The child image already inherits all the parent's filesystem
FROM build AS test
RUN dotnet test \
Tests/UnitTests/UnitTests.csproj \
-c Release \
--no-restore
FROM build AS publish
RUN dotnet publish \
src/Host/Host.csproj \
-c Release \
-o /app/publish \
--no-restore \
--no-build
FROM runtime AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Host.dll"]
Note: every FROM statement starts a new stage. You keep writing instructions in one stage until you reach the next FROM, which ends the current stage and begins a new one.
Now let’s understand what each stage does
Stage 1: runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
Docker pulls mcr.microsoft.com/dotnet/aspnet:10.0, which becomes the base image for this stage. This image includes many things, but the most important ones for us are:
- A lightweight Linux OS, usually based on Ubuntu or Debian. It contains only what is needed to provide an environment ready to host the application.
- The .NET runtime, which is responsible for executing the
.dllfiles that will be placed in the final image.
There is no compiler here to build source code, and there are no test tools to run tests. This image is intended for execution only, and that is exactly what we want.
WORKDIR /app
This creates a directory inside the container’s file system, which will later be populated with the .dll files that the application will execute.
After establishing the base OS and runtime, and creating the working directory, you would roughly have something like this inside the container:
EXPOSE 8080
This exposes port 8080 inside the container. It is the port the application will listen on within the container itself.
/ (Container Root File System)
├── app/ <-- Created by "WORKDIR /app" (Target for final .dlls)
├── bin/ # System binaries (bash, sh, coreutils)
├── dev/ # System devices
├── etc/ # OS configurations & OS release information
│ ├── os-release # (Identifies base image, e.g., Debian/Ubuntu)
│ └── ssl/ # CA Certificates required for HTTPS/TLS communication
├── lib/ & lib64/ # C/C++ runtime system libraries (glibc/musl)
├── usr/
│ └── share/
│ └── dotnet/ <-- Pre-installed ASP.NET 10.0 Runtime Ecosystem
│ ├── dotnet # Main .NET executable host driver
│ ├── host/ # Host policy & resolver libraries
│ ├── shared/ # Shared framework dependencies
│ ├── Microsoft.NETCore.App/ # Base .NET runtime libraries
│ └── Microsoft.AspNetCore.App/ # ASP.NET web framework assemblies
│
├── var/ # System runtime variable data
└── tmp/ # Temporary files
Stage 2: build
Important: at the beginning of each stage, Docker preserves the previous stage(s), which means you can reuse them later in the same build whenever you want. When you start a new stage, it begins a new image from scratch, as if no previous stage had been created. The stages are preserved, but they are not automatically included unless you reuse them.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
Docker pulls mcr.microsoft.com/dotnet/sdk:10.0, which contains everything from the runtime image, plus all the tools that .NET needs to build, debug, test, compile, and publish code. Commands such as dotnet build and dotnet test will not run without those tools.
WORKDIR /src
This creates a new working directory for the image, which becomes the default path for any further instructions.
COPY ExpenseLedgerSolution.slnx .
COPY NuGet.config .
These files rarely change. For files that change rarely, it is better to keep them in separate layers so Docker can cache them and reuse them in later builds.
COPY src/Domain/Domain.csproj src/Domain/
COPY src/Application/Application.csproj src/Application/
COPY src/Host/Host.csproj src/Host/
COPY src/Infrastructure/Infrastructure.csproj src/Infrastructure/
COPY Tests/UnitTests/UnitTests.csproj /Tests/UnitTests/
These .csproj files contain the packages and dependencies your application needs.
Note: we did not include integration tests here, and they will never run during the Docker build. That is intentional and, in practice, a best practice. Integration tests add unnecessary overhead and make the build more error-prone. For a simple Docker build, including them defeats the whole purpose of a multi-stage build.
RUN dotnet restore src/Host/Host.csproj && \
dotnet restore /Tests/UnitTests/UnitTests.csproj
dotnet restore installs the packages and dependencies. .NET follows the project references, so restoring Host and UnitTests guarantees that all referenced projects are restored, except for IntegrationTests, since no project references them, which is exactly what we want here.
COPY . .
This copies the rest of the source code, meaning the actual .cs files that should be executed. You may wonder about Markdown files, log folders, environment variables, and similar files. Do not worry — that is handled later by the .dockerignore section.
RUN dotnet build \
src/Host/Host.csproj \
-c Release \
--no-restore
This builds the application itself, using the compiler and build tools provided by the image to generate the binary and .dll files.
Why build here?
To make sure that the dependencies were installed correctly and that the application can compile. However, this still does not guarantee that the dependencies actually work properly. That is where the test stage comes in.
Stage 3: test
Why is this important?
You may ask: why test inside Docker when you can do it in CI/CD or manually before building the image? That is a fair question, and the answer is that you absolutely can and should run tests in those places. So why test here too?
At this point in the image build, you have already restored the dependencies and installed them inside a new OS environment. There could still be conflicts between the image’s OS and the packages. This is rare, but possible. To cover that, we run only unit tests as follows:
RUN dotnet test \
Tests/UnitTests/UnitTests.csproj \
-c Release \
--no-restore
The test stage inherits everything from the build stage. You do not want to start from scratch here. You want to continue from the point where all dependencies are installed, source code is available, and the tests are present. So the test stage starts from the same file system the build stage left behind.
If the tests pass successfully, the build continues. If any test fails, the whole build fails. And yes, that is exactly what you want. When something breaks during development, you want to catch it before pushing to production. The test stage is one way to detect those problems early.
Stage 4: publish
You have already restored dependencies, built the application, and tested it. Now you want to include only what is necessary for the final image, so that the image stays as small as possible. This stage does exactly that with a single command:
FROM build AS publish
Here, you start from the build stage, just as the test stage did. But where did the test stage go? Docker preserved it for further use in this build, but we no longer need it. We already used it for what it was meant to do: run the tests. If we reached stage 4, then we already know the tests passed, so we do not need the test stage anymore.
RUN dotnet publish \
src/Host/Host.csproj \
-c Release \
-o /app/publish \
--no-restore \
--no-build
What does this command do? It runs the publishing pipeline, which produces deployable .dll files that are already compiled and ready to execute.
The command also places the output of the publish operation inside the container’s /app/publish.
Stage 5: final
FROM runtime AS final
WORKDIR /app
The stage starts from the runtime image that we configured in stage 1.
COPY --from=publish /app/publish .
It copies everything from /app/publish in the publish image and places it in the /app directory, so the final image includes only the deployable output and the necessary tools and packages to function properly.
ENTRYPOINT ["dotnet", "Host.dll"]
It sets the entry point, which becomes the main process of the started container. In this case, it simply starts the application.
Why .dockerignore matters
Now that we have covered how to build the Docker image with a multi-stage build, we need to add another important file: .dockerignore.
You are probably already familiar with this idea, even if you have never worked with Docker before, because it works the same way as .gitignore, which is probably one of the most important files in your repository.
.dockerignore is a text file that excludes files from the build context. In other words, when you copy the directory to the image’s file system, Docker excludes those files from being included.
Files to exclude
- Environment variables
- Markdown files
- Log files
- Documentation files
- IDE-generated files
- The Dockerfile and
.dockerignorethemselves - Git and GitHub files
Note: it is very important to exclude any IDE-specific generated files, because they often reference paths from your local machine’s file system. That can break the build, since the image will try to access a file that does not exist in its own file system. I have suffered from this a lot, so trust me on this one.
A comprehensive .dockerignore file could look like this:
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]eleases/
[Rr]eleasesPublic/
[Oo]utput/
[Ll]og/
[Ll]ogs/
# User-specific files
*.user
*.userosscache
*.suo
*.sln.docstates
# MonoDevelop
*.userprefs
# Auto-generated files by Visual Studio
*.ncb
*.suo
*.sdf
*.cache
*.dbmdl
*.opendb
*.pdb
*.psess
*.vsp
*.vsps
*.vssscc
*.vssscc
*.vsix
*.vsixmanifest
# NuGet
*.nupkg
*.snupkg
*.nuspec
.nuget/
packages/
project.lock.json
project.fragment.lock.json
*.csproj.user
# Backup files
*~
*.bak
*.tmp
# Folder-specific ignores
# Exclude folder for build output
**/bin/
**/obj/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# VS Code settings
*.vscode/
.vscode/
.vs/
# Environment Variables
appsettings.development.json
appsettings.development.example.json
# Agents Specifications
.agents/
docs/
opencode.json
nul
host_run.log
# MDs
*.md
*.MD
*.Md
# Docker Files
Dockerfile
.dockerignore
Takeaways
- A multi-stage build separates the build process into dedicated stages, each with a single responsibility.
- The final image contains only the published application, resulting in a smaller, more secure production image.
- Docker layer caching speeds up builds by reusing unchanged layers, making proper file ordering essential.
- Running tests as part of the build pipeline helps ensure the application behaves correctly before publishing.
- A well-designed
.dockerignorereduces build time, keeps images clean, and prevents unnecessary or sensitive files from being included.
That’s it! You now have a production-ready multi-stage Dockerfile that is optimized for size, build performance, maintainability, and security.
메타데이터
- post_id
- 955b4eaf8c9c
- slug
- docker-multi-stage-build-a-practical-way-to-reduce-image-size-and-improve-deployment-955b4eaf8c9c
- url
- https://medium.com/@mohamedcoder22/docker-multi-stage-build-a-practical-way-to-reduce-image-size-and-improve-deployment-955b4eaf8c9c
- canonical_url
- https://medium.com/@mohamedcoder22/docker-multi-stage-build-a-practical-way-to-reduce-image-size-and-improve-deployment-955b4eaf8c9c
- author_url
- https://medium.com/@mohamedcoder22
- status
- ok
- fetched_at
- 2026-09-04 11:21:07