DEV Community

Cover image for Configuration in ASP.NET Core
Rhuturaj Takle
Rhuturaj Takle

Posted on

Configuration in ASP.NET Core

Configuration in ASP.NET Core

A deep-dive walkthrough of configuration in ASP.NET Core — covering IConfiguration and the layered provider model underneath it, the precise precedence order across appsettings.json, environment-specific overrides, environment variables, and command-line arguments, User Secrets as a development-only mechanism worth understanding structurally, Azure Key Vault for production secrets, the Options pattern revisited in full depth, options validation, and configuration reloading — the whole system that decides what value a running application actually sees for any given setting.


Table of Contents

  1. Introduction
  2. IConfiguration and the Provider Model
  3. The Default Provider Stack and Its Precedence Order
  4. Hierarchical Keys: The Colon Separator
  5. appsettings.json and Environment-Specific Overrides
  6. Environment Variables
  7. User Secrets: Development-Only, and Why That Matters
  8. Azure Key Vault: Production Secrets
  9. The Options Pattern, Revisited in Depth
  10. Binding Complex Objects and Arrays
  11. Options Validation
  12. Configuration Reloading
  13. Custom Configuration Providers
  14. Common Pitfalls
  15. Quick Reference Table
  16. Conclusion

Introduction

An ASP.NET Core application's configuration doesn't come from one single file — it's assembled, at startup, from a layered stack of sources (JSON files, environment variables, command-line arguments, secret stores), each capable of overriding values from the sources before it, merged into one unified IConfiguration that the rest of the application reads from without needing to know or care which specific source a given value actually came from. This is architecturally the same layered-provider idea this series' Logging guide's Section 1 describes for ILogger — a thin, unified abstraction over a pluggable, ordered stack of underlying sources — and this guide goes deep on exactly how that stack is assembled, in what precedence order, and on the specific sources (User Secrets, Azure Key Vault) that exist specifically to keep sensitive configuration values out of source control entirely, building directly on the Options pattern this series' ASP.NET Core Dependency Injection guide's Section 12 introduces.

appsettings.json (base)
   ↓ overridden by
appsettings.{Environment}.json
   ↓ overridden by
User Secrets (Development only, Section 6)
   ↓ overridden by
Environment Variables
   ↓ overridden by
Command-line arguments (highest precedence)

Every layer's value for a given KEY overrides whatever a PRIOR layer set —
the FINAL, merged result is what IConfiguration actually returns.
Enter fullscreen mode Exit fullscreen mode

1. IConfiguration and the Provider Model

IConfiguration: the same thin-abstraction-over-providers shape this series' Logging guide already establishes

public class SomeService
{
    private readonly IConfiguration _configuration;
    public SomeService(IConfiguration configuration) => _configuration = configuration; // DI-resolved, per
                                                                                            //  this series' ASP.NET
                                                                                            //  Core DI guide

    public void DoSomething()
    {
        string? apiKey = _configuration["ExternalApi:ApiKey"]; // reads from WHICHEVER provider actually
                                                                   //  supplied the winning value
    }
}
Enter fullscreen mode Exit fullscreen mode

IConfiguration itself doesn't know or care whether a given key's value came from a JSON file, an environment variable, or a secret store — exactly the same separation of concerns this series' Logging guide's Section 1 describes for ILogger/ILoggerProvider, here applied to configuration sources instead of log destinations. IConfiguration is registered in the DI container automatically by WebApplication.CreateBuilder, and is injectable anywhere, exactly like any other service.

IConfigurationProvider: what actually supplies the key-value pairs underneath

Each registered source (JSON file, environment variables, etc.) is
  backed by its own IConfigurationProvider implementation — at startup,
  EVERY provider is read, in REGISTRATION ORDER, and the results are
  MERGED into a single, flat key-value store — later providers'
  values WIN over earlier ones for the SAME key (Section 2 covers this
  precedence precisely).
Enter fullscreen mode Exit fullscreen mode

This is the architectural mechanism underneath the whole layering system this guide is built around — worth knowing explicitly that "layering" isn't a special configuration-specific feature; it's the direct, mechanical consequence of registering multiple providers and letting later ones override earlier ones for any key they both happen to define.


