Levelwise
English
Infrastructure and DevOps

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.

Not reviewedWritten with AI helpReading time: 14 minOnline shop order service exampleDockerfile code and .NET 10

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:

  1. The .NET runtime version on the server is older.
  2. A system library is not installed on the server.
  3. 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

Virtual machineContainerOrders APICatalog APIPayment APIGuest OSGuest OSGuest OSHypervisorHost OSServerOrders APICatalog APIPayment APIContainer RuntimeHost OS (Linux kernel)ServerEach app has a full OS: heavy and slowAll share one kernel: light and fast
A virtual machine has a full operating system for each app. Containers share only one kernel.
  1. Each virtual machine has a full operating system. So it takes a lot of memory and starts slowly.
  2. Containers share the kernel of the host operating system. Each container has only the app and its libraries.
  3. The result. A container is lighter and starts faster. This is why Kubernetes and cloud services work with containers.
One cost: Because the kernel is shared, containers are less isolated than virtual machines. So do not run the app as the root user (we see this below).

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:

  1. The Docker tool checks the commands from top to bottom.
  2. If a command and its input files have not changed, Docker takes the layer from the cache.
  3. The first layer that has changed is built again.
  4. All layers after it are also built again, even if they did not change themselves.
Build stage layersFROM sdk:10.0COPY *.csprojRUN dotnet restoreCOPY . .RUN dotnet publishBuild order: bottom to topOnly one code file changedBuilt againbecause the code files changedComes from cachePackages are not downloaded again
The project file is copied separately and before the code. So when the code changes, the package download comes from the cache.

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:

Layers and cache when building an Image
Choose an order, then a change.

    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:

    1. Build stage. On the SDK Image, we restore and publish the code.
    2. Final stage. We start on the small aspnet Image. We copy only the publish output from the previous stage.
    3. The result. The compiler, the source code and the temporary files never go into the final Image.
    Build stageFROM sdk:10.0 AS buildCompiler and toolsDownloaded packagesSource codeTemp build filesFinal build outputFinal package to runFROM aspnet:10.0Runtime onlyApp filesCOPY --from=buildCompiler and source code are left out
    Only the publish output goes from the build stage to the final Image.

    Why does a small Image matter?

    1. Speed. Each new server must download the Image. A small Image means pods start faster at busy times.
    2. Security. Every extra tool is a possible way in for an attacker. What is not in the Image has no vulnerabilities either.
    3. 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

    1. 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).
    2. 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.
    3. A container is temporary. Whenever a container is created again, the files inside it are lost. Put customer uploaded files in external storage.
    4. Write logs to standard output. Nobody sees a log file inside the container. Container tools collect the standard output.
    5. 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.
    6. 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.
    Memory in a container: The .NET GC sees the container’s memory limit and adjusts itself to it. So if you set a memory limit for the container, choose it based on the app’s real usage.

    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

    1. An Image packages the app with all its dependencies, and it runs the same everywhere.
    2. A container is lighter than a virtual machine, because it shares the operating system kernel.
    3. Each command is a layer. The first changed layer and all layers after it are built again.
    4. First the project file and restore, then the rest of the code. This way the package cache is kept.
    5. With a Multi-stage build, only the publish output goes onto the small aspnet Image.
    6. Do not put secrets in the Image, run as a non-root user, and tag the Image with a specific version.