Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-container-you-cannot-configure && read --section="top" 0%
.NET

The Container You Cannot Configure

A product whose entire interface is a YAML document, shipped in an image that document cannot enter - plus the resilience inventory of a gateway with retries and nothing else.

By Kumar Chandrachooda 27 Feb 2026 10 min read
A sealed shipping container with a document held outside it and no slot to post it through

You have the image. You have your routes in a YAML file, which is the whole point of this product — no code, one document, done. Now get the document into the container. There is no flag for it. The environment variable you found in the source belongs to a different host. Arguments passed to docker run vanish before the process sees them. The README shows three commands and none of them mentions the problem.

Part 9 established that CI builds the wrong configuration and never builds the image at all. This part reads the image, which is the artefact the README leads with and the one nothing has ever verified.

Eleven lines

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /publish
COPY . .
RUN dotnet publish src/Ntrada.Host -c Release -o out

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /ntrada
COPY --from=build /publish/out .
ENV ASPNETCORE_URLS http://*:80
ENV ASPNETCORE_ENVIRONMENT docker
ENTRYPOINT dotnet Ntrada.Host.dll

Dockerfile, entire, no trailing newline

Every line carries a finding, and the file is short enough that no paraphrase is honest.

Line 1 — nothing in this build is pinned. sdk:3.1 is a floating minor tag: 3.1.100 in December 2019, 3.1.426 by the time the repository went quiet. Combine it with the 0.4.* NuGet wildcard from part 8 and the absence of any lock file, and no layer of this build is reproducible. To be fair, pinning to a patch-level SDK tag was not common practice in .NET Dockerfiles of that era; floating minor tags were what Microsoft's own samples showed. What is not a 2019 question is that the mcr.microsoft.com/dotnet/core/* namespace was retired in late 2020. This Dockerfile does not build today, and it fails at FROM with a registry error rather than anything a reader would recognise as version rot.

Line 3 — COPY . ., against a two-line .dockerignore. The whole exclusion list is **/obj/ and **/bin/. So the entire 103-commit git history ships into the build stage of an image whose job is to run one 28-line Program. The consequence that costs you daily is the cache: COPY . . invalidates on any change to any file in the context, including .git/HEAD, so RUN dotnet publish re-executes a full NuGet restore on every single build, forever. The idiomatic fix — copy the .sln and the .csproj files, restore as its own layer, then copy everything and publish with --no-restore — was in Microsoft's own documentation at the time, is four lines, and was never made.

Line 4 — -c Release, and the build graph follows it in. This is part 9's finding in its native habitat. In Release the extensions resolve Ntrada from nuget.org rather than from the source tree in the same image. Being precise rather than dramatic: Ntrada.Host takes an unconditional ProjectReference to the core, so the locally-built Ntrada.dll is produced and copied to out. What you get is a publish directory in which the core is your build while five extension assemblies were compiled against the published 0.4.x reference assembly. A mixed graph that works exactly as long as your local core stays binary-compatible with the released one — and the moment it does not, docker build succeeds and the container throws a type-load failure on its first request.

Line 6 — the runtime stage is right. Runtime-only aspnet base rather than the SDK, a proper multi-stage split. This is the one unambiguously good decision in the file and it saves roughly half a gigabyte.

Line 8 — this line was wrong for eight months. It shipped in 0112fe1 (2019-02-20) as COPY --from=build /publish/src/Ntrada.Host/out ., pointing at a path that dotnet publish -o out invoked from /publish does not produce. It was corrected in c8c92db “Fixed Dockerfile” (2019-10-18) — which also fixed a second defect introduced one commit earlier:

-FROM mcr.microsoft.com/dotnet/core/sdk:3.0k AS build
+FROM mcr.microsoft.com/dotnet/core/sdk:3.0 AS build

git show c8c92db -- Dockerfile