2. The Default Provider Stack and Its Precedence Order

WebApplication.CreateBuilder registers this exact stack, in this exact order, automatically

1. appsettings.json
2. appsettings.{EnvironmentName}.json   (e.g., appsettings.Development.json)
3. User Secrets                          (Development environment ONLY, Section 6)
4. Environment variables
5. Command-line arguments                (HIGHEST precedence — always wins)
Enter fullscreen mode Exit fullscreen mode

This ordering is worth memorizing precisely, since it's the answer to "why is my setting not taking effect" more often than almost anything else in a typical ASP.NET Core troubleshooting session — a value set via an environment variable will always override the same key set in appsettings.json, regardless of which file looks more "authoritative" to a developer reading it; the registration order, not any file's apparent importance, is what determines the winner.

Why this specific order makes sense, given what each source is actually for

Base defaults (appsettings.json) → environment-specific refinements
  (appsettings.{Env}.json) → LOCAL developer secrets (User Secrets,
  Section 6) → DEPLOYMENT-time overrides (environment variables,
  typically set by the hosting platform/container orchestrator) →
  EXPLICIT, per-invocation overrides (command-line arguments, useful
  for a one-off run with a specific setting temporarily changed).
Enter fullscreen mode Exit fullscreen mode

Each layer represents a progressively more specific, more recent, or more deployment-context-aware source of truth — this is precisely why later layers override earlier ones: a value explicitly set for this specific deployment (an environment variable set by your container orchestrator) should reasonably win over a generic default baked into a JSON file checked into source control, and an explicit command-line flag for this specific run should win over everything else.

Adding custom sources, and where they fit in the precedence order

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("customsettings.json", optional: true);
builder.Configuration.AddAzureKeyVault(/* ... */, Section 7); // typically added AFTER the defaults, to override them
Enter fullscreen mode Exit fullscreen mode

Every Add... call on builder.Configuration appends a new provider to the stack, at whatever point in your Program.cs you call it — this is a genuinely important, sometimes-overlooked detail: because precedence is purely about registration order, adding Azure Key Vault (Section 7) before environment variables would let an environment variable override a Key Vault secret, which is very likely not what you'd actually want for production secrets — the order you call these methods in Program.cs is a real, consequential design decision, not just setup boilerplate.


3. Hierarchical Keys: The Colon Separator

Nested JSON structure maps to colon-separated keys

