Writing a Dockerfile

A multi-stage build that restores, publishes, then copies only the output into a runtime image, with layer caching that works.

Syntaxdocker build -t inventory-api:1.4.0 -f Dockerfile .

A Dockerfile is a text file of instructions for building an image. docker build reads it top to bottom and each instruction produces a layer, a saved snapshot of the filesystem at that point. Layers are why builds are fast: if an instruction and everything above it are unchanged, Docker reuses the cached layer instead of running it again.

Two stages

A multi-stage build is a Dockerfile with more than one FROM. The first stage uses the SDK image and produces a publish folder. The last stage starts from the aspnet image and copies that folder in with COPY --from=build. Only the last stage becomes the image you ship, so the compiler, the NuGet cache and your source never leave the build stage.

Caching that actually works

Copy the project file first, run dotnet restore, and only then copy the source. Restoring packages is the slow part, and done in that order its layer is reused until a dependency actually changes. Copy everything first and every edit to a single .cs file re-downloads every package.

What trips people up

The ENTRYPOINT names the assembly file, not the project. A project called InventoryApi produces InventoryApi.dll, and renaming the project without updating the entrypoint gives a container that starts, fails to find the file and exits immediately.

Example

Example · csharp
// Program.cs - the app the Dockerfile in the examples below builds.
// A build stage can bake a value into the image as an environment
// variable. When it is absent, ?? supplies the fallback instead.
string buildId = Environment.GetEnvironmentVariable("BUILD_ID") ?? "local";

Console.WriteLine("inventory-api starting");
Console.WriteLine($"build: {buildId}");

// Run on your own machine, with BUILD_ID unset:

// Output:
// inventory-api starting
// build: local

When to use it

  • Turn a service into an image your CI pipeline can build on every commit.
  • Cut a two-minute image build to seconds by caching the package restore separately from the source.
  • Produce a shippable image that contains no source code and no compiler.

More examples

The multi-stage Dockerfile

The shape to start from for any ASP.NET Core service. docker build -t inventory-api:1.4.0 . produces an image that listens on port 8080, because that is the default port in the .NET 8 and later images. EXPOSE documents the port and lets -P map it; it does not open anything by itself.

Example · dockerfile
# Stage one. Named "build" so the next stage can copy out of it.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# Copy only the project file, then restore. This layer is reused on every
# build until a PackageReference changes.
COPY InventoryApi/InventoryApi.csproj InventoryApi/
RUN dotnet restore InventoryApi/InventoryApi.csproj

# Now the source. Editing a .cs file invalidates this layer and the ones
# below it, but not the restore layer above.
COPY . .
RUN dotnet publish InventoryApi/InventoryApi.csproj \
    -c Release -o /app/publish --no-restore

# Stage two. This is the image that ships: no compiler, no source.
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish .

# Run as the rootless "app" user that the base image provides.
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "InventoryApi.dll"]

.dockerignore

Sits next to the Dockerfile and is read by docker build, which sends everything else to the build daemon as the build context. Smaller context means faster builds, and excluding bin/ and obj/ prevents stale artifacts from your host leaking into the container build.

Example · text
# Build output from your own machine. Never send it to the build context.
bin/
obj/

# Nothing in the image needs these.
.git/
.vs/
.vscode/
Dockerfile
.dockerignore
**/*.user

# Local-only settings that must not be baked into an image.
**/appsettings.Development.json

Shorthand and longhand for the build steps

dotnet publish implies a restore and a build unless you pass --no-restore or --no-build. Use the short form in a Dockerfile where restore is already its own cached layer; the long form earns its place in a CI pipeline that wants to report the restore, build and publish steps separately.

Example · dockerfile
# Longhand: three commands, three layers, each cached separately.
RUN dotnet restore InventoryApi/InventoryApi.csproj
RUN dotnet build InventoryApi/InventoryApi.csproj -c Release --no-restore
RUN dotnet publish InventoryApi/InventoryApi.csproj \
    -c Release -o /app/publish --no-restore --no-build

# Shorthand: dotnet publish does the restore and the build itself.
# These two blocks produce the same publish folder.
RUN dotnet publish InventoryApi/InventoryApi.csproj -c Release -o /app/publish

Discussion

  • Be the first to comment on this lesson.