← All tutorials

Dockerizing a .NET Project

A practical guide to writing a Dockerfile for .NET — including a private nuget.config

Docker lesson banner: Source, Build, and Runtime stages of a multi-stage .NET image.
The SDK stays in the build stage. The final image only runs the app. Labels on this diagram are in Persian.

What will you learn here?

In this tutorial we package a real .NET 8 Web API into a Docker image: a clean, small, production-ready image built with a multi-stage build. Along the way we deal with one of the most common pain points teams hit — restoring NuGet packages from private feeds (such as Azure Artifacts, GitHub Packages or a self-hosted Nexus/BaGet) inside the container, using a nuget.config file.

By the end you'll have a Dockerfile, a .dockerignore, a nuget.config, and a docker-compose.yml — plus the commands to build and run everything safely.

1. Why Docker for .NET?

Docker lets you package your app together with the exact runtime it needs, so it runs the same on your laptop, your colleague's machine, and the production server. For .NET this is especially handy because:

  • No "works on my machine" — the .NET SDK/runtime version is locked inside the image.
  • The final image only ships the runtime, not the whole SDK, so it stays small.
  • CI/CD pipelines can build once and deploy the same artifact everywhere.
We'll use Microsoft's official images: mcr.microsoft.com/dotnet/sdk to build, and mcr.microsoft.com/dotnet/aspnet to run.

2. Project structure

Let's assume a typical Web API solution:

MyApp/
├─ src/
│  ├─ MyApp.Api/
│  │  ├─ MyApp.Api.csproj
│  │  └─ Program.cs
│  ├─ MyApp.Application/
│  │  └─ MyApp.Application.csproj
│  └─ MyApp.Domain/
│     └─ MyApp.Domain.csproj
├─ nuget.config
├─ .dockerignore
├─ Dockerfile
└─ MyApp.sln

Keep the Dockerfile, nuget.config and .dockerignore at the repository root so the build context is the whole solution.

3. nuget.config — what & why

nuget.config tells the .NET tooling where to download packages from. By default it only knows about the public feed (nuget.org). The moment your team uses a private package feed, you must declare it here — otherwise dotnet restore fails inside Docker with a "package not found" or "401 Unauthorized" error.

3.1 Public feed only

The simplest config — pin the public source explicitly so the build is reproducible:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
  </packageSources>
</configuration>
<clear /> removes any inherited sources from machine-level config, so the container only uses exactly what you list. This is important for predictable CI builds.

3.2 Adding a private feed

Now add your private feed and its credentials. Never hard-code a real token in a committed file. Instead use an environment-variable placeholder that NuGet expands at restore time:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="my-private" value="https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json" />
  </packageSources>

  <packageSourceCredentials>
    <my-private>
      <add key="Username" value="myorg" />
      <add key="ClearTextPassword" value="%NUGET_TOKEN%" />
    </my-private>
  </packageSourceCredentials>
</configuration>
The element name under <packageSourceCredentials> must match the source key exactly (my-private here). Any spaces in the key become _x0020_ — so keep keys simple with no spaces.

%NUGET_TOKEN% is read from an environment variable. We'll feed that into Docker as a build secret in the next sections — so the secret never lands in the image history.

4. .dockerignore

Before writing the Dockerfile, add a .dockerignore. It keeps the build context small and fast, and — critically — stops local build artifacts and secrets from being copied into the image.

# build output
**/bin/
**/obj/
# IDE / VCS
.vs/
.git/
.gitignore
# docker
Dockerfile
.dockerignore
# misc
**/*.user
**/appsettings.Development.json
Ignoring bin/ and obj/ is the single biggest win — otherwise stale local builds get copied in and can break the layer cache or leak machine-specific paths.

5. Writing the Dockerfile

5.1 Multi-stage build

The trick to a small image is two stages: a build stage that has the full SDK and does restore/publish, and a runtime stage that copies only the published output onto the lean ASP.NET runtime image.

# ---------- Build stage ----------
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Copy csproj + nuget.config first to maximize layer caching
COPY nuget.config ./
COPY src/MyApp.Api/MyApp.Api.csproj            src/MyApp.Api/
COPY src/MyApp.Application/MyApp.Application.csproj  src/MyApp.Application/
COPY src/MyApp.Domain/MyApp.Domain.csproj      src/MyApp.Domain/

# Restore using the nuget.config at the root
RUN dotnet restore src/MyApp.Api/MyApp.Api.csproj --configfile nuget.config

# Now copy the rest of the source and publish
COPY . .
RUN dotnet publish src/MyApp.Api/MyApp.Api.csproj \
    -c Release -o /app/publish --no-restore

# ---------- Runtime stage ----------
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
Why copy the .csproj files first? Docker caches each layer. If your source changes but dependencies don't, Docker reuses the cached restore layer and skips re-downloading every package — build times drop dramatically.

5.2 Passing the feed token safely

The private feed needs NUGET_TOKEN. The wrong way is ARG NUGET_TOKEN + ENV, because build args get baked into the image history and can be inspected later. The right way is BuildKit secrets, which mount the value only for one RUN and never persist it:

# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

COPY nuget.config ./
COPY src/MyApp.Api/MyApp.Api.csproj src/MyApp.Api/
# ... copy other csproj files ...

# Mount the token as a secret only for this RUN
RUN --mount=type=secret,id=nuget_token \
    NUGET_TOKEN="$(cat /run/secrets/nuget_token)" \
    dotnet restore src/MyApp.Api/MyApp.Api.csproj --configfile nuget.config

COPY . .
RUN dotnet publish src/MyApp.Api/MyApp.Api.csproj -c Release -o /app/publish --no-restore

