Skip to content
Production-Grade Configuration in ASP.NET Core: Options Pattern, Secrets, and Environment-Safe Defaults

Production-Grade Configuration in ASP.NET Core: Options Pattern, Secrets, and Environment-Safe Defaults

1 Why Configuration Is a First-Class Architecture Concern

Configuration in ASP.NET Core has matured far beyond the simple appsettings.json file developers once sprinkled through projects. In production, configuration is a living, breathing dependency — loaded dynamically, injected safely, validated early, and evolved continuously. When configuration management is treated as an afterthought, subtle environment drift and secret sprawl can quietly undermine reliability and security. This section builds the mindset: configuration is not a static artifact but a runtime contract between your app, your environment, and your team.

1.1 Config as a Runtime Dependency, Not a File

Configuration often starts as a few key-value pairs in appsettings.json. But in production systems, configuration is an operational dependency as critical as a database connection or a cache cluster. It defines how the application behaves, where it connects, and what it exposes.

A mature system recognizes that configuration:

  • Has multiple sources (JSON, environment, Key Vault, feature store).
  • Evolves during runtime (via hot reload, environment overrides, or cloud refresh).
  • Requires validation and observability just like code.

Example: From Static File to Dependency Graph

In development:

// appsettings.json
{
  "ConnectionStrings": {
    "Default": "Server=.;Database=AppDb;Trusted_Connection=True;"
  }
}

In production, this becomes dynamic:

// appsettings.Production.json
{
  "ConnectionStrings": {
    "Default": "@Microsoft.KeyVault(SecretUri=https://prod-vault.vault.azure.net/secrets/sql-conn/)"
  }
}

And you might wire it with environment detection in Program.cs:

builder.Configuration
    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
    .AddEnvironmentVariables()
    .AddUserSecrets<Program>(optional: true)
    .AddAzureKeyVault(new Uri(builder.Configuration["KeyVaultUri"]), new DefaultAzureCredential());

At this point, configuration behaves like a dependency tree: if Key Vault is unreachable or credentials expire, the app must degrade gracefully or fail fast — not crash unexpectedly. That’s runtime dependency management, not file I/O.

1.2 Failure Modes in the Wild: Nulls, Stale Reloads, Env Drift, Secret Sprawl

Even experienced teams fall into configuration traps that only surface in production.

Null Configuration and Silent Failures

A common pitfall: unvalidated or missing configuration silently defaults to null or empty values.

Incorrect

var apiKey = configuration["Api:Key"]; // returns null if missing
client.DefaultRequestHeaders.Add("x-api-key", apiKey);

Correct

var options = builder.Configuration
    .GetSection("Api")
    .Get<ApiOptions>() 
    ?? throw new InvalidOperationException("Missing Api configuration.");

if (string.IsNullOrWhiteSpace(options.Key))
    throw new InvalidOperationException("API Key must be configured.");

Stale Reloads

Many assume reloadOnChange: true means all dependent services auto-update. Not true. Only components wired via IOptionsMonitor<T> or similar mechanisms receive updates. Without proper immutability boundaries, reloads can cause inconsistent states — e.g., one thread uses old settings while another uses new ones.

Environment Drift

When Dev, QA, and Prod environments use subtly different file hierarchies or variable naming (e.g., Database__ConnectionString vs. Db__ConnStr), drift accumulates. Eventually, the deployment that “worked in QA” fails in production.

Secret Sprawl

Secrets often leak across:

  • JSON configs committed to source control.
  • Environment variables in build servers.
  • CI/CD YAML pipelines with inline credentials.

This creates compliance and operational nightmares. Production-grade systems delegate secret storage to managed identity and vault services, not to developers’ laptops.

1.3 Goals for a Production-Grade Configuration Approach

A robust configuration strategy should deliver on four pillars: safety, portability, observability, and testability.

Safety

  • Secrets never exist in plaintext files.
  • Configuration is validated before use.
  • Reloads are atomic and consistent.
  • Sensitive data is masked in logs.

Portability

  • Configuration works across OS, containers, and cloud providers.
  • Environment-specific overrides don’t break local development.
  • Providers can be swapped without rewriting business code.

Observability

  • You can trace where a value originated (provider chain).
  • Configuration changes trigger structured logs or metrics.
  • Health checks can detect missing or invalid configuration.

Testability

  • You can replace real providers with in-memory mocks.
  • Unit and integration tests validate bindings and invariants.
  • CI pipelines enforce config linting or schema compliance.

Taken together, these goals let teams move configuration from an “afterthought” to a first-class design concern that scales safely under automation.

1.4 What’s New in .NET 8/9: Modern Hosting, Binding, and Validation

ASP.NET Core 8/9 introduces several features that modernize configuration handling.

Minimal Hosting Model

Configuration and dependency injection are unified under the WebApplicationBuilder API. You no longer juggle multiple startup constructs — configuration is part of the app lifecycle from the first line.

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
builder.Services.AddOptions<MyOptions>()
    .Bind(builder.Configuration.GetSection("MySection"))
    .ValidateOnStart();

Source-Generated Binding

With .NET 8+, the new ConfigurationBindingSourceGenerator eliminates reflection-heavy binding. Using [ConfigurationKeyName] attributes and source generation improves startup performance and AOT compatibility:

[ConfigurationKeyName("MySection")]
public sealed class MyOptions
{
    public required string Url { get; init; }
    public required int Timeout { get; init; }
}

Validation on Start

You can now enforce fail-fast behavior via ValidateOnStart() — configuration is validated before serving requests. Invalid settings crash early with clear diagnostics, preventing latent bugs.

Cloud-Native Providers

The Azure SDKs provide first-class support for:

  • Azure Key Vault via AddAzureKeyVault()
  • Azure App Configuration with dynamic refresh and feature flags
  • Managed Identity authentication through DefaultAzureCredential

Together, these enable “cloud-safe” defaults: configuration that adjusts automatically to the runtime environment without embedding secrets or manual wiring.