{
  "ExternalApi": {
    "BaseUrl": "https://api.example.com",
    "Timeout": {
      "Seconds": 30
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
string? baseUrl = _configuration["ExternalApi:BaseUrl"];        // "https://api.example.com"
string? timeoutSeconds = _configuration["ExternalApi:Timeout:Seconds"]; // "30" (as a STRING — Section 8 covers typed binding)
Enter fullscreen mode Exit fullscreen mode

Every level of JSON nesting becomes another :-separated segment in the flattened key — this is how a hierarchical JSON structure and a flat environment variable (Section 5, which can't natively express nesting the way JSON can) end up able to override the exact same logical setting, despite their very different native shapes.

GetSection: working with a whole sub-tree at once, rather than one key at a time

IConfigurationSection apiSection = _configuration.GetSection("ExternalApi");
string? baseUrl = apiSection["BaseUrl"]; // relative to the SECTION — no need to repeat "ExternalApi:" here
Enter fullscreen mode Exit fullscreen mode

GetSection returns a scoped view into one branch of the configuration tree, letting code that only cares about one logical group of settings work with relative keys rather than repeating a long, fully-qualified key prefix on every access — this is also the foundation Section 8's Options pattern binding is built directly on top of.


4. appsettings.json and Environment-Specific Overrides

The base file, and the environment-specific file that layers on top of it

// appsettings.json (base — checked into source control, safe/shared defaults)
{
  "ExternalApi": { "BaseUrl": "https://api-staging.example.com", "Timeout": 30 }
}
Enter fullscreen mode Exit fullscreen mode
// appsettings.Production.json (overrides ONLY what genuinely differs in Production)
{
  "ExternalApi": { "BaseUrl": "https://api.example.com" }
}
Enter fullscreen mode Exit fullscreen mode

Per Section 2's precedence, appsettings.{Environment}.json is loaded after the base appsettings.json, so it only needs to specify keys that genuinely differ for that environment — Timeout isn't repeated in the Production file above because the base file's value of 30 is already correct there too; only BaseUrl genuinely needs overriding.

How {EnvironmentName} is determined: the ASPNETCORE_ENVIRONMENT environment variable

The specific environment-named file loaded (appsettings.Development.json,
  appsettings.Production.json, etc.) is determined by the value of the
  ASPNETCORE_ENVIRONMENT environment variable at STARTUP — this is,
  itself, one of the very FEW configuration values read before the
  configuration SYSTEM itself is fully assembled, since it determines
  WHICH files that system will even attempt to load.
Enter fullscreen mode Exit fullscreen mode

Worth knowing this as a genuine bootstrapping detail — ASPNETCORE_ENVIRONMENT is read specially, early, precisely because it determines which appsettings.*.json file even gets added to the provider stack in the first place; it isn't itself subject to the full layering this guide otherwise describes, since it's what decides part of that layering.

Never put genuinely sensitive values directly in appsettings.json, even the environment-specific ones

appsettings.json and its environment-specific variants are typically
  CHECKED INTO SOURCE CONTROL — connection strings with embedded
  credentials, API keys, or any genuinely sensitive value committed here
  is committed to your repository's history PERMANENTLY, discoverable
  by anyone with repository access, past or present, even if later removed.
Enter fullscreen mode Exit fullscreen mode

This is precisely the gap Sections 6 and 7 exist to close — appsettings.json is the right place for genuinely non-sensitive configuration (a base URL, a timeout, a feature flag), and the wrong place for anything that would constitute a real security exposure if it leaked into source control history.


5. Environment Variables

A flat, universal mechanism, readable across essentially any hosting platform or language

export ExternalApi__BaseUrl="https://api.example.com" # DOUBLE UNDERSCORE maps to the colon-separated hierarchy
Enter fullscreen mode Exit fullscreen mode

Environment variables are the standard way most hosting platforms, container orchestrators (Docker, Kubernetes), and CI/CD systems inject deployment-specific configuration — because environment variable names can't contain a literal : on every platform, .NET's environment variable provider specifically maps a double underscore (__) to the same hierarchical : structure Section 3 describes, so ExternalApi__BaseUrl becomes exactly ExternalApi:BaseUrl once loaded into IConfiguration.

Why environment variables are the standard mechanism for container-based deployments specifically

A Docker container, or a Kubernetes pod, is typically configured
  entirely through environment variables set by the orchestration layer
  at DEPLOYMENT time — the SAME container image can run in staging or
  production, entirely unmodified, with environment variables alone
  determining which environment-specific configuration it actually uses.
Enter fullscreen mode Exit fullscreen mode

This is worth knowing as the genuine, practical reason environment variables sit where they do in Section 2's precedence order — they're specifically the mechanism through which the infrastructure deploying an application (rather than the application's own checked-in files) supplies configuration, which is exactly why they need to be able to override whatever the checked-in appsettings.*.json files say.


6. User Secrets: Development-Only, and Why That Matters

The problem: a developer needs a real API key or connection string locally, without ever committing it

A developer's LOCAL machine often needs real, working credentials
  (a test database connection string, a sandbox API key) to actually run
  the application during development — but appsettings.Development.json
  is STILL typically checked into source control, meaning anything
  written there is committed, exactly like appsettings.json itself (Section 4).
Enter fullscreen mode Exit fullscreen mode

The Secret Manager tool: storing secrets OUTSIDE the project directory entirely

dotnet user-secrets init                                    # generates a UserSecretsId, stored in the .csproj
dotnet user-secrets set "ExternalApi:ApiKey" "sk-test-12345" # stored in a file OUTSIDE the repository entirely
Enter fullscreen mode Exit fullscreen mode
// Program.cs — automatically added when Environment.IsDevelopment(), per Section 2's precedence stack
// no explicit code needed in most templates; WebApplication.CreateBuilder wires this up automatically
Enter fullscreen mode Exit fullscreen mode

User Secrets works by storing values in a JSON file in a per-user, per-project directory outside the repository's own file tree entirely (typically under the user's profile directory, keyed by a UserSecretsId GUID stored in the .csproj file, which itself contains no actual secret data) — this structural separation is the entire point: even if a developer accidentally tries to commit their whole project directory, the secrets themselves physically aren't there to be committed.

Why this is explicitly, deliberately a DEVELOPMENT-ONLY mechanism, not a production secrets solution

Per Section 2: User Secrets is only added to the provider stack when
  Environment.IsDevelopment() is true — it's NOT encrypted at rest (the
  underlying file is plain JSON, just stored outside the repo), has NO
  access control beyond ordinary file-system permissions, and has NO
  mechanism for secure DISTRIBUTION across a team or a deployment
  pipeline. It solves EXACTLY ONE problem: keeping a local developer's
  secrets out of SOURCE CONTROL — nothing more.
Enter fullscreen mode Exit fullscreen mode

This is worth stating with real precision, since "user secrets" as a name can suggest more security than the mechanism actually provides — it's a genuinely well-designed solution to a narrow, real problem (accidental commits of local dev credentials), and Section 7's Azure Key Vault (or an equivalent production secrets manager) is the actual, appropriate tool once you're talking about production credentials, team-wide secret sharing, or anything requiring genuine encryption and access control.


7. Azure Key Vault: Production Secrets

A managed, centralized, access-controlled secret store — the production-appropriate counterpart to Section 6's dev-only tool

var builder = WebApplication.CreateBuilder(args);

var keyVaultUri = new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/");
builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential()); // added to the PROVIDER STACK,
                                                                                       //  same as any other source
Enter fullscreen mode Exit fullscreen mode

Once added, secrets stored in Key Vault appear in IConfiguration exactly like values from any other provider — application code reading _configuration["ExternalApi:ApiKey"] doesn't know or care whether that value came from appsettings.json, an environment variable, or Key Vault; this is precisely the payoff of the unified provider abstraction Section 1 establishes: swapping where a secret actually comes from doesn't require touching any code that consumes it.

DefaultAzureCredential: how the application authenticates to Key Vault itself, without an embedded secret

DefaultAzureCredential tries a SEQUENCE of authentication methods
  automatically — a Managed Identity (when running in Azure, the most
  common and most secure production case, requiring NO secret at all to
  be present anywhere), environment variables, a locally-logged-in
  Azure CLI session (useful for LOCAL testing against a real Key Vault),
  and several others — trying each in turn until one succeeds.
Enter fullscreen mode Exit fullscreen mode

This directly echoes this series' Payment Processing guide's Secret Management discussion of avoiding hardcoded credentials wherever possible — Managed Identity is precisely the mechanism that lets an Azure-hosted application authenticate to Key Vault with no secret credential embedded anywhere at all: Azure's own infrastructure vouches for the application's identity directly, closing the otherwise-circular problem of "how do I securely store the credential I'd need to access my secure credential store."

Why Key Vault genuinely solves what User Secrets structurally cannot

Encrypted at rest and in transit, GENUINE access control (Azure RBAC —
  who/what is allowed to READ which secrets), a full AUDIT LOG of every
  access, secret VERSIONING and rotation support, and a SINGLE,
  centralized source of truth shared safely across an entire team or
  deployment pipeline, rather than each developer's own local machine.
Enter fullscreen mode Exit fullscreen mode

8. The Options Pattern, Revisited in Depth

Binding a configuration section to a strongly-typed class — this series' ASP.NET Core Dependency Injection guide's Section 12, in full depth

public class ExternalApiOptions
{
    public string BaseUrl { get; set; } = "";
    public int TimeoutSeconds { get; set; }
}

builder.Services.Configure<ExternalApiOptions>(builder.Configuration.GetSection("ExternalApi")); // binds the
                                                                                                     //  WHOLE section
Enter fullscreen mode Exit fullscreen mode

This series' ASP.NET Core Dependency Injection guide's Section 12 introduces this pattern as it relates to service lifetimes; worth restating and extending here as this guide's own subject — Configure<T> uses reflection to match configuration keys to the bound type's property names (case-insensitively), converting each value to the property's actual type (TimeoutSeconds's string "30" becomes a genuine int here), which is precisely why application code should almost never read raw, untyped strings via _configuration["..."] directly (Section 1's examples) when a proper Options class is available instead — it trades stringly-typed, unchecked access for genuine compile-time type safety.

IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>: the three lifetime-matched consumption patterns

public class ApiClient
{
    // IOptions<T>: resolved ONCE, value captured at FIRST resolution — safe to inject into a SINGLETON
    public ApiClient(IOptions<ExternalApiOptions> options) { var opts = options.Value; }
}

public class RequestScopedService
{
    // IOptionsSnapshot<T>: re-computed PER SCOPE (per request) — sees configuration as of the START of THIS scope
    public RequestScopedService(IOptionsSnapshot<ExternalApiOptions> options) { var opts = options.Value; }
}

public class LongLivedWatcher
{
    // IOptionsMonitor<T>: a SINGLETON that can react to LIVE changes via OnChange — Section 11 covers this
    public LongLivedWatcher(IOptionsMonitor<ExternalApiOptions> options)
    {
        options.OnChange(newOptions => Console.WriteLine("Options changed!"));
    }
}
Enter fullscreen mode Exit fullscreen mode

This directly extends this series' ASP.NET Core Dependency Injection guide's brief introduction of this trio, and the reasoning is exactly the same lifetime-matching discipline that guide's Section 7 establishes for services generally — IOptions<T> itself is registered as a singleton (safe anywhere, but never reflects a later configuration change); IOptionsSnapshot<T> is scoped, giving each request a fresh, consistent read as of that request's start; IOptionsMonitor<T> is a singleton with genuine live-reload capability, appropriate for long-lived services that specifically need to react when configuration changes without restarting.


9. Binding Complex Objects and Arrays

Nested objects bind recursively, following the same hierarchical structure Section 3 describes

{
  "Email": {
    "SmtpServer": "smtp.example.com",
    "Retry": { "MaxAttempts": 3, "DelaySeconds": 5 }
  }
}
Enter fullscreen mode Exit fullscreen mode
public class EmailOptions
{
    public string SmtpServer { get; set; } = "";
    public RetryOptions Retry { get; set; } = new(); // a NESTED class binds automatically, recursively
}
public class RetryOptions { public int MaxAttempts { get; set; } public int DelaySeconds { get; set; } }
Enter fullscreen mode Exit fullscreen mode

The binder recurses into nested object properties automatically, matching the JSON's own nested structure — no special configuration or attributes are needed for this to work correctly, as long as the C# class structure genuinely mirrors the configuration's hierarchical shape.

Arrays and lists bind from numerically-indexed keys

{ "AllowedOrigins": [ "https://app1.example.com", "https://app2.example.com" ] }
Enter fullscreen mode Exit fullscreen mode
public class CorsOptions { public List<string> AllowedOrigins { get; set; } = new(); }
// underneath, this JSON array is flattened to keys: AllowedOrigins:0, AllowedOrigins:1
Enter fullscreen mode Exit fullscreen mode

Worth knowing the mechanism explicitly, since it explains how an environment variable (which can't natively express a JSON array) could still populate a list: setting AllowedOrigins__0 and AllowedOrigins__1 as separate environment variables (Section 5's double-underscore mapping) achieves the exact same flattened-key structure a JSON array produces, letting array-bound configuration be overridden via environment variables too, just less conveniently than editing a JSON file directly.


10. Options Validation

The problem: a missing or malformed configuration value should fail LOUDLY, at startup, not silently at first use

public class ExternalApiOptions
{
    [Required]
    public string BaseUrl { get; set; } = "";
    [Range(1, 300)]
    public int TimeoutSeconds { get; set; }
}

builder.Services.AddOptions<ExternalApiOptions>()
    .Bind(builder.Configuration.GetSection("ExternalApi"))
    .ValidateDataAnnotations() // uses standard DataAnnotations attributes, per above
    .ValidateOnStart(); // ❗ fails the APPLICATION STARTUP itself if validation fails, not just the first USE
Enter fullscreen mode Exit fullscreen mode

ValidateOnStart() is genuinely worth calling out as a deliberate, valuable choice — without it, a misconfigured or missing required setting only surfaces as an error the first time something actually tries to use that options instance, which could be minutes or hours after deployment, deep into production traffic; ValidateOnStart() converts that into an immediate, loud, deployment-blocking failure at application startup instead, which is a considerably safer failure mode.

Custom validation logic, beyond simple DataAnnotations attributes

builder.Services.AddOptions<ExternalApiOptions>()
    .Bind(builder.Configuration.GetSection("ExternalApi"))
    .Validate(options => Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out _), "BaseUrl must be a valid absolute URI")
    .ValidateOnStart();
Enter fullscreen mode Exit fullscreen mode

For validation logic beyond what a simple attribute can express, .Validate(Func<T, bool>, string) lets you write arbitrary C# validation logic directly, with a custom error message — worth reaching for whenever a setting's validity genuinely depends on more than a single attribute can check (cross-field validation, format checks beyond a simple regex, and similar).


11. Configuration Reloading

appsettings.json reloads automatically, by default, without an application restart

builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); // the DEFAULT
Enter fullscreen mode Exit fullscreen mode

