Configuration and Environments
Before you start
You need: the pipeline (Article 02).
Time: about 45 minutes, plus the practice.
Learning objective
Configure an application across development and production without ever putting a credential in source control, and know which source supplied any given value.
Topics
- Configuration sources and precedence
appsettings.jsonand environment overrides- Reading configuration
- The options pattern
- Validating options at startup
- User secrets
- Environment variables and key vaults
- Environments and conditional behaviour
- Diagnosing a wrong value
Sources and precedence
WebApplication.CreateBuilder(args) loads these in order, each overriding the last:
appsettings.jsonappsettings.{Environment}.json- User secrets — Development only
- Environment variables
- Command-line arguments
Set them in Project Properties → Debug → General → Open debug launch profiles UI → Command line arguments:
--ConnectionStrings:SchoolDb="Server=.;Database=Test;"
That wins over everything, because command-line arguments are last.
Memorise this order. It explains why a change to appsettings.json has no effect when a stale environment variable is set on the server — the most common configuration incident there is.
appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"SchoolDb": ""
},
"SchoolPortal": {
"DefaultPageSize": 20,
"MaxPageSize": 100,
"AcademicYear": "2024-25",
"EnableFeeModule": true,
"SupportEmail": "info@nexcoding.in"
},
"Jwt": {
"Issuer": "https://nexcoding.in",
"Audience": "https://nexcoding.in",
"ExpiryMinutes": 60,
"Key": ""
}
}
appsettings.json is committed, so every secret value is left empty here. The empty SchoolDb and Jwt:Key are deliberate — they document that the setting exists without supplying it.
// appsettings.Development.json — also committed, so still no real secrets
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"SchoolPortal": {
"DefaultPageSize": 5
}
}
Environment overrides merge, they do not replace. SchoolPortal:MaxPageSize survives from the base file; only DefaultPageSize changes.
Arrays are the exception — a whole array is replaced, not merged element by element.
Reading configuration
public class StudentService
{
private readonly IConfiguration _configuration;
public StudentService(IConfiguration configuration)
{
_configuration = configuration;
}
public int GetPageSize()
{
return _configuration.GetValue<int>("SchoolPortal:DefaultPageSize", 20);
}
}
_configuration["SchoolPortal:AcademicYear"]; // string?, null if absent
_configuration.GetValue<int>("SchoolPortal:MaxPageSize");
_configuration.GetConnectionString("SchoolDb"); // shorthand for ConnectionStrings:SchoolDb
_configuration.GetSection("SchoolPortal");
The : separator navigates the hierarchy. In an environment variable it becomes __, because : is not permitted in variable names on Linux:
export SchoolPortal__DefaultPageSize=50
export ConnectionStrings__SchoolDb="Server=nca-sql;Database=NexCodingSchool;..."
Injecting IConfiguration everywhere is a poor habit. Keys are strings, so typos fail silently; there is no type safety; and nothing validates that the value exists. The options pattern fixes all three.
The options pattern
public class SchoolPortalOptions
{
public const string SectionName = "SchoolPortal";
public int DefaultPageSize { get; set; } = 20;
public int MaxPageSize { get; set; } = 100;
public string AcademicYear { get; set; } = string.Empty;
public bool EnableFeeModule { get; set; }
public string SupportEmail { get; set; } = string.Empty;
}
builder.Services.Configure<SchoolPortalOptions>(
builder.Configuration.GetSection(SchoolPortalOptions.SectionName));
public class StudentService
{
private readonly SchoolPortalOptions _options;
public StudentService(IOptions<SchoolPortalOptions> options)
{
_options = options.Value;
}
public int ResolvePageSize(int? requested)
{
if (requested is null or <= 0)
{
return _options.DefaultPageSize;
}
return Math.Min(requested.Value, _options.MaxPageSize);
}
}
Typed, discoverable, and a renamed property is a compile error rather than a silent null.
The three interfaces
| Interface | Lifetime | Re-reads on change |
|---|---|---|
IOptions<T> | Singleton | No — read once at startup |
IOptionsSnapshot<T> | Scoped | Yes, once per request |
IOptionsMonitor<T> | Singleton | Yes, with a change callback |
// Most cases — configuration does not change at run time
public StudentService(IOptions<SchoolPortalOptions> options)
// Per-request, picks up file changes
public StudentService(IOptionsSnapshot<SchoolPortalOptions> options)
// In a singleton that needs current values
public class CacheWarmer
{
private readonly IOptionsMonitor<SchoolPortalOptions> _monitor;
public CacheWarmer(IOptionsMonitor<SchoolPortalOptions> monitor)
{
_monitor = monitor;
_monitor.OnChange(options =>
{
_logger.LogInformation("Configuration changed; page size is now {Size}",
options.DefaultPageSize);
});
}
public int PageSize => _monitor.CurrentValue.DefaultPageSize;
}
IOptionsSnapshot<T> is scoped, so injecting it into a singleton throws at startup — the captive-dependency check catches it. That is the reason IOptionsMonitor<T> exists.
Default to IOptions<T>.
Validating at startup
public class JwtOptions
{
public const string SectionName = "Jwt";
[Required]
public string Issuer { get; set; } = string.Empty;
[Required]
public string Audience { get; set; } = string.Empty;
[Required]
[MinLength(32, ErrorMessage = "The signing key must be at least 32 characters.")]
public string Key { get; set; } = string.Empty;
[Range(1, 1440)]
public int ExpiryMinutes { get; set; } = 60;
}
builder.Services
.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
ValidateOnStart() is the important call. Without it, validation runs when the options are first resolved — which may be on the first request that needs a token, hours after deployment. With it, the application refuses to start:
Microsoft.Extensions.Options.OptionsValidationException:
DataAnnotation validation failed for 'JwtOptions' members: 'Key' with the error:
'The signing key must be at least 32 characters.'
A deployment that fails immediately with that message is far better than one that starts cleanly and fails at login. Validate every setting the application cannot run without.
Custom validation for rules annotations cannot express:
builder.Services
.AddOptions<SchoolPortalOptions>()
.Bind(builder.Configuration.GetSection(SchoolPortalOptions.SectionName))
.Validate(options => options.DefaultPageSize <= options.MaxPageSize,
"DefaultPageSize must not exceed MaxPageSize.")
.ValidateOnStart();
The connection string deserves the same treatment:
var connectionString = builder.Configuration.GetConnectionString("SchoolDb");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"Connection string 'SchoolDb' is not configured. " +
"Set it via user secrets in development or ConnectionStrings__SchoolDb in production.");
}
An exception at startup naming the setting and how to supply it saves the next person an hour.
User secrets
For development credentials, never in the project folder.
Right-click the project → Manage User Secrets. Visual Studio adds a UserSecretsId to the .csproj and opens secrets.json:
{
"ConnectionStrings": {
"SchoolDb": "Server=.;Database=NexCodingSchool;Integrated Security=True;TrustServerCertificate=True;"
},
"Jwt": {
"Key": "a-development-only-signing-key-at-least-32-chars"
}
}
Secrets are stored outside the repository, keyed by a UserSecretsId in the .csproj:
Windows: %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json
Linux: ~/.microsoft/usersecrets/<id>/secrets.json
They load only in the Development environment and only for local runs. They are a convenience, not encryption — the file is plain JSON, readable by anyone with your account.
Production secrets
Environment variables are the baseline:
export ConnectionStrings__SchoolDb="Server=nca-sql;Database=NexCodingSchool;User Id=app;Password=..."
export Jwt__Key="..."
export ASPNETCORE_ENVIRONMENT="Production"
A managed vault is better, because it adds rotation and access auditing:
if (builder.Environment.IsProduction())
{
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{builder.Configuration["KeyVault:Name"]}.vault.azure.net/"),
new DefaultAzureCredential());
}
A credential must never be in appsettings.json. That file is committed, and Git history is permanent — deleting the value in a later commit does not remove it. Anyone who clones the repository has it.
git log -p | grep -i "password\|pwd=\|connectionstring\|api[_-]key"
If that finds anything, rotate the credential first. Cleaning history is secondary and does not undo the exposure.
Environments
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
if (app.Environment.IsEnvironment("Staging"))
{
app.UseSwagger();
}
Development, Staging and Production are the built-in names, and any string is allowed. The value comes from ASPNETCORE_ENVIRONMENT — from launchSettings.json locally, and from the actual environment variable on a server.
Production is the default when the variable is unset. That default is correct: an unconfigured server should not expose developer diagnostics.
builder.Services.AddScoped<IEmailSender>(sp =>
{
if (builder.Environment.IsDevelopment())
{
return new FileEmailSender(sp.GetRequiredService<ILogger<FileEmailSender>>());
}
return new SmtpEmailSender(sp.GetRequiredService<IOptions<SmtpOptions>>());
});
Writing development emails to a file rather than sending them is a good pattern — and it prevents the incident where a test run emails real parents.
Environment-specific appsettings files load automatically:
appsettings.json
appsettings.Development.json
appsettings.Staging.json
appsettings.Production.json
appsettings.Production.json is still committed, so it still holds no secrets — only non-sensitive production settings.
Diagnosing a wrong value
Configuration problems are almost always precedence problems. Work in this order.
1. Print the resolved value at startup.
app.Logger.LogInformation("Environment: {Environment}", app.Environment.EnvironmentName);
app.Logger.LogInformation("Page size: {PageSize}",
app.Configuration.GetValue<int>("SchoolPortal:DefaultPageSize"));
2. Dump the whole configuration, with secrets masked.
if (app.Environment.IsDevelopment())
{
foreach (var entry in app.Configuration.AsEnumerable().OrderBy(e => e.Key))
{
var value = entry.Key.Contains("Password", StringComparison.OrdinalIgnoreCase)
|| entry.Key.Contains("Key", StringComparison.OrdinalIgnoreCase)
? "***"
: entry.Value;
app.Logger.LogDebug("{Key} = {Value}", entry.Key, value);
}
}
3. Find which provider supplied it.
var root = (IConfigurationRoot)app.Configuration;
app.Logger.LogDebug(root.GetDebugView());
GetDebugView() prints every key with its value and the provider that won. It answers "why is this value not what the file says" directly, and it is the single most useful diagnostic here.
4. Check for a stale environment variable. printenv | grep -i school on the server. An environment variable set months ago overrides every file change.
| Symptom | Cause |
|---|---|
| A file change has no effect | An environment variable or command-line argument is winning |
A setting is null in production | Set in user secrets, which do not load outside Development |
| A nested value is not found | Wrong separator — : in files, __ in environment variables |
| An array is not merged | Arrays are replaced wholesale, not merged |
| Options are all default | The section name does not match |
| Works locally, fails deployed | launchSettings.json set the environment; the server does not |
The "section name does not match" case is worth watching for: Configure<T> binds silently, so a typo in GetSection("SchoolPortl") produces an options object with every property at its default and no error at all.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
Configuration value is null | Key path wrong, or the section is not bound | Check the exact key including casing |
| Development settings used in production | ASPNETCORE_ENVIRONMENT not set on the server | Set the environment variable |
| A secret ended up in Git | Put in appsettings.json | Use Manage User Secrets; rotate the credential |
| Overrides do not apply | Wrong precedence order | Later sources win — command line beats all |
appsettings.Development.json ignored | Environment is not Development | Check the launch profile |
A committed secret is a leaked secret. Deleting it in a later commit does not remove it from history; the only real fix is rotating it.
Common mistakes
- A credential in
appsettings.json - Not knowing the precedence order
:instead of__in an environment variable- Injecting
IConfigurationrather than typed options - No
ValidateOnStart(), so a missing setting fails on first use - A typo in the section name, silently binding nothing
IOptionsSnapshot<T>injected into a singleton- Expecting user secrets to load in Production
- Expecting arrays to merge across environment files
- No startup check on the connection string
- Not using
GetDebugView()when a value is wrong
Practice
- Add a
SchoolPortalsection and bind it toSchoolPortalOptions. InjectIOptions<T>and read a value. - Override
DefaultPageSizeinappsettings.Development.json. ConfirmMaxPageSizestill comes from the base file. - Override it again with an environment variable using
__. Confirm it wins. - Override it once more with a command-line argument. Confirm that wins.
- Use
:in the environment variable name on Linux or in a container. Record what happens. - Misspell the section name in
GetSection. Confirm every option is default with no error. - Add
[Required]and[MinLength(32)]toJwtOptions.KeywithValidateOnStart(). Leave it empty and run. Record the startup failure. - Remove
ValidateOnStart()and run again. Confirm the app starts and fails only when a token is first created. - Move the connection string to user secrets. Confirm nothing sensitive remains in the repository.
- Set
ASPNETCORE_ENVIRONMENT=Productionand run. Confirm user secrets no longer load and the startup check fires. - Inject
IOptionsSnapshot<T>into a singleton. Record the exception. - Call
GetDebugView()and find which provider suppliedDefaultPageSizeafter step 4. - Define an array in
appsettings.jsonand a shorter one in the Development file. Confirm replacement, not merge. - Run
git log -p | grep -i passwordon your repository.
Exercises 7 and 12 are the two that save the most time in production.
You can now
- Configure an application across environments
- Keep every credential out of source control with User Secrets
- Say which configuration source wins
- Bind a section to a typed options class
- Explain what
ASPNETCORE_ENVIRONMENTcontrols
Review questions
- What is the configuration precedence order, and which source wins?
- Why does
ValidateOnStart()matter more than validation itself? - Why do user secrets not solve production configuration?
- What does
GetDebugView()tell you that reading the file does not?
Next: Dependency injection