Docker and small Images for .NET
Package the app with everything it needs in one Image, so it runs the same everywhere. With a Multi-stage build and the right order of layers, the Image is small, safe and fast to build.
Author: bezzad
The problem: “it works on my machine”
Our online shop has an order service. The project name is Shop.Orders.Api. The developer runs it on their own laptop, and everything is fine. But on the server it gives an error.
Why? Because the two environments are different:
- The .NET runtime version on the server is older.
- A system library is not installed on the server.
- The time zone or system language setting is different.
Every time we add a new server, we must fix all of these by hand. This work is slow and full of mistakes.
The idea: package the app and its environment together
With Docker, we put the app together with everything it needs in one package. We call this package an Image. When an Image runs, it is called a Container.
- An Image is like a template. It is fixed and does not change.
- Each Container is a running copy of that template. You can make ten Containers from one Image.
So the same Image that was tested on the laptop also runs on the server. The environment is no longer different.
Container vs virtual machine
- Each virtual machine has a full operating system. So it takes a lot of memory and starts slowly.
- Containers share the kernel of the host operating system. Each container has only the app and its libraries.
- The result. A container is lighter and starts faster. This is why Kubernetes and cloud services work with containers.
Layers: why the order of commands matters
The Dockerfile is the recipe for building an Image. Each command in it builds one layer (Layer). Layers sit on top of each other.
The important point is that Docker caches the layers:
- The Docker tool checks the commands from top to bottom.
- If a command and its input files have not changed, Docker takes the layer from the cache.
- The first layer that has changed is built again.
- All layers after it are also built again, even if they did not change themselves.
Now see why the order matters. Downloading NuGet packages (the restore command) is slow. We change code files several times a day, but the project file (csproj) changes less often. So:
- Good order. First copy only the project file and run restore. Then copy the rest of the code.
- Bad order. First copy all the code, then run restore. Every small code change breaks the package cache.
Live example
Choose an order. Then make a change and see which layers come from the cache:
Building a small Image with a Multi-stage build
To build the app, we need the .NET SDK: the compiler, the tools and the packages. But to run the app, only the runtime is needed. And the SDK Image is much bigger than the runtime Image.
In a Multi-stage build, one Dockerfile has several stages:
- Build stage. On the SDK Image, we restore and publish the code.
- Final stage. We start on the small aspnet Image. We copy only the publish output from the previous stage.
- The result. The compiler, the source code and the temporary files never go into the final Image.
Why does a small Image matter?
- Speed. Each new server must download the Image. A small Image means pods start faster at busy times.
- Security. Every extra tool is a possible way in for an attacker. What is not in the Image has no vulnerabilities either.
- Cost. Storage space and network traffic go down.
Code
The Dockerfile for the order service
# Stage 1: build with the full SDK
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy only the project file first, so restore is cached
COPY Shop.Orders.Api/Shop.Orders.Api.csproj Shop.Orders.Api/
RUN dotnet restore Shop.Orders.Api/Shop.Orders.Api.csproj
# Now copy the rest of the code and publish
COPY . .
RUN dotnet publish Shop.Orders.Api/Shop.Orders.Api.csproj \
-c Release -o /app/publish --no-restore
# Stage 2: small runtime image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# Do not run as root. The .NET images define this user.
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Shop.Orders.Api.dll"]
A few notes about this file:
- Since .NET 8, the official ASP.NET Core Images listen on port 8080 by default, not port 80. The reason is so the app can run without root.
- The APP_UID variable is defined in the official Images and points to a normal (non-root) user.
- In .NET 10, the default Images are based on Ubuntu. There are also smaller versions called chiseled that have no shell and no package manager.
The dockerignore file
Without this file, the “COPY . .” command also brings the bin and obj folders from the laptop into the Image. This breaks the cache for no reason and may put the wrong files into the Image.
**/bin/
**/obj/
.git/
.vs/
**/*.user
**/appsettings.Development.json
Another way: without a Dockerfile
The dotnet tool itself can also build an Image. This way is good for simple projects:
dotnet publish Shop.Orders.Api -c Release /t:PublishContainer
Important rules
- Settings come from outside. Do not put the database connection string inside the Image. Give it with an environment variable, for example ConnectionStrings__Orders. So one Image works for all environments (test and Production).
- Do not put secrets inside the Image. Every layer stays in the Image forever. If you copy a secret file in one layer and delete it in the next layer, it is still in the earlier layer, and anyone who has the Image can see it.
- A container is temporary. Whenever a container is created again, the files inside it are lost. Put customer uploaded files in external storage.
- Write logs to standard output. Nobody sees a log file inside the container. Container tools collect the standard output.
- One app per container. Do not put the order service and the database in one container. Each one starts on its own, scales on its own and fails on its own.
- Use a specific version. Do not use the latest tag for deploys. Tag each Image with a version or a commit ID, so you know exactly what is running and you can go back.
Common mistakes
| Mistake | Result | Right way |
|---|---|---|
| One stage with the SDK Image | The Image is very big, and the compiler is in Production. | Multi-stage build. |
| Copying all code before restore | Every small change downloads all packages again. | First the project file, then restore, then the rest of the code. |
| No dockerignore file | The bin and obj folders, and even secret files, go into the Image. | Put these folders in dockerignore. |
| A secret or connection string in the Image | Anyone who has the Image has the secret. | An environment variable or a secret management service. |
| Running as the root user | If the app is hacked, the attacker has more access. | The USER command with a non-root user. |
| The latest tag in deploys | It is not clear which version runs, and going back is hard. | A tag with a version or a commit ID. |
When to use Docker?
Good fit
- A web service or Worker that runs on several servers or in Kubernetes.
- The team wants development, test and Production to be the same.
- Running dependencies like a database and Redis for tests on a laptop or in CI.
Little benefit
- A small tool that runs only on one Windows desktop.
- The team has no infrastructure to build and maintain Images, and a simple server is enough.
Summary in six lines
- An Image packages the app with all its dependencies, and it runs the same everywhere.
- A container is lighter than a virtual machine, because it shares the operating system kernel.
- Each command is a layer. The first changed layer and all layers after it are built again.
- First the project file and restore, then the rest of the code. This way the package cache is kept.
- With a Multi-stage build, only the publish output goes onto the small aspnet Image.
- Do not put secrets in the Image, run as a non-root user, and tag the Image with a specific version.