This is worth knowing explicitly: WebApplication.CreateBuilder configures appsettings.json (and its environment-specific counterpart) with reloadOnChange: true by default — editing the file on disk while the application is running triggers IConfiguration to pick up the change live, without a restart.

Why IOptionsSnapshot<T>/IOptionsMonitor<T> matter specifically because of this

Per Section 8: IOptions<T> captures its value ONCE and never updates,
  even though the underlying appsettings.json file CAN change live —
  IOptionsSnapshot<T> (re-read per scope) and IOptionsMonitor<T> (live
  OnChange notification) exist SPECIFICALLY to let application code
  actually benefit from this reload capability, rather than being stuck
  with whatever value was captured at the very first resolution.
Enter fullscreen mode Exit fullscreen mode

This closes the loop on Section 8's lifetime-matched trio — the reload capability this section describes is precisely why those three variants exist with genuinely different behavior, not an arbitrary API surface; choosing the wrong one means either missing out on legitimate live reloads (using IOptions<T> for something that should react to change) or paying unnecessary overhead for reload-awareness a truly static setting never actually needs.


12. Custom Configuration Providers

Writing your own provider for a source .NET doesn't support natively

public class DatabaseConfigurationProvider : ConfigurationProvider // extends the base provider class
{
    private readonly string _connectionString;
    public DatabaseConfigurationProvider(string connectionString) => _connectionString = connectionString;