2 Configuration Fundamentals in ASP.NET Core (The Right Baseline)

Before layering advanced techniques, you need to master the baseline: the configuration pipeline, how precedence works, and how typed binding interacts with providers. ASP.NET Core’s configuration system is highly composable — but that flexibility cuts both ways if not clearly understood.

2.1 The Configuration Pipeline: Providers, Precedence, and Key Normalization

Configuration in ASP.NET Core is built from a chain of configuration providers. Each provider contributes key/value pairs into a unified IConfiguration tree. Later providers override earlier ones when the same key appears.

Provider Precedence

Typical order in WebApplication.CreateBuilder():

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. User secrets (if in Development)
  4. Environment variables
  5. Command-line arguments
  6. Optional cloud providers (e.g., Key Vault, App Config)

The last registered source wins.

Key Normalization

All keys are normalized to a colon-delimited hierarchy (:). This ensures consistency across sources:

Source KeyNormalized Key
ConnectionStrings:DefaultConnectionStrings:Default
ConnectionStrings__Default (env var)ConnectionStrings:Default

Example: Inspecting Providers

foreach (var provider in ((IConfigurationRoot)builder.Configuration).Providers)
{
    Console.WriteLine(provider);
}

This diagnostic step helps you confirm the provider chain and detect misordered registrations.

2.2 Default Host Wiring

By default, ASP.NET Core uses a layered approach where each source overrides the previous:

builder.Configuration
    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
    .AddUserSecrets<Program>(optional: true)
    .AddEnvironmentVariables()
    .AddCommandLine(args);

Where Reload Works

  • JSON files with reloadOnChange: true: supported.
  • Environment variables: no automatic reload.
  • Azure Key Vault: no reload unless manually configured.
  • Azure App Configuration: supports refresh with sentinel keys.

Example: Environment-Specific Overrides

# Linux container
export ASPNETCORE_ENVIRONMENT=Production
export ConnectionStrings__Default="Server=db;Database=App;User Id=appuser;Password=secret"
dotnet MyApp.dll

This overrides any connection string in your JSON configs.

2.3 Binding to Types: Bind, Source Generators, and AOT Considerations

Typed configuration binding is essential for safety and discoverability.

Classic Binding

builder.Services.Configure<MyOptions>(
    builder.Configuration.GetSection("MySection"));

The MyOptions class can then be injected:

public class MyService(IOptions<MyOptions> options)
{
    private readonly MyOptions _opts = options.Value;
}

Source-Generated Binding (Modern)

To optimize startup and enable AOT trimming, .NET 8 introduced source-generated binding:

builder.Services.AddOptions<MyOptions>()
    .BindConfiguration("MySection", binderOptions => binderOptions.BindNonPublicProperties = true)
    .ValidateDataAnnotations()
    .ValidateOnStart();

Under the hood, this avoids reflection and supports compile-time key validation.

Trimming/AOT Safety

If you’re publishing to native AOT (via dotnet publish -p:PublishAot=true), you must use source generators or BindRuntimeValues() patterns. Reflection-based binding (Bind()) may be trimmed away, leading to runtime errors.

2.4 Choosing Provider Order and Overrides

Provider order defines your override semantics. A misordered registration can cause subtle misbehavior.

Incorrect

builder.Configuration
    .AddEnvironmentVariables()
    .AddJsonFile("appsettings.json");

Here, environment variables load before the JSON file, so any appsettings.json values overwrite environment variables — the reverse of intended behavior.

Correct

builder.Configuration
    .AddJsonFile("appsettings.json", false, true)
    .AddEnvironmentVariables();

Custom Providers

You can implement a custom provider by extending ConfigurationProvider and ConfigurationSource. For example, pulling configuration from an internal API or encrypted blob store.

public class ApiConfigurationProvider : ConfigurationProvider
{
    public override void Load()
    {
        var data = FetchFromApi();
        Data = data.ToDictionary(k => k.Key, v => v.Value);
    }
}

Custom providers should follow the same normalization rules and support reload tokens if they need dynamic refresh.

2.5 When to Split Configuration

Not all configuration belongs in the same place. Splitting improves governance and security.

1 Operational Settings

Parameters like timeouts, retries, or feature toggles. Usually reloadable and environment-specific.

2 Feature Settings

Business-level toggles that may require versioned rollout or A/B testing. Often managed via feature flags or App Configuration.

3 Secrets

API keys, connection strings, certificates — always externalized (Key Vault, AWS Secrets Manager, etc.).

4 Performance-Tuned Settings

Cache limits, queue sizes, or concurrency parameters. Usually static after deployment to avoid runtime instability.

Example: Multi-File Strategy

/appsettings.json                → Safe defaults
/appsettings.Development.json    → Dev-specific tools
/appsettings.Production.json     → Hardened limits
/secrets.json (excluded)         → Local-only secrets

This separation allows teams to safely commit configuration while ensuring secrets never enter source control.


3 The Options Pattern, Done Properly

ASP.NET Core’s Options Pattern is the backbone of safe, typed configuration access. When used correctly, it provides clear contracts, validation, and reload behavior. When misused, it can cause inconsistent state or silent misconfiguration.

3.1 IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T>

These three abstractions define when configuration values are read.

InterfaceLifetimeUse Case
IOptions<T>SingletonStatic config, never changes after startup
IOptionsSnapshot<T>Scoped (per request)Reload per request (e.g., web app config changes)
IOptionsMonitor<T>Singleton (with change notifications)Dynamic reload for long-running services

Example

public class MyService(IOptionsMonitor<MyOptions> monitor)
{
    public void DoWork()
    {
        var current = monitor.CurrentValue;
        // React to live changes if provider supports reload
    }
}

You can also subscribe to change events:

monitor.OnChange(newValue =>
{
    _logger.LogInformation("Config changed: {Timeout}", newValue.Timeout);
});

