blog
Override appsettings.json with environment variables in .NET
Override nested appsettings.json values in ASP.NET Core with portable environment names, optional prefixes, startup validation, and safer secret handling.
ASP.NET Core can replace any appsettings.json value with an environment variable. For a nested key, replace each colon in the configuration path with a double underscore. That is the whole trick:
JSON path:
EmailDelivery:ApiKey
Environment name:
EmailDelivery__ApiKey
You normally do not need to call AddEnvironmentVariables() yourself. WebApplication.CreateBuilder(args) already loads environment variables after the JSON configuration files, so the environment value wins.
A complete nested setting example
Suppose an application keeps non-secret email defaults in appsettings.json:
{
"EmailDelivery": {
"TimeoutMilliseconds": 60000,
"InboxId": ""
}
}
The API key is deliberately missing. Set it when the process starts rather than committing it to the repository.
On macOS or Linux:
export EmailDelivery__ApiKey="your-api-key"
export EmailDelivery__InboxId="your-inbox-id"
dotnet run
In PowerShell:
$env:EmailDelivery__ApiKey = "your-api-key"
$env:EmailDelivery__InboxId = "your-inbox-id"
dotnet run
Inside the application, both sources form one configuration tree:
var builder = WebApplication.CreateBuilder(args);
var apiKey = builder.Configuration["EmailDelivery:ApiKey"];
var inboxId = builder.Configuration["EmailDelivery:InboxId"];
var timeout = builder.Configuration.GetValue<int>(
"EmailDelivery:TimeoutMilliseconds"
);
Notice that C# reads the key with colons even though the shell sets it with double underscores. The .NET environment-variable provider converts __ to : so the same name works on Windows, macOS, Linux, and container platforms.
Why the environment value overrides appsettings.json
Configuration providers are applied in order. In a normal ASP.NET Core application, later providers replace earlier values with the same key. The useful part of the default order is:
appsettings.jsonappsettings.{Environment}.json- User Secrets in the Development environment
- Environment variables
- Command-line arguments
This lets the repository hold safe defaults while each deployment supplies its own endpoint, timeout, inbox ID, or credential. Microsoft's ASP.NET Core configuration guide documents the complete order and the differences between application and host configuration.
There is no need to repeat this in a standard web project:
builder.Configuration.AddEnvironmentVariables();
Adding the provider a second time is usually harmless, but it suggests the default builder forgot something when it did not. Add it explicitly when you built the configuration pipeline yourself, removed the default sources, or need a custom prefix.
Do not add ASPNETCORE_ to every application setting
ASPNETCORE_ and DOTNET_ are used for host configuration, including documented values such as ASPNETCORE_ENVIRONMENT. They are not required prefixes for your own application sections.
For the EmailDelivery:ApiKey application key, use:
EmailDelivery__ApiKey=your-api-key
Treating ASPNETCORE_ as a universal prefix makes configuration harder to reason about and can behave differently when an app changes hosting style. Keep host settings and application settings separate.
Add a custom prefix when several apps share one environment
A prefix can keep a crowded host tidy. Register it after creating the builder:
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables(prefix: "MAILAPP_");
Now set:
export MAILAPP_EmailDelivery__ApiKey="your-api-key"
The provider strips MAILAPP_, leaving the application key EmailDelivery:ApiKey. The prefix is a filter and a namespace; it does not become part of the key your C# code reads.
Pick one prefix convention and document it beside the deployment configuration. A half-prefixed setup is the sort of tiny mystery that can consume a perfectly good afternoon.
Bind related values and fail when required settings are missing
Reading configuration strings throughout the application makes typos easy to miss. Bind one section to a small options class instead:
using System.ComponentModel.DataAnnotations;
public sealed class EmailDeliveryOptions
{
public const string SectionName = "EmailDelivery";
[Required]
public string ApiKey { get; init; } = string.Empty;
[Required]
public string InboxId { get; init; } = string.Empty;
[Range(1000, 120000)]
public int TimeoutMilliseconds { get; init; } = 60000;
}
Register and validate it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<EmailDeliveryOptions>()
.Bind(builder.Configuration.GetRequiredSection(
EmailDeliveryOptions.SectionName
))
.ValidateDataAnnotations()
.ValidateOnStart();
ValidateOnStart() makes a missing key or an invalid timeout stop the application during startup. That is much kinder than discovering the problem when the first password-reset email is already waiting to be sent. See Microsoft's options validation documentation for custom rules and more complex objects.
Do not include the API key in the validation message or log the bound options object. A useful error says which setting is missing, not what every setting contains.
Set the same values in Docker Compose
Container environments use the same double-underscore names. A Compose service might look like this:
services:
web:
build: .
environment:
EmailDelivery__ApiKey: "${MAILSLURP_API_KEY}"
EmailDelivery__InboxId: "${MAILSLURP_INBOX_ID}"
EmailDelivery__TimeoutMilliseconds: "60000"
Here Compose reads MAILSLURP_API_KEY and MAILSLURP_INBOX_ID from the machine or deployment system, then passes the mapped names to the .NET process. Quote numbers and booleans in YAML so the application receives predictable string values for configuration binding.
A .env file is not a built-in ASP.NET Core configuration source. Docker Compose can use one for interpolation, and third-party .NET packages can load one, but WebApplication.CreateBuilder does not read arbitrary .env files on its own.
Keep secrets out of the JSON file and the logs
Environment variables keep credentials out of source control, but they are not encrypted storage. They may be visible to the process, host administrators, diagnostic tools, or an accidental configuration dump. Microsoft's app secrets guidance recommends User Secrets for local development and a controlled secret store for production.
A practical split is:
- keep timeouts, feature switches, and non-sensitive URLs in
appsettings.json; - use
dotnet user-secretsfor a developer's local credentials; - let the deployment platform inject production secrets from its secret manager;
- never print the whole configuration tree to debug one missing value; and
- rotate a MailSlurp API key if it reaches a commit, build log, or screenshot.
The MailSlurp API key guide shows how to create and load a key without putting it in application code.
Why an override sometimes appears to do nothing
The process was already running
Environment variables are read from the process environment. Stop and restart the app after changing one. A variable set in one terminal is not automatically available to a process launched from another terminal or an IDE.
launchSettings.json supplied another value
When a launch profile is active, its environment values can override system settings for that run. Inspect Properties/launchSettings.json, or use dotnet run --no-launch-profile to test the deployment-style environment directly. Do not place real shared credentials in launchSettings.json; it is commonly committed.
The separator is wrong
Use EmailDelivery__ApiKey, not EmailDelivery_ApiKey. A single underscore is an ordinary character and does not create a nested section. Although colons work in some environments, double underscores are the portable form.
A custom prefix was registered but not supplied
If the application calls AddEnvironmentVariables(prefix: "MAILAPP_"), the matching variable must start with MAILAPP_. The C# key still omits the prefix after the provider loads it.
The value cannot be converted
All environment values begin as strings. The options binder can convert common numbers, booleans, enums, and time values, but an invalid value should fail validation. Prefer a clear startup error over silently falling back to a different production behavior.
Test the email path after the configuration loads
A successful startup proves that the application found and parsed its settings. It does not prove the credential is valid or the email journey works.
For a .NET email workflow, follow the configuration check with one real, bounded test: create a controlled inbox, trigger the application, wait for the expected message, and assert the recipient, subject, and link or code. The C# email guide covers SMTP sending and inbox checks, while the Selenium and .NET example shows the same idea in a browser-driven flow. MailSlurp Email Sandbox keeps those messages away from customer inboxes.
The configuration layer should be pleasantly uneventful: one familiar JSON path, one portable environment name, and an early error when something is missing. Once that is true, the interesting part of the application can get on with its job.