    public override void Load()
    {
        using var connection = new SqlConnection(_connectionString);
        // query a SETTINGS table, populate the base class's protected Data dictionary
        Data = QuerySettingsFromDatabase(connection);
    }
}
Enter fullscreen mode Exit fullscreen mode

For a genuinely custom configuration source (a settings table in a database, a remote configuration service without a built-in .NET provider), implementing ConfigurationProvider directly — overriding Load() to populate the base class's internal key-value store — lets that custom source participate in exactly the same layered, precedence-ordered system every built-in provider does, adding it to the stack via builder.Configuration.Add(...) exactly like any other source.


13. Common Pitfalls

Pitfall Why it hurts Better approach
Assuming a value in appsettings.json will always "win" because it's the most visible file Later-registered providers (environment variables, command-line args) always override earlier ones, regardless of file visibility Understand and check the actual precedence order (Section 2) before assuming which source's value is in effect
Committing genuinely sensitive values to appsettings.json or appsettings.{Env}.json Permanently exposes secrets in source control history, discoverable by anyone with repository access Use User Secrets locally (Section 6) and a real secret manager like Azure Key Vault in production (Section 7)
Treating User Secrets as a production-appropriate secrets solution It's unencrypted, has no access control beyond file permissions, and has no team-distribution mechanism Use User Secrets strictly for local development; use Key Vault (or an equivalent) for anything production-facing
Registering Azure Key Vault (or another override source) before environment variables in Program.cs Lets a less-trusted or less-specific source override a genuinely production-critical secret unintentionally Be deliberate about provider registration order — it directly determines precedence (Section 2)
Reading configuration via raw, untyped _configuration["Key:SubKey"] string access throughout application code Stringly-typed, unchecked, error-prone, and undiscoverable compared to a properly bound Options class Bind configuration sections to strongly-typed Options classes (Section 8) instead of scattering raw string-keyed reads
Injecting IOptions<T> into a service that genuinely needs to react to configuration changes IOptions<T> captures its value once and never updates, even though the underlying source can reload live Use IOptionsSnapshot<T> or IOptionsMonitor<T> when live reload behavior is genuinely needed (Section 8, Section 11)
Letting a missing or invalid required setting fail silently at first use, deep into runtime The failure surfaces unpredictably, potentially well after deployment, rather than immediately and clearly Use ValidateOnStart() so misconfiguration fails loudly at application startup instead (Section 10)
Assuming an environment variable can express nested JSON structure directly Environment variables are flat; nested/array configuration requires the specific double-underscore/indexed-key convention Use __ for hierarchy and numeric suffixes for arrays (Key__0, Key__1) when setting environment variables (Section 5, Section 9)

