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
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>
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
Build with:
docker build \
--build-arg NUGET_USERNAME=build-agent \
--build-arg NUGET_PASSWORD="${TEAMCITY_NUGET_TOKEN}" \
-t your-service:latest .
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>
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
Build command:
DOCKER_BUILDKIT=1 docker build \
--secret id=nuget_config,src=./nuget-config.xml \
-t your-service:latest .
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
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:
- In TeamCity: Project Settings → Parameters
- Add
env.NUGET_USERNAMEandenv.NUGET_PASSWORD(mark as password) - Use the
NuGet.Configin 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 .
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 .
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)