Trade-Offs

  • IOptions<T>: safest, simplest, best for immutable config.
  • IOptionsSnapshot<T>: fine for short-lived scopes like HTTP requests.
  • IOptionsMonitor<T>: powerful for background services, but requires immutable usage to avoid race conditions.

3.2 Named Options and Hierarchical Options

When your app consumes multiple similar services — e.g., multiple APIs — named options shine.

Example: Multiple APIs

builder.Services.AddOptions<ApiOptions>("Payments")
    .Bind(builder.Configuration.GetSection("Apis:Payments"));
builder.Services.AddOptions<ApiOptions>("Shipping")
    .Bind(builder.Configuration.GetSection("Apis:Shipping"));

Then consume them:

public class ApiClientFactory(IOptionsMonitor<ApiOptions> options)
{
    public HttpClient CreateClient(string name)
    {
        var config = options.Get(name);
        return new HttpClient { BaseAddress = new Uri(config.BaseUrl) };
    }
}

This avoids brittle “stringly-typed” lookups while maintaining strong contracts.

3.3 Validation Strategies

Validation is the difference between deterministic startup and production surprises.

Built-in Validators

  • ValidateDataAnnotations(): validates [Required], [Range], etc.
  • ValidateOnStart(): ensures all validations run before serving traffic.
  • Validate(...): custom lambda validation.
builder.Services.AddOptions<PaymentOptions>()
    .Bind(builder.Configuration.GetSection("Payments"))
    .ValidateDataAnnotations()
    .Validate(opt => opt.Timeout > 0, "Timeout must be positive")
    .ValidateOnStart();

Custom Validators

For complex cross-field rules, implement IValidateOptions<T>:

public class PaymentOptionsValidator : IValidateOptions<PaymentOptions>
{
    public ValidateOptionsResult Validate(string? name, PaymentOptions options)
    {
        if (options.MaxRetries < options.MinRetries)
            return ValidateOptionsResult.Fail("MaxRetries cannot be less than MinRetries.");
        return ValidateOptionsResult.Success;
    }
}

Register it with DI:

builder.Services.AddSingleton<IValidateOptions<PaymentOptions>, PaymentOptionsValidator>();

Fail-fast validation turns configuration from “optional noise” into a real deployment contract.

3.4 Post-Configuration and Composition

Sometimes you need to apply computed or environment-based defaults after binding.

builder.Services.AddOptions<CacheOptions>()
    .Bind(builder.Configuration.GetSection("Cache"))
    .PostConfigure(options =>
    {
        options.DefaultTtl ??= TimeSpan.FromMinutes(10);
    });

This ensures the application has sensible defaults even when the source configuration is incomplete.

Post-configuration also enables composition — layering defaults from shared libraries over app-level overrides.

3.5 Example: PaymentsOptions with Validation and Tests

public sealed class PaymentsOptions
{
    [Required]
    public required string ApiBaseUrl { get; init; }

    [Range(1, 60)]
    public int TimeoutSeconds { get; init; } = 10;

    [Required]
    public required string ApiKey { get; init; }
}

Registration

builder.Services.AddOptions<PaymentsOptions>()
    .BindConfiguration("Payments")
    .ValidateDataAnnotations()
    .ValidateOnStart();

Usage

public class PaymentService(IOptions<PaymentsOptions> opts)
{
    private readonly PaymentsOptions _options = opts.Value;

    public async Task<bool> ChargeAsync(decimal amount)
    {
        using var client = new HttpClient { BaseAddress = new Uri(_options.ApiBaseUrl) };
        client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_options.ApiKey}");
        var response = await client.PostAsync("/charge", new StringContent($"{{\"amount\":{amount}}}"));
        return response.IsSuccessStatusCode;
    }
}

Integration Test

[Fact]
public void Should_Validate_PaymentOptions()
{
    var services = new ServiceCollection();
    var config = new ConfigurationBuilder()
        .AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Payments:ApiBaseUrl"] = "https://api.payments.test",
            ["Payments:TimeoutSeconds"] = "5",
            ["Payments:ApiKey"] = "secret"
        })
        .Build();

    services.AddOptions<PaymentsOptions>()
        .BindConfiguration("Payments")
        .ValidateOnStart();

    var provider = services.BuildServiceProvider(validateScopes: true);
    var options = provider.GetRequiredService<IOptions<PaymentsOptions>>().Value;

    Assert.Equal(5, options.TimeoutSeconds);
}

This pattern — typed options with validation and test coverage — forms the foundation of production-grade configuration. Each section, provider, and reload mechanism you add builds on this baseline.


4 Reloads Without Regret

Configuration reloads are powerful but deceptively complex. They allow running applications to adapt to changes in the environment or external stores—without redeploying. But reloads also introduce synchronization risks and observability challenges. Knowing what actually reloads and how to consume it safely determines whether your app reacts gracefully or ends up in inconsistent states.

4.1 What Actually Reloads? JSON Providers, Env Vars, and Cloud Sources

Not every configuration source in ASP.NET Core supports runtime reloads. Understanding provider behavior is the first step toward building predictable systems.

JSON Files with reloadOnChange

By default, when you add JSON files like this:

builder.Configuration.AddJsonFile(
    "appsettings.json", optional: false, reloadOnChange: true);

ASP.NET Core monitors the file for changes using a file watcher. When modified, the configuration provider rebuilds its in-memory representation and triggers a reload token. Components using IOptionsMonitor<T> receive updated values.

This works well for development but should be used carefully in production, as file-system watchers may not behave consistently in containerized or read-only file systems.

Environment Variables

Environment variables do not support reloads. They are static snapshots read at startup. To change them, you must restart the process or redeploy the container. This is why environment variables are suitable for stable operational configuration, not for dynamic toggles.

Azure Key Vault

The Key Vault configuration provider does not natively support automatic reload. Secrets are cached in memory. If a secret rotates, the application must either restart or use an external trigger to refresh. Some teams address this by pairing Key Vault with Azure App Configuration (which can signal refresh).

Azure App Configuration

