asp.net core configuration environment variables docker

How ASP.NET Core Reads Environment Variables (and Why It's Double Underscore)

Turn appsettings.json into environment variables for Docker Compose, Kubernetes and Azure App Service: the __ separator, arrays, nulls, prefixes, and the quoting that breaks deployments.

· Mohammed Aquib Ansari

The first time I moved an ASP.NET Core API into a container, I did what everyone does: I copied appsettings.json into the image and then tried to override a couple of values from docker-compose. Logging:LogLevel:Default looked like the obvious variable name. It was silently ignored — Compose passed the variable through, but my shell scripts choked on it and half the team’s tooling refused it outright. The fix was the double underscore, and after that I kept hitting smaller versions of the same problem: arrays, connection strings with semicolons, a password containing $, a Kubernetes value that got “expanded” into nothing.

This guide is what I wish I’d had then. It covers how .NET maps environment variables onto configuration, and how to generate the variables for each platform without hand-escaping anything. The examples come straight out of the appsettings.json to environment variables converter, which applies the same rules as Microsoft.Extensions.Configuration.

How .NET turns JSON into keys

Configuration in ASP.NET Core is a flat dictionary of strings. The JSON provider walks your appsettings.json and joins nested keys with a colon:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": ["orders.example.com", "admin.example.com"]
}

becomes Logging:LogLevel:Default, Logging:LogLevel:Microsoft.AspNetCore, AllowedHosts:0 and AllowedHosts:1. Arrays are not special — each element simply gets its index as a key segment.

Environment variables are read by a separate provider that comes later in the default order (after appsettings.json, appsettings.{Environment}.json and user secrets), which is why they win. That provider does one extra thing: it replaces __ with : in every variable name. So Logging__LogLevel__Default=Debug overrides Logging:LogLevel:Default.

Why the double underscore

A colon is legal in a Windows environment variable name, and setx Logging:LogLevel:Default Debug works there. On Linux the kernel doesn’t care either, but POSIX shells only accept letters, digits and underscores in names you can export, and plenty of tooling follows the shell’s rules. __ is the separator that works everywhere, so it’s the one to standardise on. The converter’s “Colon keys” output exists for Windows and launchSettings.json, and it says so in a warning every time you pick it.

Generating the variables

Paste your appsettings.json into the converter — comments and trailing commas are fine, because .NET accepts both — and pick a target. For this input:

{
  "ConnectionStrings": {
    "Default": "Server=db;Database=Orders;User Id=app;Password=p@ss;word"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": ["orders.example.com", "admin.example.com"],
  "Features": { "MaxItemsPerOrder": 50, "RetryPolicy": null }
}

the .env output is:

ConnectionStrings__Default='Server=db;Database=Orders;User Id=app;Password=p@ss;word'
Logging__LogLevel__Default=Information
Logging__LogLevel__Microsoft.AspNetCore=Warning
AllowedHosts__0=orders.example.com
AllowedHosts__1=admin.example.com
Features__MaxItemsPerOrder=50
Features__RetryPolicy=

Three things in there are worth slowing down for.

The connection string is quoted. It contains spaces and semicolons. In a .env file that’s read by Docker Compose, single quotes keep the value literal.

The number became text. Environment variables are always strings. .NET converts 50 back into an int when it binds your options class, so nothing is lost, but the tool notes it so you’re not surprised when you look at docker inspect.

The null became empty. There’s no way to express null in an environment variable. .NET reads an empty variable as an empty string, and if your code distinguishes “not configured” from “configured as empty”, that difference disappears here. Empty objects and empty arrays are worse: they produce no variables at all, and the converter lists every path it dropped so you can decide whether that matters.

Quoting is where deployments break

Each target has its own escaping rules, and they are not interchangeable.

For bash, the converter uses single quotes and writes a literal ' as '\''. It also refuses to emit something bash can’t run:

export ConnectionStrings__Default='Server=db;Database=Orders;User Id=app;Password=p@ss;word'
export Logging__LogLevel__Default=Information
# skipped: "Logging__LogLevel__Microsoft.AspNetCore" is not a valid shell variable name

That dotted category name is a real gotcha. Microsoft.AspNetCore is a perfectly normal logging category, but export Logging__LogLevel__Microsoft.AspNetCore=Warning is a syntax error. The variable can still reach your process — Docker, Kubernetes and env "NAME=value" dotnet app.dll all pass it through — you just can’t set it from a shell script. PowerShell handles it with the ${env:…} form:

${env:Logging__LogLevel__Microsoft.AspNetCore} = 'Warning'

Docker Compose interpolates $VAR inside the compose file itself, so a password like pa$$word needs every $ doubled. The Compose output does that for you and double-quotes every value, which also stops YAML from turning true or 50 into a boolean or a number.

Kubernetes has its own trap: the kubelet expands $(OTHER_VAR) references inside value:. If a secret happens to contain $(, it gets mangled. The converter writes $$(, which Kubernetes treats as a literal:

env:
  - name: ConnectionStrings__Default
    value: "Server=db;Database=Orders;User Id=app;Password=p@ss;word"
  - name: Features__MaxItemsPerOrder
    value: "50"

For Azure App Service, the “Advanced edit” view on the Configuration blade takes a JSON array of { "name", "value", "slotSetting" } objects. The Azure output produces exactly that, so you can paste a whole file’s worth of settings at once instead of clicking “New application setting” forty times. On Linux plans, stick with __ — Azure rejects colons there.

Prefixes

If your app calls builder.Configuration.AddEnvironmentVariables("ORDERS_"), .NET only reads variables that start with ORDERS_ (case-insensitively) and strips it before mapping __ to :. It’s a good habit on shared hosts where other processes set their own variables. Put the same prefix in the tool’s Prefix field and every name comes out as ORDERS_Logging__LogLevel__Default.

Going the other way

The reverse direction is the one I use most when debugging. Something in a running container is misconfigured, and all I have is the environment block from a Compose file or a pod spec. Paste it in, and the tool rebuilds the nested JSON so I can compare it with appsettings.json side by side:

environment:
  - Logging__LogLevel__Default=Debug
  - AllowedHosts__0=a.example.com
  - AllowedHosts__2=c.example.com
  - Features__MaxItemsPerOrder=25

gives:

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug"
    }
  },
  "AllowedHosts": {
    "0": "a.example.com",
    "2": "c.example.com"
  },
  "Features": {
    "MaxItemsPerOrder": 25
  }
}

Notice that AllowedHosts came back as an object, not an array, with a warning. Index 1 is missing, and the tool won’t invent it. That’s usually exactly the bug: someone removed an element from the array in one environment and left a stale __2 behind in another. .NET will happily bind indexes 0 and 2 and skip 1.

The input format is auto-detected — .env lines, export lines, $env: assignments, Compose list or map syntax, a Kubernetes env: list, or Azure’s JSON — and you can override it if the guess is wrong. “Infer types” writes true, false and numbers as JSON literals; turn it off if you want every value kept as the string it really is.

Case sensitivity

.NET configuration keys are case-insensitive. Linux environment variables are not. That combination produces bugs that are hard to see: Logging__LogLevel__Default and LOGGING__LOGLEVEL__DEFAULT can both exist in a container, and .NET keeps whichever it reads last. In JSON it’s worse — two keys that differ only by case in the same object make the JSON provider throw “A duplicate key was found” at startup. The converter warns about both situations.

Secrets

appsettings files and environment blocks tend to contain connection strings and keys. The conversion runs in your browser rather than on a server, and the Share button switches itself off when the input contains anything that looks like a password, secret, token, key or connection string. There’s also a “Mask secrets” toggle that replaces those values with ******** if you want to paste the output into a ticket.

Masking isn’t a substitute for real secret storage, though. Environment variables are visible to anyone who can run docker inspect or read the pod spec. For production credentials I use Kubernetes Secrets or Azure Key Vault references, and keep only non-sensitive configuration in plain variables. If you’re starting from a flat .env file rather than appsettings.json, the Env Variable Converter covers the Dockerfile, GitHub Actions and ConfigMap/Secret targets.

Checklist

  • Use __, not :, anywhere outside Windows.
  • Arrays are Name__0, Name__1 — keep indexes contiguous.
  • Null and empty containers don’t survive the trip; check the converter’s warnings.
  • Quote per target: bash '…', PowerShell '…' with doubled quotes, Compose $$, Kubernetes $$(.
  • Match your AddEnvironmentVariables prefix.
  • Watch for keys that differ only by case.

When in doubt, paste the environment block back into the converter in reverse mode and read the JSON. It’s the fastest way I know to see configuration the way .NET sees it.