The first line # syntax=docker/dockerfile:1.7 is required to enable the secret mount feature.

Never run echo $NUGET_TOKEN or leave the token in a RUN that gets cached. With --mount=type=secret the value lives only in memory for that single command.

5.3 How Docker layer caching works

Every line in a Dockerfile (FROM, COPY, RUN, …) creates a layer. During docker build, Docker walks the file top to bottom and asks for each layer: "Do I already have an identical result from a previous build?" If yes, it reuses the cached layer and skips re-running that step.

The basic rule

Docker caches a layer only when all of these are true:

  • The instruction text is exactly the same (e.g. the same RUN dotnet restore … command).
  • Every layer above it was also taken from cache (the chain must stay intact).
  • For COPY/ADD: the files being copied have the same content checksum as last time.
If one layer is rebuilt, every layer below it is invalidated too — even if their own inputs did not change. Order matters.

What happens in our Dockerfile — step by step

InstructionWhen cache is reusedWhen cache breaks
FROM … AS buildSame base image tag (e.g. sdk:8.0) already pulled locallyYou change the tag, or pull a newer digest for the same tag
WORKDIR /srcAlmost always (unless a layer above changed)A layer above was rebuilt
COPY nuget.confignuget.config content unchangedYou add/remove a feed or change credentials placeholder
COPY … .csprojNone of the copied .csproj files changedYou add a NuGet package reference or change TargetFramework
RUN dotnet restoreAll layers above cached and restore command unchangedAny .csproj or nuget.config change — packages are downloaded again
COPY . .No file in build context changed since last buildAny file changed (even a comment in one .cs file) — expected on source edits
RUN dotnet publishCOPY . . was cached (nothing in source changed)Any source change — full compile runs again
FROM … AS finalRuntime base image unchangedNew aspnet image tag/digest
COPY --from=build …Build stage produced the same publish outputPublish layer above changed

Two practical scenarios

Scenario A — you only change Program.cs:

  • FROM → WORKDIR → COPY nuget.config → COPY csproj → RUN restore → all cached ✅
  • COPY . . → cache miss (source changed) → runs again
  • RUN publish → runs again (layer above changed)

Result: NuGet packages are not re-downloaded. Only compile/publish runs — usually much faster.

Scenario B — you add a package to MyApp.Api.csproj:

  • COPY … .csproj → cache miss (file content changed)
  • RUN restore → runs again, downloads the new package
  • COPY . . and RUN publish → also run again

Result: Expected — dependencies changed, so restore must run again.

Why COPY . . should come after restore

If you put COPY . . before dotnet restore, then any source edit (even fixing a typo in a comment) invalidates restore and forces NuGet to download everything again. Splitting "dependency files first, full source later" is the most important Dockerfile optimization for .NET.

Things that do not get cached the way you might expect

  • ARG values — changing a build arg invalidates all layers after the ARG line that uses it.
  • Timestamps inside RUN — RUN date or commands that always produce different output never cache reliably.
  • Files excluded by .dockerignore — they are not sent to Docker at all, so they cannot break COPY . .; that's another reason .dockerignore matters.
  • docker build --no-cache — forces a full rebuild of every layer, ignoring all cache.
Quick check during build: when you run docker build, lines ending with CACHED reused a layer; lines without it were executed fresh. Use that output to see exactly where your cache broke.

6. docker-compose

For local development with a database, a compose file ties everything together:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__Default=Server=db;Database=myapp;User=sa;Password=Your_password123;TrustServerCertificate=True
    depends_on:
      - db

  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=Your_password123
    ports:
      - "1433:1433"
.NET maps ConnectionStrings__Default (double underscore) to the nested config key ConnectionStrings:Default — that's how you override appsettings from environment variables.

7. Build & run

Build with a private feed token (BuildKit secret)

# Make sure BuildKit is on (default on modern Docker)
export DOCKER_BUILDKIT=1

# Pass the token from an env var into the secret
echo "$NUGET_TOKEN" | docker build \
  --secret id=nuget_token,src=/dev/stdin \
  -t myapp:latest .

Or from a file

docker build --secret id=nuget_token,src=./token.txt -t myapp:latest .

Run the container

docker run --rm -p 8080:8080 myapp:latest

Or with compose

docker compose up --build

Then open http://localhost:8080/swagger to test the API.

CommandWhat it does
docker build -t myapp .Build the image
docker run -p 8080:8080 myappRun a container
docker imagesList local images
docker compose up --buildBuild & run the whole stack
docker system pruneClean unused data/cache

8. Best practices

  • Multi-stage builds keep the final image to the runtime only — often 200–220 MB instead of 800+ MB.
  • Copy .csproj first, restore, then copy the rest for maximum layer-cache reuse.
  • Never bake secrets into ARG/ENV; use BuildKit --mount=type=secret.
  • Pin the base image tag (e.g. 8.0, or a digest) so builds are reproducible.
  • Run as non-root in production — the official images support USER $APP_UID on .NET 8+.
  • Always add a .dockerignore to keep the context lean and avoid leaking bin/obj.
  • Use --no-restore on publish since you already restored — saves time and avoids surprises.

Final summary

  • nuget.config declares your package sources; use <clear/> + an env placeholder for private feed credentials.
  • .dockerignore keeps bin/obj and secrets out of the build context.
  • Dockerfile = multi-stage: SDK image builds & publishes, ASP.NET runtime image runs.
  • BuildKit secrets pass the NuGet token without leaking it into image history.
  • docker-compose wires the API together with a database for local dev.

Now your .NET app is portable, reproducible and safe to ship. 🐳