Azure App Configuration supports dynamic reloads via sentinel keys. You register keys that act as change markers. When a sentinel changes, all dependent configuration sections refresh.

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(builder.Configuration["AppConfigConnection"])
        .Select("App:*")
        .ConfigureRefresh(refresh =>
        {
            refresh.Register("App:Sentinel", refreshAll: true)
                   .SetCacheExpiration(TimeSpan.FromSeconds(30));
        });
});

This pattern ensures you don’t reload on every small edit but only when deliberate changes are published.

4.2 Wiring IOptionsMonitor<T> Safely: Immutability at the Edges

IOptionsMonitor<T> gives live-updating configuration, but careless use can cause race conditions or partial updates. The key is immutability at consumption boundaries.

Immutable Snapshots

When consuming monitored options, take a local snapshot instead of using CurrentValue repeatedly.

Incorrect

public async Task ProcessAsync()
{
    var timeout = _optionsMonitor.CurrentValue.TimeoutSeconds;
    await Task.Delay(timeout * 1000);
    // May change mid-operation
}

Correct

public async Task ProcessAsync()
{
    var options = _optionsMonitor.CurrentValue;
    await Task.Delay(options.TimeoutSeconds * 1000);
}

Capturing CurrentValue at the start ensures atomicity of the operation. Subsequent reloads won’t affect the current transaction.

Hot-Swap Patterns

For long-lived components (e.g., background services or connection pools), you can hot-swap dependencies when configuration changes.

private IDisposable? _changeToken;

public MyService(IOptionsMonitor<DbOptions> monitor, ILogger<MyService> logger)
{
    _dbClient = CreateClient(monitor.CurrentValue);
    _changeToken = monitor.OnChange(opts =>
    {
        logger.LogInformation("Reloading DB client due to config change.");
        _dbClient.Dispose();
        _dbClient = CreateClient(opts);
    });
}

This approach lets your app reconfigure without downtime, but note that transitions should be idempotent and thread-safe.

4.3 Race-Condition and Consistency Pitfalls

Dynamic reloads can produce transient inconsistencies if not carefully managed. Three common pitfalls stand out.

1 Partial Reads

When multiple settings change together (e.g., an API key and endpoint), reload may temporarily expose mismatched values. Use atomic sections or grouped configurations.

public record ApiOptions
{
    public required string Url { get; init; }
    public required string ApiKey { get; init; }
}

2 Multi-Thread Access

If your reload callback updates a shared resource, synchronize access:

private readonly object _lock = new();

monitor.OnChange(opts =>
{
    lock (_lock)
    {
        _client = new ApiClient(opts.Url, opts.ApiKey);
    }
});

3 Deferred Validation

When values reload, validation should re-run. Implement a validation method that executes in your OnChange callback to avoid silently loading invalid state.

4.4 Example: Rotating API Tokens and Circuit Breakers

Imagine an external payments API that rotates tokens every hour and allows configuration of circuit breaker thresholds via App Configuration.

public class PaymentGatewayOptions
{
    public required string ApiUrl { get; init; }
    public required string Token { get; init; }
    public int FailureThreshold { get; init; }
}

Configuration Setup

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(builder.Configuration["AppConfigConnection"])
        .Select("Payments:*")
        .ConfigureRefresh(refresh =>
        {
            refresh.Register("Payments:Sentinel", refreshAll: true)
                   .SetCacheExpiration(TimeSpan.FromMinutes(5));
        });
});

builder.Services.AddOptions<PaymentGatewayOptions>()
    .BindConfiguration("Payments")
    .ValidateOnStart();

Safe Consumption

public class PaymentClient
{
    private readonly ILogger<PaymentClient> _logger;
    private readonly object _sync = new();
    private ApiClient _client;

    public PaymentClient(IOptionsMonitor<PaymentGatewayOptions> monitor, ILogger<PaymentClient> logger)
    {
        _logger = logger;
        _client = CreateClient(monitor.CurrentValue);

        monitor.OnChange(opts =>
        {
            lock (_sync)
            {
                _logger.LogInformation("Refreshing PaymentClient due to token rotation");
                _client.Dispose();
                _client = CreateClient(opts);
            }
        });
    }

    private static ApiClient CreateClient(PaymentGatewayOptions opts)
        => new(opts.ApiUrl, opts.Token, opts.FailureThreshold);
}

Here, the service safely swaps the HTTP client when tokens rotate or thresholds change. The reloads are atomic and observable.

4.5 Observability: Logging Configuration Deltas Safely

Without visibility, reloads can go unnoticed until errors appear. Yet logging raw configuration can leak secrets. The balance: log deltas and metadata, not values.

Example Logging Pattern

monitor.OnChange((opts, name) =>
{
    _logger.LogInformation("Configuration for {Name} reloaded at {Time}", name, DateTime.UtcNow);
    _logger.LogDebug("Effective timeout: {Timeout}", opts.TimeoutSeconds);
});

Mask or omit sensitive data:

_logger.LogInformation("API key rotated for {Service}, new key masked: {Key}",
    "Payments", Mask(opts.ApiKey));

Surface Effective Values

Expose current non-secret configuration via a diagnostic endpoint (secured, internal only):

app.MapGet("/_config", (IOptionsMonitor<MyOptions> opts) =>
{
    var sanitized = new
    {
        opts.CurrentValue.BaseUrl,
        opts.CurrentValue.TimeoutSeconds
    };
    return Results.Ok(sanitized);
});

This enables operational teams to verify effective configuration during incidents.

4.6 Testing Reload Flows with Integration Tests

You can test reload behavior without touching the file system by using ConfigurationManager and in-memory providers.

Example Test