A typo that makes the image unbuildable survived a commit, and was fixed in a follow-up, because nothing in CI builds the image. There is no docker build step in the workflow. The container — the artefact the README leads with, the one the “no coding whatsoever” promise rests on — has never once been built by automation in this repository's history. Both of its defects were found by a human typing the command by hand, and the older one took eight months.

Line 9 — the port moved and its only declaration was deleted. 5016fdb changed ASPNETCORE_URLS from http://*:5000 to http://*:80 and removed EXPOSE 5000 rather than updating it to EXPOSE 80. EXPOSE is documentation, not enforcement, so nothing breaks — but it is the only machine-readable statement of what port the image listens on, it is what docker run -P and every orchestrator's port inference read, and it is now absent.

Line 10 — ASPNETCORE_ENVIRONMENT docker is neither Development nor Production. IsDevelopment() and IsProduction() both return false. Nothing here branches on environment, so the value is inert — but Host.CreateDefaultBuilder reads it for appsettings.{env}.json layering, user-secrets loading and developer-exception-page middleware. The container gets production-ish behaviour by accident rather than by declaration, and the one legible signal a reader could use to ask “is this image safe to run in prod?” answers with something that maps to neither.

Line 11 — shell form, and it costs two things. ENTRYPOINT dotnet Ntrada.Host.dll without JSON brackets is rewritten by Docker to /bin/sh -c "dotnet Ntrada.Host.dll".

The first cost is no graceful shutdown. PID 1 in the container is sh, not dotnet, and sh -c does not forward signals to its child. docker stop sends SIGTERM to PID 1; IHostApplicationLifetime.ApplicationStopping never fires; in-flight requests are not drained; the container is killed by SIGKILL after the grace period, every single time. For an API gateway — a component whose entire job is to sit in front of other services holding HTTP connections open in both directions — this is the most consequential single line in the estate's operational surface. Every deploy, every scale-in, every rolling update drops in-flight requests. The fix is four characters of JSON: ENTRYPOINT ["dotnet", "Ntrada.Host.dll"].

The second cost is that trailing docker run arguments are discarded rather than appended, which is what makes the rest of this article the article.

Absent, stated plainly: no USER, so the container runs as root — and here the 2019 defence is complete, because the .NET base images had no non-root default until Microsoft added the app user in .NET 8, so writing your own adduser was the only option and was uncommon. Also absent: HEALTHCHECK, STOPSIGNAL, and any LABEL at all. The image, like the package, carries no version.

Three ways in, and none of them work

There are two Program.cs files in this repository that boot a gateway, and they resolve their configuration path differently. The sample reads an environment variable:

const string extension = "yml";
var ntradaConfig = Environment.GetEnvironmentVariable("NTRADA_CONFIG");
var configPath = args?.FirstOrDefault() ?? ntradaConfig ?? $"ntrada.{extension}";
if (!configPath.EndsWith($".{extension}"))
{
    configPath += $".{extension}";
}

builder.AddYamlFile(configPath, false);

samples\Ntrada.Samples.Api\Program.cs:23-31

The host that Docker actually publishes does not:

var configPath = args?.FirstOrDefault() ?? "ntrada.yml";
builder.AddYamlFile(configPath, false);

src\Ntrada.Host\Program.cs:22-23

So inside the container the resolution chain is exactly args[0], or the literal string "ntrada.yml". Follow every option a deployer would try:

  • docker run ntrada /config/mine.yml — the argument is swallowed by sh -c. The container loads the baked-in file.
  • docker run -e NTRADA_CONFIG=/config/mine.yml ntradaNtrada.Host never reads that variable. The container loads the baked-in file.
  • docker run -e Modules__home__routes__0__returnValue=Hi ntrada — this is the subtle one. The YAML file is added inside the application's own ConfigureAppConfiguration callback, which runs after Host.CreateDefaultBuilder has installed its defaults. The layering is therefore appsettings.json → environment variables → command line → then ntrada.yml. The gateway's primary configuration source sits at the bottom of the override stack, which is the inverse of what a twelve-factor deployer expects, so environment overrides only reach keys the YAML file does not define.
  • Bind-mount over the baked file, or rebuild the image. These work. These are the only two that do.

