Every application needs some kind of configuration: whitelisted URLs, threshold values, database connection strings. Some values differ between environments; others stay the same.

One application across three environmentsThe same application uses different database and logging configuration in development, staging, and production.

Application configuration

Development: Local DB · Debug logs

Staging: Test DB · Info logs

Production: Production DB · Warning logs

All of these sources can contribute configuration to the same application. To understand how .NET combines them, we first need to establish a few core concepts.

Core concepts

What is configuration?

Configuration is a collection of values that controls application behavior without changing the actual code. A database connection string is a good example.

Configuration sources

A configuration source is where configuration values originate.

The best-known configuration source in .NET is appsettings.json. Beyond that, there are also:

  • Environment variables
  • Command-line arguments
  • User Secrets
  • In-memory values
  • Azure App Configuration
  • Azure Key Vault

Configuration providers

For configuration to be read from a source, a configuration provider is required. It knows how to:

  • read values from a configuration source
  • convert them into the .NET configuration representation
  • expose them to the configuration system
  • optionally support change detection and reloads

JSON files use the JSON configuration provider, and environment variables use the environment variables provider. Every configuration source has a dedicated configuration provider.

Configuration sources flow through providersJSON, environment variables, and command-line arguments each have a provider that contributes values to IConfiguration for the application.

JSON file

JSON provider

Environment variables

Environment provider

Command-line arguments

Command-line provider

IConfiguration

Application

IConfiguration represents the application’s combined configuration. It gives the application a single abstraction over all registered providers.

Custom configuration providers

Providers aren’t limited to the built-in ones. You can write your own by implementing IConfigurationSource and ConfigurationProvider, the same base class the built-in providers use. This minimal provider reads Key=Value pairs from a flat file, with Section:Key nesting:

// KeyValueConfigurationSource.cs
using Microsoft.Extensions.Configuration;

namespace MiniApi.Configuration;

public class KeyValueConfigurationSource : IConfigurationSource
{
    public string Path { get; set; } = string.Empty;
    public bool Optional { get; set; } = true;

    public IConfigurationProvider Build(IConfigurationBuilder builder)
        => new KeyValueConfigurationProvider(Path, Optional);
}
// KeyValueConfigurationProvider.cs
using Microsoft.Extensions.Configuration;

namespace MiniApi.Configuration;

// Reads simple "Key=Value" lines, supports "Section:Key=Value" nesting,
// reloads automatically when the file changes on disk.
public class KeyValueConfigurationProvider : ConfigurationProvider
{
    private readonly string _path;
    private readonly bool _optional;

    public KeyValueConfigurationProvider(string path, bool optional)
    {
        _path = path;
        _optional = optional;
    }

    public override void Load()
    {
        var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);

        if (!File.Exists(_path))
        {
            if (!_optional)
                throw new FileNotFoundException($"Config source not found: {_path}");

            Data = data;
            return;
        }

        foreach (var rawLine in File.ReadAllLines(_path))
        {
            var line = rawLine.Trim();

            if (line.Length == 0 || line.StartsWith('#'))
                continue;

            var separatorIndex = line.IndexOf('=');
            if (separatorIndex <= 0)
                continue;

            var key = line[..separatorIndex].Trim();
            var value = line[(separatorIndex + 1)..].Trim();

            data[key] = value;
        }

        Data = data;
    }
}

A small extension method keeps registration readable:

// KeyValueConfigurationExtensions.cs
using Microsoft.Extensions.Configuration;

namespace MiniApi.Configuration;

public static class KeyValueConfigurationExtensions
{
    public static IConfigurationBuilder AddKeyValueFile(
        this IConfigurationBuilder builder, string path, bool optional = true)
    {
        return builder.Add(new KeyValueConfigurationSource 
        { 
          Path = path, 
          Optional = optional 
        });

    }
}

Hierarchical key-value model

Regardless of the source, .NET always represents data in the same structure: a hierarchical key-value model.

It doesn’t matter whether it’s a JSON file:

{
  "Payments": {
    "Stripe": {
      "Timeout": 30,
      "RetryCount": 3
    }
  }
}

An environment variable:

Payments__Stripe__Timeout=30

Or a command-line argument:

--Payments:Stripe:Timeout=30

.NET uses the section delimiter (:) to flatten everything into one Dictionary<string, string>, preserving the parent-child relationship:

Different source formats become one configuration keyJSON nesting, double-underscore environment variables, and colon-delimited command-line arguments all map to Payments colon Timeout.

JSON: Payments → Timeout → 30

Payments:Timeout = 30

Environment variable: Payments__Timeout=30

Command line: --Payments:Timeout=30

Configuration precedence

When multiple configuration providers contain the same key, the value from the provider added later has higher priority and overrides the previous value.

Later configuration providers override earlier onesThe appsettings value is replaced by the environment-variable value, which is then replaced by the command-line value of 40.

overridden by

overridden by

appsettings.json: Timeout = 10

Environment variable: Timeout = 30

Command line: Timeout = 40

Effective value: Payments:Timeout = 40

The application code doesn’t need to know where the final value originated. It asks the configuration system for Payments:Timeout and receives the effective value after all providers have been processed.

Precedence depends entirely on the order in which providers are registered.

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

Command-line arguments win here: that provider was added last.

.NET default configuration

The configuration pipeline is set up inside Program.cs:

using MiniApi.Configuration;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddKeyValueFile("settings.kv", optional: true);

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

WebApplication.CreateBuilder(args) registers the standard application configuration sources automatically, including JSON files, User Secrets in Development, environment variables, command-line arguments, and more:

Default ASP.NET Core configuration priorityDefault values begin in appsettings, then environment-specific settings, local user secrets, environment variables, and command-line arguments override them in order.

appsettings.json: Shared defaults

appsettings.{Environment}.json: Environment defaults

User Secrets: Development only

Environment variables: Deployment overrides

Command-line arguments: Explicit startup overrides

Effective ASP.NET Core configuration

appsettings.json

This file holds the base configuration for all environments:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

It’s in JSON format and it’s a good fit for values like:

  • timeouts
  • logging defaults
  • feature flags

appsettings.{Environment}.json

These files are environment-specific and override values from appsettings.json. .NET loads appsettings.{ENVIRONMENT}.json after appsettings.json:

// appsettings.json
{
  "Payments": {
    "Timeout": 30
  }
}
// appsettings.Production.json
{
  "Payments": {
    "Timeout": 60
  }
}

Since environment configuration loads after the base file, .NET already knows how to resolve the {ENVIRONMENT} placeholder.

It reads the ASPNETCORE_ENVIRONMENT variable, set here in launchSettings.json:

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "http://localhost:5264",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Environment variables

These values come from the operating system, the host (container), or the CI/CD pipeline. Because different systems (Windows, Mac, Linux) provide them differently, they use a double underscore (__) as a separator. .NET converts that to the section delimiter (:) internally.

A .NET configuration key and its environment-variable equivalent:

.NET configuration keyEnvironment variable
Payments:TimeoutPayments__Timeout

A Docker Compose file shows this in practice:

services:
  api:
    environment:
      Payments__BaseUrl: https://payments.example.com
      Payments__Timeout: 60

Environment variables have higher priority than appsettings.json and appsettings.{Environment}.json:

They let the deployment environment override application defaults without modifying the application package.

Command line

Values can also be attached from the CLI to a specific process at startup:

dotnet MyApp.dll --Payments:Timeout=90

That argument becomes the logical configuration key Payments:Timeout = 90, so application code reads it the same way as any other value:

var timeout =
    builder.Configuration.GetValue<int>("Payments:Timeout");

Command-line arguments have the highest precedence and override all previous values:

SourceRoleTypical environment
appsettings.jsonShared defaultsAll
appsettings.{Environment}.jsonEnvironment defaultsDev/Staging/Prod
User SecretsLocal secretsDevelopment
Environment variablesDeployment valuesUsually production
Command lineExplicit startup overrideAny

The table is ordered from lowest to highest priority.

IConfiguration

IConfiguration is the read-only abstraction your application uses to access the effective configuration after all registered providers have contributed their values.

var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;

At this point, builder.Configuration already represents the application’s combined configuration. The key idea is to read configuration through a single API, regardless of where the values originally came from.

The simplest usage accesses values through the indexer, which always returns a string:

var timeout = configuration["Payments:Timeout"];

The same indexer is how a request-pipeline component reads a single value without binding a whole section. An authorization filter checking an API key against configuration["DemoApiKey"] is a real example in Filters in .NET.

GetValue<T> is a better approach, in my opinion, because you get the type you need:

var timeout = configuration.GetValue<int>("Payments:Timeout");

IConfiguration can also expose an entire subtree through sections. The example above can be written a bit longer, but cleaner:

var paymentsSection = configuration.GetSection("Payments");
var timeout = paymentsSection.GetValue<int>("Timeout");

For simple scenarios, direct access through IConfiguration works fine. But as more settings get added, it becomes a maintenance problem:

  • configuration keys are repeated as strings
  • settings are scattered across the codebase
  • related values aren’t modeled together

To avoid that and keep related configuration encapsulated, bind it to strongly typed objects instead.

Binding configuration to objects

A new configuration section is added for payments:

{
  "Payments": {
    "BaseUrl": "https://api.example.com",
    "Timeout": 30,
    "RetryCount": 3
  }
}

This is a good candidate for a PaymentSettings type. First, the class contract:

public sealed class PaymentSettings
{
    public string BaseUrl { get; init; } = string.Empty;
    public int Timeout { get; init; }
    public int RetryCount { get; init; }
}

None of these properties reject a bad value yet. Timeout binds a negative number just as happily as a positive one. C# 14’s field keyword makes that validation possible directly inside the setter; see .NET 10 and C# 14: The features I’d actually use in production for the pattern.

Then the mapping:

var settings = configuration
    .GetSection("Payments")
    .Get<PaymentSettings>();

Get<T> always creates a new instance and returns it. Nested objects, if there are any, get populated too. Consuming it looks like this:

var settings = configuration.GetSection("Payments").Get<PaymentSettings>();

Console.WriteLine($"Calling {settings.BaseUrl} with a {settings.Timeout}s timeout");

If you’d rather not create a new object every time, use Bind() instead. It populates an object you’ve already created:

var settings = new PaymentSettings();

configuration
    .GetSection("Payments")
    .Bind(settings);

My personal preference is Bind(), since I’m not a fan of creating a new object every time. Use whatever fits your case.

That covers sources, providers, precedence, and binding to typed objects. A follow-up post covers the Options pattern, validation, and a production-ready setup.