[Fact]
public void Should_React_To_Config_Changes()
{
    var configData = new Dictionary<string, string?>
    {
        ["Service:Url"] = "https://v1.api",
        ["Service:Retry"] = "3"
    };
    var config = new ConfigurationBuilder()
        .AddInMemoryCollection(configData)
        .Build();

    var services = new ServiceCollection();
    services.AddOptions<ServiceOptions>()
        .Bind(config.GetSection("Service"))
        .ValidateOnStart();

    var provider = services.BuildServiceProvider();
    var monitor = provider.GetRequiredService<IOptionsMonitor<ServiceOptions>>();
    Assert.Equal("https://v1.api", monitor.CurrentValue.Url);

    // Simulate reload
    configData["Service:Url"] = "https://v2.api";
    (config as IConfigurationRoot)!.Reload();
    Assert.Equal("https://v2.api", monitor.CurrentValue.Url);
}

Such tests validate that your reload logic and downstream consumers behave as expected before shipping to production.

4.7 Operational Guardrails

Dynamic reloads should be tightly governed. Consider these practices:

  1. Ownership: Only designated engineers or automation should edit reloadable config.
  2. Change Management: Use feature branches or App Config revision history.
  3. Change Windows: Avoid reloads during high-traffic hours to reduce risk.
  4. Auditing: Keep a log of who changed which key and when.
  5. Fallbacks: Implement a sentinel rollback key in App Configuration to revert a bad change quickly.

By formalizing reload governance, teams balance agility with safety.


5 Secrets: From Dev to Prod Without Leaks

Configuration is incomplete without a disciplined secret management lifecycle. Secrets—API keys, connection strings, credentials—must never be baked into code or configuration files. ASP.NET Core offers an opinionated path: local secrets in development, environment variables in CI, and managed vaults in production.

5.1 Development Secrets: dotnet user-secrets

The dotnet user-secrets tool isolates sensitive settings per developer and per project. It stores them in the user profile, not the repo.

Setup

cd MyApp
dotnet user-secrets init
dotnet user-secrets set "Payments:ApiKey" "dev-key-123"

This creates a GUID in the .csproj and a JSON file under %APPDATA%\Microsoft\UserSecrets\<guid>\secrets.json (Windows) or ~/.microsoft/usersecrets/ (Linux/macOS).

Usage

builder.Configuration
    .AddUserSecrets<Program>(optional: true);

Never commit secrets to appsettings.Development.json or .env files. Local secrets stay on each developer’s machine and integrate seamlessly with configuration binding.

Never in Git

Patterns to exclude:

appsettings.Production.json
.env
*.secrets.json

Git hooks or CI scans can enforce this via regex or trufflehog checks.

5.2 Production Secrets: Azure Key Vault as a Provider

Azure Key Vault centralizes secrets with fine-grained access control and auditing. ASP.NET Core can bind directly to it.

Setup

builder.Configuration.AddAzureKeyVault(
    new Uri(builder.Configuration["KeyVaultUri"]),
    new DefaultAzureCredential());

Secrets in Key Vault use hierarchical naming (e.g., Payments--ApiKey) and map automatically to configuration keys (Payments:ApiKey).

Refresh Considerations

Key Vault secrets do not reload automatically. To refresh periodically, combine Key Vault with Azure App Configuration or implement a custom timer to reload the configuration root.

Secret Naming and Organization

Use a consistent schema:

Secret NamePurpose
Sql--ConnectionStringDatabase access
Payments--ApiKeyThird-party API
Jwt--SigningKeyToken signing

This naming allows hierarchical access via ConfigurationBinder.

5.3 AuthN to Key Vault Using Entra ID

Authentication is handled by Azure Identity via DefaultAzureCredential, which automatically selects the best available method depending on the environment.

Local Development

If signed in via Visual Studio or Azure CLI:

az login

DefaultAzureCredential uses that context to access the vault.

In Azure App Service or Functions

When deployed, the app uses Managed Identity—no credentials required. Assign the app’s identity the Key Vault Secrets User role.

az keyvault set-policy --name prod-vault \
  --object-id <app-msi-object-id> \
  --secret-permissions get list

This way, you never embed credentials in configuration. The same code runs unchanged across environments.

Fine-Tuning the Credential Chain

If you need explicit control:

var credentials = new DefaultAzureCredential(
    new DefaultAzureCredentialOptions
    {
        ExcludeEnvironmentCredential = false,
        ExcludeVisualStudioCredential = false,
        ManagedIdentityClientId = builder.Configuration["ManagedIdentityClientId"]
    });

5.4 Boot-Time Validation for Secrets

Failing fast on missing or invalid secrets prevents production incidents.

builder.Services.AddOptions<JwtOptions>()
    .BindConfiguration("Jwt")
    .Validate(opt => !string.IsNullOrEmpty(opt.SigningKey), "JWT signing key is required.")
    .ValidateOnStart();

If Key Vault is unavailable, the app should abort startup with a descriptive error instead of running in a half-configured state.

Masking Diagnostics

Expose only the presence, not the value:

[ERROR] Missing secret: Payments:ApiKey (masked)
[INFO] Vault: prod-vault.vault.azure.net, Identity: Managed (AppId: xxxxx)

This ensures incident logs remain useful without leaking sensitive data.

5.5 Example: End-to-End Secret Flow

A realistic setup across environments:

EnvironmentSecret SourceAuth Method
Developmentdotnet user-secretsLocal user
CIEnvironment variables (GitHub Actions, Azure Pipelines)Secret store injection
Staging/ProdAzure Key VaultManaged Identity

Configuration

builder.Configuration
    .AddJsonFile("appsettings.json", false, true)
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true)
    .AddUserSecrets<Program>(optional: true)
    .AddEnvironmentVariables();

if (builder.Environment.IsProduction())
{
    builder.Configuration.AddAzureKeyVault(
        new Uri(builder.Configuration["KeyVaultUri"]),
        new DefaultAzureCredential());
}

This single startup path adapts automatically across environments, achieving zero secret leakage and consistent behavior.


6 Environment-Safe Defaults and Per-Environment Overrides

Different environments—Development, Staging, Production—serve different purposes. Each needs its own configuration layer while sharing safe defaults. The goal is deterministic behavior: developers can test locally without compromising production hardening.