Quick Reference Table

Source Precedence (relative) Typical Use
appsettings.json Lowest Safe, shared, non-sensitive defaults
appsettings.{Environment}.json Overrides base Environment-specific, still non-sensitive, refinements
User Secrets Overrides both above (Dev only) Local developer secrets, never committed
Environment variables Overrides all above Deployment-time configuration, set by hosting infrastructure
Command-line arguments Highest Explicit, per-invocation overrides
Azure Key Vault Wherever registered in Program.cs Centralized, access-controlled, encrypted production secrets
Concept Syntax Purpose
Hierarchical key "ExternalApi:BaseUrl" (or ExternalApi__BaseUrl for env vars) Maps nested structure to a flat, queryable key
Strongly-typed binding services.Configure<T>(configuration.GetSection("...")) Converts raw configuration into a typed, discoverable class
Startup validation .ValidateDataAnnotations().ValidateOnStart() Fails loudly at startup on misconfiguration, not silently at first use
Live reload consumption IOptionsSnapshot<T> / IOptionsMonitor<T> Lets application code benefit from configuration reloading

Conclusion

Configuration in ASP.NET Core is, architecturally, the same layered-provider abstraction this series' Logging guide describes for ILogger — a thin, unified interface over a precedence-ordered stack of sources, each capable of overriding the ones before it — and understanding that precedence order precisely is what actually resolves the overwhelming majority of "why isn't my setting taking effect" confusion, far more reliably than assuming any one file is inherently authoritative. The progression from appsettings.json's safe defaults, through environment-specific overrides, to User Secrets' narrow but genuinely useful local-development protection, to Key Vault's full production-grade encryption and access control, reflects a deliberate escalation matched to how sensitive and how deployment-specific a given setting actually is — treating User Secrets as if it offered Key Vault's guarantees, or committing a secret to appsettings.json "just for now," are both mistakes that come from not respecting that escalation's actual purpose.

The Options pattern this guide revisits from this series' ASP.NET Core Dependency Injection guide is what turns this entire layered system from raw, stringly-typed key access into genuinely safe, discoverable, validated configuration consumption — and ValidateOnStart() is worth treating as close to a default choice for any genuinely required setting, since the alternative (a misconfiguration surfacing unpredictably, deep into runtime, possibly well after a deployment has already gone out) is a strictly worse failure mode than an immediate, loud startup failure that's impossible to miss.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the environment-variable-silently-overrode-the-appsettings-value debugging session that made the precedence order click far better than any documentation table ever could.

Top comments (0)