The README shows neither. Its Docker section is three lines — docker build, docker run -it --rm -p 5000:80, curl localhost:5000 — with no -v, no -e, no mention of NTRADA_CONFIG anywhere in the file, and no docker-compose.yml in the repository.

A product whose entire user interface is a YAML document ships a container into which that YAML document cannot be injected, and a run-book that does not mention the problem. Each artefact is defensible alone: the Dockerfile is a normal 2019 Dockerfile, Ntrada.Host is a normal minimal host, the README is a normal quick-start. The defect exists only between them, which is why nobody who owned any one of the three ever saw it.

The fairest reading of intent, and I think it is the correct one: src\Ntrada.Host was built as a demo host, not a distributable gateway. Its ntrada.yml is seven lines and returns a greeting on GET /. The README's Docker block exists to prove that curl localhost:5000 answers in under a minute, and at that job it succeeds completely. The problem is that the README does not say so — it presents Docker as one of two equal ways to run the product, immediately below the sentence promising a gateway that “requires no coding whatsoever and can be started via Docker.”

The resilience inventory

While we are at the operational tier, here is what this gateway has and does not have for the moment a downstream service goes wrong.

Block State
Retries Present — Polly, via AddTransientHttpErrorPolicy
Circuit breaker Absent
Timeout Absent
Bulkhead / per-service isolation Absent — one named HttpClient for every route to every downstream
Rate limiting Absent
Health / readiness endpoint Absent

Retries without the rest is the worst possible subset, and the arithmetic says so. services.AddHttpClient("ntrada") is never followed by a Timeout assignment and no Polly TimeoutAsync is added, so HttpClient.Timeout takes the framework default of 100 seconds. With the sample's retries: 2, exponential: true, interval: 2.0, one request against a downstream that accepts connections and never answers occupies the gateway for 100 + 2 + 100 + 4 + 100 = 306 seconds.

A gateway with retries and no circuit breaker is a load amplifier pointed at your weakest service. Normally it multiplies client requests by one; under downstream failure it multiplies them by three, precisely when the downstream can least absorb it. Three of the absent rows — breaker, timeout, bulkhead — are four lines of Polly away, on a builder that already has Polly referenced.

Two smaller notes in the same inventory. The exponential formula is Math.Pow(http.Interval, retryAttempt) — interval raised to the attempt, not multiplied by a power of two — so for any interval of 1.0 or less the “exponential” backoff is constant or decreasing. And AddTransientHttpErrorPolicy retries on 5xx and 408 regardless of HTTP verb, so a POST that times out downstream is retried; the sample's matchAll route forwards POST and DELETE. The retry feature silently converts at-most-once into at-least-once for every non-idempotent route, and the README advertises “HTTP retries” with no caveat.

The health-check absence has the estate's single most quotable piece of evidence attached to it. The tracing extension's shipped configuration excludes two paths from Jaeger:

  tracing:
    excludePaths:
      - /ping
      - /metrics

extensions\Ntrada.Extensions.Tracing\tracing.yml, duplicated at samples\Ntrada.Samples.Api\ntrada.yml:99-101

Ntrada has no /ping and no /metrics. RouteProvider maps exactly the routes declared under modules: and nothing else. The estate configures tracing exclusions for two endpoints it never creates, because those are the conventional health and metrics paths of the author's other projects and the default value migrated between codebases faster than the endpoints it refers to.

The honest counterweight is that Ntrada is deployed, in its own reference architecture, behind an ingress that does rate limiting and health-gating already — so the omissions are coherent within that deployment model. They are simply never stated, and an undocumented simplification is indistinguishable from a recommendation to anybody reading a teaching repository.

Next, the retrospective — a Christmas Day commit, three years of unactioned dependabot, and a final fix by a stranger.