6.1 Designing Environment Layers

ASP.NET Core follows a layered file convention:

  1. appsettings.json: baseline defaults (safe everywhere)
  2. appsettings.Development.json: local tools, verbose logging, mock APIs
  3. appsettings.Production.json: strict timeouts, disabled debug features

Example:

// appsettings.json
{
  "Logging": { "LogLevel": { "Default": "Information" } },
  "HttpClient": { "TimeoutSeconds": 10 }
}
// appsettings.Development.json
{
  "HttpClient": { "TimeoutSeconds": 3 },
  "Features": { "EnableDebugPage": true }
}
// appsettings.Production.json
{
  "HttpClient": { "TimeoutSeconds": 15 },
  "Features": { "EnableDebugPage": false }
}

This hierarchy prevents unsafe dev values from leaking into production.

6.2 A Configuration Contract for Teams and Services

Large teams often share microservices or libraries that depend on consistent configuration structures. A “configuration contract” formalizes these expectations.

Define typed settings in a shared Contracts project:

public sealed record MessagingOptions
{
    public required string BrokerUrl { get; init; }
    public int RetryCount { get; init; }
}

Version this contract and validate it across services. When evolving settings, prefer additive changes (new fields) to breaking removals. Teams should track configuration schema versions like API versions.

6.3 Feature Store and Operational Overrides

Azure App Configuration doubles as both centralized config and feature store. You can override app defaults in real time without redeployment.

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(builder.Configuration["AppConfigConnection"])
           .Select("Common:*")
           .Select("ServiceA:*");
});

To manage rollout toggles:

builder.Services.AddFeatureManagement()
    .AddFeatureFilter<PercentageFilter>();

Then configure:

{
  "FeatureManagement": {
    "NewCheckoutFlow": {
      "EnabledFor": [{ "Name": "Percentage", "Parameters": { "Value": 25 } }]
    }
  }
}

This lets operations teams toggle features per environment using Azure Portal or CLI.

6.4 Conditional Wiring in Program.cs

Use the environment abstraction to change behavior without branching code everywhere.

if (builder.Environment.IsDevelopment())
{
    builder.Services.AddSingleton<IEmailSender, LocalEmailSender>();
}
else
{
    builder.Services.AddSingleton<IEmailSender, SendGridEmailSender>();
}

Or for more advanced composition:

builder.Services.AddHttpClient("Payments", client =>
{
    var opts = builder.Configuration.GetSection("Payments").Get<PaymentsOptions>();
    client.BaseAddress = new Uri(opts.BaseUrl);
    client.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds);
});

Each environment provides its own section in configuration.

6.5 Example: Multi-Environment HTTP Client Policy

Define typed options for each environment:

public sealed class HttpPolicyOptions
{
    public required string BaseUrl { get; init; }
    public int TimeoutSeconds { get; init; }
    public int RetryCount { get; init; }
}

Configuration:

// appsettings.Production.json
{
  "HttpPolicy": { "BaseUrl": "https://api.prod", "TimeoutSeconds": 5, "RetryCount": 2 }
}

Registration:

builder.Services.AddHttpClient("ExternalApi")
    .ConfigureHttpClient((sp, client) =>
    {
        var opts = sp.GetRequiredService<IOptionsMonitor<HttpPolicyOptions>>().CurrentValue;
        client.BaseAddress = new Uri(opts.BaseUrl);
        client.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds);
    })
    .AddPolicyHandler(GetRetryPolicy());

This isolates each environment’s behavior while keeping code unchanged.

6.6 Kubernetes and Containers: Secrets and ConfigMaps

In containerized environments, configuration and secrets are injected through ConfigMaps and Secrets, then exposed as environment variables or mounted files.

env:
  - name: ConnectionStrings__Default
    valueFrom:
      secretKeyRef:
        name: db-connection
        key: connection-string

ASP.NET Core automatically binds these via AddEnvironmentVariables(). The 12-factor principle—**

store config in the environment**—applies naturally.

For advanced scenarios, mount configuration as files and enable reloads:

volumeMounts:
  - name: appsettings
    mountPath: /app/config
volumes:
  - name: appsettings
    configMap:
      name: app-config

And configure:

builder.Configuration.AddJsonFile("/app/config/appsettings.json", optional: false, reloadOnChange: true);

Kubernetes provides declarative updates and rollbacks, completing the end-to-end chain of environment-safe configuration.


7 Feature Flags, Typed Bindings, and Progressive Delivery

Modern software delivery emphasizes iteration without risk. Instead of all-or-nothing deployments, feature flags let teams progressively enable capabilities, toggle experiments, or disable malfunctioning components in real time. When feature flags are treated as part of configuration rather than ad-hoc conditionals, they become predictable, testable, and governable—critical for safe continuous delivery.

7.1 Why Flags Belong in Configuration: Gradual Rollout, Kill Switches, and Experiment Gates

Feature flags sit at the intersection of configuration and release management. When handled properly, they enable three high-value practices:

  1. Gradual Rollout: Deploy code broadly but activate new functionality incrementally—say, 5% of users per day—so you can observe performance before full exposure.
  2. Kill Switches: Disable a problematic component instantly without redeployment. This is vital for mitigating external dependency failures or runaway costs.
  3. Experimentation Gates: Drive A/B testing and behavior-based experiments without introducing new deployment artifacts.

Flags shouldn’t live in static JSON files or hard-coded conditionals. They must exist within a configuration system that supports safe reloads, centralized management, and observability.

Example of what not to do:

Incorrect

if (DateTime.UtcNow > new DateTime(2025, 10, 1))
{
    EnableNewPricingPage();
}

This approach hardwires rollout logic into the code. Once deployed, it can’t be changed without redeployment.

Correct

if (_featureManager.IsEnabledAsync("NewPricingPage").Result)
{
    EnableNewPricingPage();
}

The flag is externalized and controllable at runtime. This is the essence of production-grade progressive delivery.

