Skip to main content
Published / updated

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.json and 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:

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. User secrets — Development only
  4. Environment variables
  5. 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

InterfaceLifetimeRe-reads on change
IOptions<T>SingletonNo — read once at startup
IOptionsSnapshot<T>ScopedYes, once per request
IOptionsMonitor<T>SingletonYes, 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.

SymptomCause
A file change has no effectAn environment variable or command-line argument is winning
A setting is null in productionSet in user secrets, which do not load outside Development
A nested value is not foundWrong separator — : in files, __ in environment variables
An array is not mergedArrays are replaced wholesale, not merged
Options are all defaultThe section name does not match
Works locally, fails deployedlaunchSettings.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 seeCauseFix
Configuration value is nullKey path wrong, or the section is not boundCheck the exact key including casing
Development settings used in productionASPNETCORE_ENVIRONMENT not set on the serverSet the environment variable
A secret ended up in GitPut in appsettings.jsonUse Manage User Secrets; rotate the credential
Overrides do not applyWrong precedence orderLater sources win — command line beats all
appsettings.Development.json ignoredEnvironment is not DevelopmentCheck 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 IConfiguration rather 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

  1. Add a SchoolPortal section and bind it to SchoolPortalOptions. Inject IOptions<T> and read a value.
  2. Override DefaultPageSize in appsettings.Development.json. Confirm MaxPageSize still comes from the base file.
  3. Override it again with an environment variable using __. Confirm it wins.
  4. Override it once more with a command-line argument. Confirm that wins.
  5. Use : in the environment variable name on Linux or in a container. Record what happens.
  6. Misspell the section name in GetSection. Confirm every option is default with no error.
  7. Add [Required] and [MinLength(32)] to JwtOptions.Key with ValidateOnStart(). Leave it empty and run. Record the startup failure.
  8. Remove ValidateOnStart() and run again. Confirm the app starts and fails only when a token is first created.
  9. Move the connection string to user secrets. Confirm nothing sensitive remains in the repository.
  10. Set ASPNETCORE_ENVIRONMENT=Production and run. Confirm user secrets no longer load and the startup check fires.
  11. Inject IOptionsSnapshot<T> into a singleton. Record the exception.
  12. Call GetDebugView() and find which provider supplied DefaultPageSize after step 4.
  13. Define an array in appsettings.json and a shorter one in the Development file. Confirm replacement, not merge.
  14. Run git log -p | grep -i password on 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_ENVIRONMENT controls

Review questions

  1. What is the configuration precedence order, and which source wins?
  2. Why does ValidateOnStart() matter more than validation itself?
  3. Why do user secrets not solve production configuration?
  4. What does GetDebugView() tell you that reading the file does not?

Next: Dependency injection