DEV Community

Cover image for Adding Private NuGet Feeds in Multi-Stage Dockerfiles Without Breaking Your Build
augustine Egbuna
augustine Egbuna

Posted on • Originally published at fivenineslab.com

Adding Private NuGet Feeds in Multi-Stage Dockerfiles Without Breaking Your Build

You're building a .NET service in TeamCity. Your build depends on internal NuGet packages from other projects. The TeamCity NuGet feed is configured, but your Docker build agent can't see it. You add dotnet nuget add source to your Dockerfile, the build completes, but when you actually try to restore packages, the source isn't there.

I hit this exact problem setting up ML model serving APIs that depended on shared utility packages. The issue isn't obvious: NuGet configuration added during the Docker build doesn't persist to the restore step because of how multi-stage builds and layer caching work.

Why Your NuGet Source Disappears

When you run dotnet nuget add source in a Dockerfile, it writes to ~/.nuget/NuGet/NuGet.Config. But in multi-stage builds, that config lives in an intermediate layer that may not be present when you actually run dotnet restore. Even in single-stage builds, if you're running the container with volume mounts or different working directories, the config path changes.

Here's what typically fails:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build

# This completes without error
RUN dotnet nuget add source https://teamcity.example.com/httpAuth/app/nuget/feed \
    --name TeamCityFeed \
    --username build-agent \
    --password $NUGET_PASSWORD \
    --store-password-in-clear-text

WORKDIR /src
COPY *.csproj .
# This step fails: source not found
RUN dotnet restore
Enter fullscreen mode Exit fullscreen mode

The add source command succeeds, but the config is written to /root/.nuget/NuGet/NuGet.Config in that layer. When dotnet restore runs, it may be looking in a different user context or the layer cache invalidates.

Solution 1: NuGet.Config in Your Source Tree

The most reliable approach is to commit a NuGet.Config file to your repository and copy it into the Docker build context. This works because the config travels with your code.

Create NuGet.Config at your solution root:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="TeamCityFeed" value="https://teamcity.example.com/httpAuth/app/nuget/feed" />
  </packageSources>
  <packageSourceCredentials>
    <TeamCityFeed>
      <add key="Username" value="%NUGET_USERNAME%" />
      <add key="ClearTextPassword" value="%NUGET_PASSWORD%" />
    </TeamCityFeed>
  </packageSourceCredentials>
</configuration>
Enter fullscreen mode Exit fullscreen mode

Notice the %NUGET_USERNAME% and %NUGET_PASSWORD% placeholders. NuGet will substitute these from environment variables at runtime. Your Dockerfile becomes:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build

WORKDIR /src
COPY NuGet.Config .
COPY *.csproj .

# Pass credentials as build args
ARG NUGET_USERNAME
ARG NUGET_PASSWORD
ENV NUGET_USERNAME=${NUGET_USERNAME}
ENV NUGET_PASSWORD=${NUGET_PASSWORD}

RUN dotnet restore

COPY . .
RUN dotnet publish -c Release -o /app/publish
Enter fullscreen mode Exit fullscreen mode

Build with:

docker build \
  --build-arg NUGET_USERNAME=build-agent \
  --build-arg NUGET_PASSWORD="${TEAMCITY_NUGET_TOKEN}" \
  -t your-service:latest .
Enter fullscreen mode Exit fullscreen mode

This approach works because the config file is present in the working directory when dotnet restore runs. NuGet searches the current directory before looking at the user profile.

Solution 2: Mount Config at Build Time

If you can't commit credentials (even as placeholders) to source control, mount the config as a build secret. Docker BuildKit supports this with --secret and RUN --mount=type=secret.

Create nuget-config.xml on your build agent:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="TeamCityFeed" value="https://teamcity.example.com/httpAuth/app/nuget/feed" />
  </packageSources>
  <packageSourceCredentials>
    <TeamCityFeed>
      <add key="Username" value="build-agent" />
      <add key="ClearTextPassword" value="actual-token-here" />
    </TeamCityFeed>
  </packageSourceCredentials>
</configuration>
Enter fullscreen mode Exit fullscreen mode

Dockerfile with mounted secret:

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

WORKDIR /src
COPY *.csproj .

RUN --mount=type=secret,id=nuget_config,target=/root/.nuget/NuGet/NuGet.Config \
    dotnet restore

COPY . .
RUN dotnet publish -c Release -o /app/publish
Enter fullscreen mode Exit fullscreen mode

Build command:

DOCKER_BUILDKIT=1 docker build \
  --secret id=nuget_config,src=./nuget-config.xml \
  -t your-service:latest .
Enter fullscreen mode Exit fullscreen mode

The --mount=type=secret makes the config available only during that RUN step. It's never written to a layer, so it won't leak into the final image.

Handling Multi-SDK Scenarios

If you're using multiple .NET SDK versions (like the original question implied), you need the config accessible to each SDK. The mounted secret approach works best here because it targets the standard config location.

For a multi-SDK Dockerfile:

# syntax=docker/dockerfile:1.4
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-legacy

WORKDIR /src/legacy
COPY legacy/*.csproj ./
RUN --mount=type=secret,id=nuget_config,target=/root/.nuget/NuGet/NuGet.Config \
    dotnet restore

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-modern

WORKDIR /src/modern
COPY modern/*.csproj ./
RUN --mount=type=secret,id=nuget_config,target=/root/.nuget/NuGet/NuGet.Config \
    dotnet restore
Enter fullscreen mode Exit fullscreen mode

Each stage gets the config mounted at the same path, so both SDK versions can read it during their respective restore steps.

TeamCity-Specific Integration

TeamCity has first-class support for NuGet feeds. Instead of managing credentials in Dockerfiles, configure them as build parameters:

  1. In TeamCity: Project Settings → Parameters
  2. Add env.NUGET_USERNAME and env.NUGET_PASSWORD (mark as password)
  3. Use the NuGet.Config in source tree approach from Solution 1

TeamCity automatically exposes these as environment variables to your build steps. Your Dockerfile reads them via the ENV directive, and NuGet substitutes them at restore time.

For builds that run outside TeamCity (local development), developers can set these environment variables manually or use a local NuGet.Config that points to their own credentials.

Common Gotcha: Layer Caching

If you modify your NuGet source configuration, Docker's layer cache may serve a stale restore. After changing feed URLs or credentials, force a rebuild:

docker build --no-cache -t your-service:latest .
Enter fullscreen mode Exit fullscreen mode

Or, more surgically, change the COPY command that includes your NuGet.Config by adding a dummy ARG with a timestamp:

ARG CACHE_BUST=1
COPY NuGet.Config .
Enter fullscreen mode Exit fullscreen mode

Build with --build-arg CACHE_BUST=$(date +%s) to invalidate the cache from that point forward.

What Didn't Work

I initially tried dotnet nuget add source with --configfile to specify an explicit path. This worked in the RUN step where I added it, but didn't persist because each RUN creates a new layer. Subsequent RUN dotnet restore commands couldn't see the source.

I also tried using docker-compose volumes to mount the host's ~/.nuget directory into the container. This worked locally but broke CI because the build agent's filesystem layout differed from the container's expectations.

The solutions above are what actually worked in production: NuGet.Config in the source tree for simplicity, mounted secrets for security-sensitive environments.


This post is an excerpt from Practical AI Infrastructure Engineering — a production handbook covering Docker, GPU infrastructure, vector databases, and LLM APIs. Full book with 4 hands-on capstone projects available at https://activ8ted.gumroad.com/l/ssmfkx


Originally published at fivenineslab.com

Top comments (0)