7.2 Azure App Configuration + Microsoft.FeatureManagement

Azure App Configuration integrates naturally with the Microsoft.FeatureManagement package, providing dynamic, cloud-managed flags that update without restarts.

Registration

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(builder.Configuration["AppConfigConnection"])
        .UseFeatureFlags(flagOptions =>
        {
            flagOptions.CacheExpirationInterval = TimeSpan.FromSeconds(30);
        });
});

builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();

Then wire it into the pipeline:

var app = builder.Build();
app.UseAzureAppConfiguration();

Common Feature Filters

Feature filters determine the conditions under which a flag activates.

  • PercentageFilter: Enables a flag for a percentage of users or sessions.
  • TimeWindowFilter: Activates a feature within a specific timeframe.
  • TargetingFilter: Allows fine-grained control by user, group, or audience.

Example configuration in Azure App Configuration:

{
  "FeatureManagement": {
    "NewCheckoutFlow": {
      "EnabledFor": [
        {
          "Name": "Percentage",
          "Parameters": { "Value": 25 }
        },
        {
          "Name": "TimeWindow",
          "Parameters": {
            "Start": "2025-10-16T00:00:00Z",
            "End": "2025-10-20T00:00:00Z"
          }
        }
      ]
    }
  }
}

ASP.NET Core evaluates these filters per request. Flags are resolved at runtime, allowing immediate adjustments from the Azure portal or via REST API.

Per-Request Evaluation

Inject the IFeatureManagerSnapshot interface to check flags on a per-request basis:

public class CheckoutController(IFeatureManagerSnapshot featureManager)
{
    public async Task<IActionResult> Index()
    {
        if (await featureManager.IsEnabledAsync("NewCheckoutFlow"))
            return View("NewCheckout");
        return View("LegacyCheckout");
    }
}

IFeatureManagerSnapshot captures the state once per request, avoiding inconsistent results.

7.3 Designing Typed Flag “Views” for Services

Using string literals for flags is brittle and unmaintainable. A typed “view” or wrapper provides compile-time safety, discoverability, and consistency.

public interface IFeatureFlags
{
    Task<bool> NewCheckoutFlowAsync();
    Task<bool> EnableDiscountEngineAsync();
}

public class FeatureFlags(IFeatureManager featureManager) : IFeatureFlags
{
    public Task<bool> NewCheckoutFlowAsync() => featureManager.IsEnabledAsync("NewCheckoutFlow");
    public Task<bool> EnableDiscountEngineAsync() => featureManager.IsEnabledAsync("EnableDiscountEngine");
}

Now, application services depend on IFeatureFlags rather than direct string-based lookups:

if (await _flags.EnableDiscountEngineAsync())
{
    ApplyPromotions();
}

This indirection pays off as flags evolve—you can rename, deprecate, or alias flags without touching business logic.

For analytics, you can extend the wrapper to emit telemetry each time a flag is evaluated, linking configuration decisions to runtime outcomes.

7.4 Live Refresh from App Configuration and Safe Fallbacks

Feature flags are only as reliable as their source. If the Azure App Configuration service becomes unreachable, your app must degrade gracefully.

Enable Refresh

Register refresh for both configuration and feature flags:

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(builder.Configuration["AppConfigConnection"])
        .ConfigureRefresh(refresh =>
        {
            refresh.Register("App:Sentinel", refreshAll: true);
        })
        .UseFeatureFlags();
});

Handle Fallbacks

IFeatureManager gracefully falls back to cached values if App Configuration is offline. You can extend this behavior:

public class ResilientFeatureManager(IFeatureManager manager, ILogger<ResilientFeatureManager> logger)
{
    public async Task<bool> IsEnabledAsync(string name)
    {
        try
        {
            return await manager.IsEnabledAsync(name);
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "Feature flag service unavailable. Defaulting to safe state for {Flag}.", name);
            return false;
        }
    }
}

Fail-safe behavior ensures critical paths—like payments—stay stable during transient outages.

7.5 Example: Rolling Out a New Pricing Page

Consider an e-commerce app introducing a redesigned pricing page. The rollout strategy uses an Azure App Configuration feature flag and request-based percentage filter.

Setup

{
  "FeatureManagement": {
    "NewPricingPage": {
      "EnabledFor": [{ "Name": "Percentage", "Parameters": { "Value": 10 } }]
    }
  }
}

Controller Integration

[FeatureGate("NewPricingPage")]
public class PricingController : Controller
{
    public IActionResult Index() => View("New");
}

[FeatureGate("!NewPricingPage")]
public class LegacyPricingController : Controller
{
    public IActionResult Index() => View("Legacy");
}

The FeatureGate attribute transparently routes traffic based on flag state. Once metrics indicate stability, you can raise the percentage to 100% or retire the old route entirely.

Observability

Integrate telemetry:

_logger.LogInformation("User {UserId} experienced pricing page: {Version}",
    userId,
    await _featureManager.IsEnabledAsync("NewPricingPage") ? "new" : "legacy");

Linking feature exposure to telemetry creates a closed feedback loop for data-driven rollout decisions.


8 Putting It All Together: A Real-World Reference Implementation

To operationalize these principles, let’s build a cohesive architecture combining configuration, secrets, validation, and feature management. This section outlines a reference implementation structured for real-world production environments.

8.1 Solution Layout and Contracts

A clean project layout separates configuration concerns clearly:

/Contracts
    /Options
        PaymentsOptions.cs
        MessagingOptions.cs
        HttpPolicyOptions.cs
/Infrastructure
    /Configuration
        ApiConfigurationProvider.cs
        Validation
            PaymentOptionsValidator.cs
/Bootstrap
    Program.cs

Each Options class acts as a configuration contract. Validators live alongside the options to ensure early and localized correctness.

8.2 Program.cs Wiring (Minimal Hosting)

The entry point orchestrates provider ordering and environment-specific sources:

var builder = WebApplication.CreateBuilder(args);

