ASP.NET Core reads configuration from a stack of sources, and the last one to set a key wins: appsettings.json, then appsettings.{Environment}.json, then user secrets (in Development only), then environment variables, then command-line arguments. On your laptop, secrets come from user secrets. On a server, they come from environment variables, written with a double underscore where the JSON had nesting - ConnectionStrings__Default, Smtp__Password. Bind each section to a typed class with the options pattern, validate it when the app starts, and a missing password becomes a clear error in the first second rather than a null reference on the first request.
That is the whole model. The rest of this post is the detail that decides whether it works: how keys are named, which interface rereads changes, what user secrets actually are, and the mistakes that leave a production app running on development settings.
Where configuration comes from#
WebApplication.CreateBuilder(args) sets up the default sources for you. In order, from lowest priority to highest:
| Order | Source | Loaded when |
|---|---|---|
| 1 | appsettings.json | Always, if present |
| 2 | appsettings.{Environment}.json | Always, if present |
| 3 | User secrets | Environment is Development |
| 4 | Environment variables | Always |
| 5 | Command-line arguments | Always |
Every source contributes a flat set of keys. When two sources set the same key, the later one wins; keys that only one source sets survive untouched. That is what makes the layering useful: appsettings.json holds every setting with a safe default, the environment file changes a handful, and the environment variables on the server supply the few that are secret or specific to that machine.
It also means a stale value can hide. An environment variable set on the server months ago overrides whatever you change in appsettings.Production.json today, and nothing tells you. When a setting refuses to change, look at the higher layers first.
Keys, sections and the double underscore#
Configuration is a tree flattened into keys joined by colons. This JSON:
{ "ConnectionStrings": { "Default": "Host=localhost;Database=shop;Username=shop;Password=dev" }, "Smtp": { "Host": "smtp.example.com", "Port": 587, "From": "shop@example.com" }, "Cors": { "Origins": [ "https://example.com", "https://www.example.com" ] }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }}produces keys such as Smtp:Host, Cors:Origins:0 and Logging:LogLevel:Default. Arrays become numbered children. Keys are case-insensitive, so smtp:host reads the same value.
Environment variable names cannot portably contain a colon, so the environment provider accepts a double underscore instead and translates it:
ConnectionStrings__Default=Host=203.0.113.10;Port=5432;Database=shop;Username=shop;Password=...Smtp__Password=...Cors__Origins__0=https://example.comCors__Origins__1=https://www.example.comLogging__LogLevel__Default=WarningTwo details here catch people. An array set from environment variables replaces elements by index, not the whole array, so if the JSON has three origins and the environment sets __0 and __1, the third from JSON is still there. And a single underscore is just a character: Smtp_Password is a different key from Smtp:Password and is silently ignored by everything that reads the latter.
Reading values directly works for one-offs:
var connection = builder.Configuration.GetConnectionString("Default");var smtpHost = builder.Configuration["Smtp:Host"];var port = builder.Configuration.GetValue<int>("Smtp:Port", 587);GetConnectionString("Default") is shorthand for Configuration["ConnectionStrings:Default"]. For anything with more than one setting, use the options pattern below instead of scattering string keys through the code.
Environments and per-environment files#
The environment name comes from ASPNETCORE_ENVIRONMENT, or DOTNET_ENVIRONMENT if that is not set, and defaults to Production when neither is. The templates set Development in launchSettings.json, which is used by dotnet run and Visual Studio but not by a published app - which is exactly why a server runs as Production without you doing anything.
The environment decides three things:
- which
appsettings.{Environment}.jsonis loaded; - whether user secrets are loaded (only in Development, by default);
- what
app.Environment.IsDevelopment()returns, which the templates use to switch the developer exception page on and HSTS off.
Use Staging for a second deployment that should behave like production but point at test data. IsStaging() exists and appsettings.Staging.json is loaded automatically. Staging and production on one account covers running the two side by side.
What belongs in each file: appsettings.json gets every key the app reads, with values that are safe to commit - hosts, ports, feature flags, timeouts. appsettings.Development.json gets local overrides such as verbose logging. appsettings.Production.json is often unnecessary; if it exists, it holds non-secret production values. None of them hold passwords, API keys or connection strings with credentials in them, because all of them are committed.
User secrets in development#
User secrets keep development credentials out of the repository by storing them in your user profile instead of the project folder.
$ dotnet user-secrets init$ dotnet user-secrets set "ConnectionStrings:Default" "Host=localhost;Password=dev-only"$ dotnet user-secrets set "Smtp:Password" "app-password-for-testing"$ dotnet user-secrets listinit adds a UserSecretsId GUID to the project file. The values go into a plain JSON file at %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json on Windows, or ~/.microsoft/usersecrets/<id>/secrets.json on Linux and macOS.
Understand what that is and is not. The file is not encrypted; it is ordinary JSON in your home directory. Its only job is to keep secrets out of the project tree, so they cannot be committed by accident. It is loaded only when the environment is Development, so it is never a deployment mechanism. A team shares nothing through it - each developer sets their own.
That last point produces the most common first-deploy failure in .NET: everything works locally because the connection string lives in user secrets, and on the server the key simply does not exist. The app starts, takes a request, and throws because the connection string is null. Validation at startup, below, turns that into an immediate and readable failure.
Secrets on the server#
On a server, environment variables are the standard place for secrets. They are set outside the repository, read through the normal configuration system with no code change, and they override every file.
On RE:NODE you set them on the Startup tab of the server, and they apply on the next start of the container - so change a value, then restart. An app plan includes two database slots, created in the panel with a generated host, user and password; copy those into ConnectionStrings__Default rather than putting them in a committed file. Environment variables and secrets covers the general rules and what to do on the day a secret ends up in Git history anyway.
Things not to do:
- Do not commit a production `appsettings.Production.json` with credentials and add it to `.gitignore` later. It is in the history. Rotate the credential.
- Do not log your configuration.
IConfigurationRoot.GetDebugView()is a useful method that prints every key, every value and which provider set it - including passwords. Use it locally; never write its output to a production log. - Do not pass secrets as command-line arguments. They show up in process listings and are easy to paste into a support ticket.
If you would rather read secrets from files than from the environment - one file per key, the way container secret mounts work - the Microsoft.Extensions.Configuration.KeyPerFile package adds that as a source with builder.Configuration.AddKeyPerFile("/path/to/secrets", optional: true). A file named Smtp__Password in that directory becomes the key Smtp:Password. External vaults (Azure Key Vault, HashiCorp Vault, AWS Secrets Manager) have configuration providers too; they make sense when you already run one, not as a first step for a single app.
One setting, followed through every layer
It helps to trace a single section from laptop to server. Take the Smtp section above.
On your laptop the environment is Development. Smtp:Host, Smtp:Port and Smtp:From come from appsettings.json. appsettings.Development.json changes Smtp:Host to localhost and Smtp:Port to 1025, pointing at a local mail catcher. Smtp:Password comes from user secrets. Four keys, three sources, and nothing secret in the repository.
On the server the environment is Production. appsettings.Development.json and user secrets are not loaded at all. Smtp:Host, Smtp:Port and Smtp:From come from appsettings.json again, so the committed defaults should be the production ones. Smtp:Password comes from the Smtp__Password environment variable on the Startup tab. If you later need to point production at a different relay for an afternoon, setting Smtp__Host in the environment overrides the file without a commit, and removing the variable puts the committed value back.
The habit that falls out of this: commit production-safe defaults to the base file, put development conveniences in the Development file, and keep the environment for secrets and for deliberate, temporary overrides. If you find yourself setting a dozen non-secret variables on the server, those values probably belong in a committed file instead, where they are reviewed and versioned like the rest of the code.
The options pattern#
Rather than reading strings, bind a section to a class:
public sealed class SmtpOptions{ public const string Section = "Smtp"; [Required] public string Host { get; set; } = ""; [Range(1, 65535)] public int Port { get; set; } = 587; [Required, EmailAddress] public string From { get; set; } = ""; [Required] public string Password { get; set; } = "";}builder.Services.AddOptions<SmtpOptions>() .BindConfiguration(SmtpOptions.Section) .ValidateDataAnnotations() .ValidateOnStart();Then inject it where it is needed. There are three interfaces, and the difference is when the values are read:
| Interface | Lifetime | Sees changes after start |
|---|---|---|
IOptions<T> | Singleton | No - read once, on first use |
IOptionsSnapshot<T> | Scoped | Yes - recomputed per request |
IOptionsMonitor<T> | Singleton | Yes - CurrentValue, plus OnChange |
Use IOptions<T> by default. It is the cheapest and the most predictable: what the app read at startup is what it uses until it restarts. IOptionsSnapshot<T> cannot be injected into a singleton, because it is scoped; that mistake produces a dependency injection error at startup. IOptionsMonitor<T> is for singletons that genuinely need to react to changes, such as a background service whose polling interval you want to tune live.
The binder sets public properties with setters, matching names case-insensitively. A property with no matching key keeps its default, silently. That is why validation matters: without it, a typo in a key name produces an object full of defaults and no error at all.
Validating at startup#
ValidateDataAnnotations() checks the attributes on the class. ValidateOnStart() makes the check run when the host starts, instead of the first time something asks for the options. Together they turn a missing secret into this, in the first second of the log:
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException:DataAnnotation validation failed for 'SmtpOptions' members: 'Password' with the error:'The Password field is required.'.That is the behaviour you want on a server. A process that refuses to start with a sentence naming the missing key is a two-minute fix. One that starts, accepts traffic and fails on the first email is an incident.
For rules that attributes cannot express, add a delegate:
builder.Services.AddOptions<SmtpOptions>() .BindConfiguration(SmtpOptions.Section) .Validate(o => o.Port != 25 || o.Host.EndsWith(".internal"), "Port 25 is only allowed for the internal relay.") .ValidateOnStart();Data annotations validate the top-level properties of the options class; nested objects are not validated recursively unless you validate them explicitly. If your options have nested sections, give each its own options class and its own validation.
A process that exits at startup will be restarted by most platforms, and repeatedly. On RE:NODE, three unexpected restarts within an hour raise a warning on the server page and open a ticket automatically, so a configuration error does not loop unnoticed. Read the first lines of the log rather than the last.
Reloading without a redeploy#
The JSON sources are registered with reloadOnChange: true, so editing appsettings.json on the server's disk updates the configuration in a running app. Whether your code notices depends on the interface: IOptionsMonitor<T> and IOptionsSnapshot<T> see the change, IOptions<T> does not, and values you copied into fields at startup obviously do not.
Logging is the one place this is genuinely useful. Log levels are read through the monitor, so changing Logging:LogLevel:Default in the file on disk raises or lowers verbosity without a restart - handy while chasing a production problem. For everything else, treat configuration as fixed for the life of the process, change it through the environment, and restart. Environment variables are read once at process start and never reload. Files edited on the server are also overwritten by the next deploy if they are tracked in the repository, which is a good reason not to hand-edit tracked files on a server at all. Logs worth keeping has more on choosing levels.
Common mistakes#
The setting will not change. A higher layer sets it. Check environment variables first, then command-line arguments.
It works locally and is null on the server. The value lives in user secrets, which only load in Development. Set it as an environment variable on the server.
The server is running with development settings. ASPNETCORE_ENVIRONMENT=Development was copied into the server's environment from a tutorial. Remove it; the default is Production.
An environment variable is ignored. A single underscore instead of two, a typo in the section name, or the app was not restarted after the change.
Options are all defaults. The section name passed to BindConfiguration does not match the JSON, or the properties have no setters. Validation would have caught it.
"Cannot consume scoped service IOptionsSnapshot from singleton". Use IOptionsMonitor<T> in singletons.
A number or boolean is wrong. Every configuration value is a string until it is bound. "Port": "587" and "Port": 587 bind the same, but a boolean written as yes, or a port written as 587/tcp, fails conversion - and the error names the key, so read it.
The secret is in the log anyway. Something logged the whole options object, or an exception message included the connection string. Override ToString() on options classes that hold secrets, and check what your logging library does with objects passed as structured properties.
FAQ#
Is it safe to keep secrets in environment variables?
Safe enough for most apps, and far safer than committed files. Anyone who can read the process environment or the panel's Startup tab can read them, so limit who has that access - subusers and least privilege covers splitting panel permissions.
Should appsettings.Production.json be committed?
Yes, if it holds only non-secret values. If it needs a secret, that secret belongs in an environment variable instead, and the file should not contain it at all.
Does ASPNETCORE_ENVIRONMENT need to be set on a server?
No. The default is Production, which is correct. Set it only for a deliberately different environment such as Staging.
How do I set a connection string with a semicolon in an environment variable?
Set it as it is; the semicolons are part of the value and need no escaping on the Startup tab. Only a shell would need quotes around it. .NET with PostgreSQL, MySQL or SQL Server shows a working connection string for each provider.
Can I change configuration without restarting the app?
For values read through IOptionsMonitor<T> or log levels, editing the JSON file on disk works. Environment variables always need a restart. For a predictable app, prefer restarting.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.