builder.Configuration
    .AddJsonFile("appsettings.json", false, true)
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true)
    .AddUserSecrets<Program>(optional: true)
    .AddEnvironmentVariables();

if (builder.Environment.IsProduction())
{
    builder.Configuration.AddAzureAppConfiguration(options =>
    {
        options.Connect(builder.Configuration["AppConfigConnection"])
            .UseFeatureFlags()
            .ConfigureRefresh(refresh =>
            {
                refresh.Register("App:Sentinel", refreshAll: true);
            });
    });

    builder.Configuration.AddAzureKeyVault(
        new Uri(builder.Configuration["KeyVaultUri"]),
        new DefaultAzureCredential());
}

builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();

builder.Services.AddOptions<PaymentsOptions>()
    .BindConfiguration("Payments")
    .ValidateOnStart()
    .ValidateDataAnnotations();

This unified setup yields predictable precedence and enables cloud-native reloads in production.

8.3 Strongly-Typed Config with Source-Generated Binding

Each configuration contract uses source-generated binding for AOT safety and startup performance.

[ConfigurationKeyName("Payments")]
public sealed record PaymentsOptions
{
    [Required]
    public required string ApiBaseUrl { get; init; }

    [Required]
    public required string ApiKey { get; init; }

    [Range(1, 30)]
    public int TimeoutSeconds { get; init; }
}

A custom validator ensures cross-field integrity:

public class PaymentOptionsValidator : IValidateOptions<PaymentsOptions>
{
    public ValidateOptionsResult Validate(string? name, PaymentsOptions opts)
    {
        if (opts.TimeoutSeconds < 1)
            return ValidateOptionsResult.Fail("Timeout must be positive.");
        if (!Uri.TryCreate(opts.ApiBaseUrl, UriKind.Absolute, out _))
            return ValidateOptionsResult.Fail("ApiBaseUrl is invalid.");
        return ValidateOptionsResult.Success;
    }
}

Validation runs on startup via ValidateOnStart(), preventing runtime surprises.

8.4 Feature Flags in Middleware and Filters

ASP.NET Core integrates feature flags seamlessly into request pipelines.

Middleware Approach

app.Use(async (context, next) =>
{
    var featureManager = context.RequestServices.GetRequiredService<IFeatureManagerSnapshot>();
    if (await featureManager.IsEnabledAsync("MaintenanceMode"))
    {
        context.Response.StatusCode = 503;
        await context.Response.WriteAsync("Service under maintenance.");
        return;
    }
    await next();
});

Attribute-Based

[FeatureGate("BetaDashboard")]
public class DashboardController : Controller
{
    public IActionResult Index() => View("Beta");
}

Feature toggles can guard routes, actions, or entire controllers.

8.5 Fail-Fast Boot Pipeline

Startups should fail deterministically when configuration or secrets are invalid. Implement a bootstrap health check:

var app = builder.Build();

if (!app.Configuration.GetValue<bool>("Bootstrap:AllowUnvalidated"))
{
    using var scope = app.Services.CreateScope();
    var validators = scope.ServiceProvider.GetServices<IValidateOptions<PaymentsOptions>>();
    foreach (var validator in validators)
    {
        var result = validator.Validate(null, scope.ServiceProvider.GetRequiredService<IOptions<PaymentsOptions>>().Value);
        if (result.Failed)
        {
            throw new InvalidOperationException($"Configuration invalid: {result.FailureMessage}");
        }
    }
}

Add a dry-run check for external dependencies:

using var client = new HttpClient { BaseAddress = new Uri(payments.ApiBaseUrl) };
var health = await client.GetAsync("/health");
if (!health.IsSuccessStatusCode)
    throw new InvalidOperationException("Payment API unreachable during boot.");

These checks enforce that deployments are only accepted in a healthy state.

8.6 Observability: Logging and Health Endpoints

For safe operations, your configuration system must emit structured events.

Configuration Change Logging

optionsMonitor.OnChange((opts, name) =>
{
    _logger.LogInformation("Configuration '{Name}' reloaded at {Time}", name, DateTime.UtcNow);
});

Exposing Health Information

Expose sanitized configuration through a diagnostics endpoint:

app.MapGet("/_health/config", (IOptionsMonitor<PaymentsOptions> opts) =>
{
    return Results.Ok(new
    {
        opts.CurrentValue.ApiBaseUrl,
        Timeout = opts.CurrentValue.TimeoutSeconds
    });
});

This helps SREs verify effective values in live environments without exposing sensitive data.

8.7 CI/CD Flow: Config and Secrets Across Environments

A mature pipeline moves configuration securely from Dev to Prod:

  1. Development: Local JSON + dotnet user-secrets.
  2. Build (CI): Inject environment variables from a secure store.
  3. Stage/Prod: Provision Azure Key Vault and App Configuration with infrastructure-as-code (Bicep/Terraform).
  4. Deployment: App reads only from approved sources, using Managed Identity.
  5. Rollback: Use App Configuration revisions or vault versioning to revert to previous states instantly.

CI pipelines should validate configuration consistency before deployment:

dotnet run -- --check-config

This invokes your ValidateOnStart() validators in headless mode.

8.8 Governance Checklist

A production-grade configuration system requires disciplined governance. Adopt these practices:

  • Code Reviews for Config: Treat configuration files like source code—require PR reviews.
  • Schema Enforcement: Use JSON schema or custom analyzers to detect drift early.
  • CI Linting: Run automated validation on every commit (dotnet run --check-config).
  • Access Controls: Restrict Key Vault and App Config write access to CI or operations teams.
  • Rotation Policy: Rotate secrets automatically; never reuse between environments.
  • Change Auditing: Log all configuration changes with timestamps and approvers.
  • Break-Glass Procedures: Define an emergency override path for disabling dangerous features fast.

When combined, these patterns create a production-grade configuration architecture: secure, validated, observable, and adaptable. Configuration ceases to be a deployment afterthought—it becomes an integral part of your runtime resilience and delivery strategy.

Advertisement