43. Configuration Management

Managing application configuration safely: appsettings hierarchy, user secrets, environment variables, and Azure Key Vault

About this chapter

Manage configuration safely across environments by separating code from configuration, using appsettings hierarchy, user secrets, and cloud vaults for sensitive data.

  • Configuration separation: Keeping secrets out of code
  • appsettings hierarchy: Base, environment-specific, and override layers
  • User Secrets: Safe local development configuration
  • Environment variables: Overriding configuration in deployment
  • Azure Key Vault: Production secret management
  • Security best practices: Never hardcoding sensitive values

Learning outcomes:

  • Understand configuration management principles
  • Use appsettings files for different environments
  • Configure User Secrets for local development
  • Override configuration with environment variables
  • Integrate Azure Key Vault for production secrets
  • Implement configuration providers in dependency injection

43.1 Why Configuration Management Matters

Bad approach:

// ❌ WRONG - Hardcoded values
var connectionString = "Host=localhost;Database=commandapi;Username=postgres;Password=SuperSecret123!";
var apiKey = "sk_live_abc123xyz789";
var emailApiUrl = "https://api.sendgrid.com";

Problems:

  • Security: Secrets in code visible to everyone
  • Inflexibility: Different values per environment (dev, staging, prod)
  • Accidental commits: Secrets pushed to GitHub (can’t be recovered!)
  • Hard to update: Secrets change without code changes

Good approach:

Code                Environment
(no secrets)        (variables, vaults)
        ↓           ↓
    Configuration
        ↓
    Application

Configuration comes from safe sources, never from code.

43.2 The appsettings Hierarchy

Files and their purpose:

appsettings.json                    Base config (all environments)
appsettings.Development.json        Dev-specific (checked in)
appsettings.Production.json         Prod-specific (checked in)
User Secrets (development only)     Sensitive dev values
Environment Variables              Prod overrides
Azure Key Vault                     Production secrets

Precedence (top overwrites bottom):

Azure Key Vault
    ↓
Environment Variables
    ↓
appsettings.{ASPNETCORE_ENVIRONMENT}.json
    ↓
User Secrets (dev only)
    ↓
appsettings.json

43.3 appsettings.json Files

Base configuration (shared across environments):

// appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.EntityFrameworkCore": "Information"
    }
  },
  "AllowedHosts": "*",
  "AppSettings": {
    "ApiName": "CommandAPI",
    "ApiVersion": "1.0.0",
    "MaxPageSize": 50
  },
  "ConnectionStrings": {
    "PostgreSqlConnection": ""  // Empty—set per environment
  }
}

Development-specific (checked into git):

// appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft.EntityFrameworkCore.Database.Command": "Debug"
    }
  },
  "ConnectionStrings": {
    "PostgreSqlConnection": "Host=localhost;Database=commandapi_dev;Username=postgres;Password=devpassword"
  },
  "AppSettings": {
    "EnableDetailedErrors": true
  }
}

Production-specific (checked into git, no secrets):

// appsettings.Production.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "ConnectionStrings": {
    "PostgreSqlConnection": ""  // Empty—set via env var or Key Vault
  },
  "AppSettings": {
    "EnableDetailedErrors": false
  }
}

What to commit:

✓ appsettings.json                  (base, no secrets)
✓ appsettings.Development.json      (dev config, no real secrets)
✓ appsettings.Production.json       (structure only, no secrets)
✗ appsettings.Production.Secrets.json (never commit!)

43.4 User Secrets for Development

User secrets store sensitive development values outside the codebase.

Initialize user secrets:

# In project directory
dotnet user-secrets init

# Creates: ~/.microsoft/usersecrets/{project-guid}/secrets.json

Set secrets:

# Set connection string
dotnet user-secrets set "ConnectionStrings:PostgreSqlConnection" \
  "Host=localhost;Database=commandapi;Username=postgres;Password=realpassword"

# Set API key
dotnet user-secrets set "ApiKeys:SendGrid" "sg_abc123xyz"

# Set JWT secret
dotnet user-secrets set "JwtSecret" "very-secret-key-at-least-32-characters-long"

# List all secrets
dotnet user-secrets list

# Remove a secret
dotnet user-secrets remove "ApiKeys:SendGrid"

# Clear all secrets
dotnet user-secrets clear

User secrets in code:

// Program.cs - Automatically loaded in development
var builder = WebApplication.CreateBuilder(args);

// In development, user secrets are automatically loaded
// No additional configuration needed!
// They override appsettings.Development.json

var connectionString = builder.Configuration.GetConnectionString("PostgreSqlConnection");
// Gets value from user secrets if set, otherwise appsettings.Development.json

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseNpgsql(connectionString);
});

.gitignore user secrets:

# User secrets
**/secrets.json
**/secrets.*.json

Never commit secrets.json!

43.5 Environment Variables for Production

Environment variables override appsettings values.

Setting environment variables (Linux/Docker):

# Set variable
export ConnectionStrings__PostgreSqlConnection="Host=prod-server;Database=commandapi;..."
export ApiKeys__SendGrid="sg_abc123xyz"
export JwtSecret="very-secret-key"

# Run app
dotnet CommandAPI.dll

Docker Compose (environment):

services:
  api:
    image: commandapi:1.0
    environment:
      ASPNETCORE_ENVIRONMENT: Production
      ConnectionStrings__PostgreSqlConnection: "Host=postgres;Database=commandapi;..."
      ApiKeys__SendGrid: ${SENDGRID_API_KEY}
      JwtSecret: ${JWT_SECRET}

Using .env file (Docker Compose):

# .env (add to .gitignore)
ASPNETCORE_ENVIRONMENT=Production
ConnectionStrings__PostgreSqlConnection=Host=prod-db;Database=commandapi;...
ApiKeys__SendGrid=sg_abc123xyz
JWT_SECRET=very-secret-key

Environment variable naming:

// JSON hierarchy uses colon separator in code
"ConnectionStrings": {
  "PostgreSqlConnection": "..."
}

// Environment variables use double underscore (colon is invalid in some shells)
ConnectionStrings__PostgreSqlConnection=...

Access in code:

var builder = WebApplication.CreateBuilder(args);

// Access values (same code regardless of source)
var connectionString = builder.Configuration.GetConnectionString("PostgreSqlConnection");
var apiKey = builder.Configuration["ApiKeys:SendGrid"];
var jwtSecret = builder.Configuration["JwtSecret"];

43.6 Azure Key Vault Integration

For production, use Azure Key Vault for secrets.

Setup:

# Create key vault
az keyvault create --resource-group myresourcegroup --name mykeyvault

# Add secrets
az keyvault secret set --vault-name mykeyvault --name ConnectionStrings-PostgreSqlConnection \
  --value "Host=prod-db;Database=commandapi;..."

az keyvault secret set --vault-name mykeyvault --name ApiKeys-SendGrid \
  --value "sg_abc123xyz"

Add NuGet package:

dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
dotnet add package Azure.Identity

Program.cs configuration:

using Azure.Extensions.AspNetCore.Configuration.Secrets;
using Azure.Identity;

var builder = WebApplication.CreateBuilder(args);

// Load configuration from appsettings
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
builder.Configuration.AddJsonFile(
    $"appsettings.{builder.Environment.EnvironmentName}.json",
    optional: true,
    reloadOnChange: true);

// In development, load user secrets
if (builder.Environment.IsDevelopment())
{
    builder.Configuration.AddUserSecrets<Program>();
}

// In production, load from Azure Key Vault
if (builder.Environment.IsProduction())
{
    var keyVaultUrl = new Uri(builder.Configuration["KeyVault:Url"]);
    
    builder.Configuration.AddAzureKeyVault(
        keyVaultUrl,
        new DefaultAzureCredential());  // Uses managed identity in Azure
}

// Services
var connectionString = builder.Configuration.GetConnectionString("PostgreSqlConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseNpgsql(connectionString);
});

var apiKey = builder.Configuration["ApiKeys:SendGrid"];
builder.Services.AddSingleton<IEmailService>(
    new SendGridEmailService(apiKey));

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

Managed Identity in Azure:

// ✓ CORRECT - Uses Azure managed identity (no secrets!)
var credential = new DefaultAzureCredential();

builder.Configuration.AddAzureKeyVault(
    keyVaultUrl,
    credential);

Local development with Key Vault:

// During development, if you want to test against real Key Vault:
if (builder.Environment.IsDevelopment())
{
    var keyVaultUrl = new Uri(builder.Configuration["KeyVault:Url"]);
    
    // Use DefaultAzureCredential with az login
    builder.Configuration.AddAzureKeyVault(
        keyVaultUrl,
        new DefaultAzureCredential());
}

43.7 Configuration Classes for Type Safety

Strongly-typed configuration:

// Options/DatabaseOptions.cs
public class DatabaseOptions
{
    public const string SectionName = "Database";
    
    public string PostgreSqlConnection { get; set; }
    public int CommandTimeout { get; set; } = 30;
    public bool EnableDetailedErrors { get; set; }
}

// Options/ApiOptions.cs
public class ApiOptions
{
    public const string SectionName = "Api";
    
    public string ApiKey { get; set; }
    public string JwtSecret { get; set; }
    public int JwtExpiryMinutes { get; set; } = 60;
}

// appsettings.json
{
  "Database": {
    "PostgreSqlConnection": "...",
    "CommandTimeout": 30,
    "EnableDetailedErrors": false
  },
  "Api": {
    "ApiKey": "",
    "JwtSecret": "",
    "JwtExpiryMinutes": 60
  }
}

// Program.cs
var builder = WebApplication.CreateBuilder(args);

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

// Bind to options classes
builder.Services.Configure<DatabaseOptions>(
    builder.Configuration.GetSection(DatabaseOptions.SectionName));

builder.Services.Configure<ApiOptions>(
    builder.Configuration.GetSection(ApiOptions.SectionName));

// Inject and use
builder.Services.AddScoped<CommandRepository>(provider =>
{
    var dbOptions = provider.GetRequiredService<IOptions<DatabaseOptions>>();
    return new CommandRepository(dbOptions.Value.PostgreSqlConnection);
});

var app = builder.Build();

// Access in controller
[ApiController]
[Route("api/[controller]")]
public class CommandsController : ControllerBase
{
    private readonly IOptions<ApiOptions> _apiOptions;

    public CommandsController(IOptions<ApiOptions> apiOptions)
    {
        _apiOptions = apiOptions;
    }

    [HttpPost]
    public IActionResult Create(CommandMutateDto dto)
    {
        // Use configuration
        var jwtSecret = _apiOptions.Value.JwtSecret;
        // ...
    }
}

43.8 Configuration Validation on Startup

Validate configuration before running:

// Options/ValidationExtensions.cs
public static class ConfigurationValidationExtensions
{
    public static IServiceCollection ValidateOptions(this IServiceCollection services, IConfiguration configuration)
    {
        // Validate DatabaseOptions
        var dbOptions = configuration.GetSection(DatabaseOptions.SectionName).Get<DatabaseOptions>();
        
        if (string.IsNullOrWhiteSpace(dbOptions?.PostgreSqlConnection))
        {
            throw new InvalidOperationException(
                "ConnectionString 'PostgreSqlConnection' is required and cannot be empty");
        }

        // Validate ApiOptions
        var apiOptions = configuration.GetSection(ApiOptions.SectionName).Get<ApiOptions>();
        
        if (string.IsNullOrWhiteSpace(apiOptions?.JwtSecret) || apiOptions.JwtSecret.Length < 32)
        {
            throw new InvalidOperationException(
                "JwtSecret must be set and at least 32 characters long");
        }

        if (string.IsNullOrWhiteSpace(apiOptions?.ApiKey))
        {
            throw new InvalidOperationException(
                "ApiKey is required");
        }

        return services;
    }
}

// Program.cs
var builder = WebApplication.CreateBuilder(args);

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

if (builder.Environment.IsDevelopment())
{
    builder.Configuration.AddUserSecrets<Program>();
}

// Validate on startup (fail fast)
builder.Services.ValidateOptions(builder.Configuration);

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

Data annotations validation:

using System.ComponentModel.DataAnnotations;

public class DatabaseOptions
{
    public const string SectionName = "Database";
    
    [Required]
    public string PostgreSqlConnection { get; set; }
    
    [Range(1, 300)]
    public int CommandTimeout { get; set; } = 30;
}

// Validation with ValidateDataAnnotationsAttribute
services.Configure<DatabaseOptions>(
    configuration.GetSection(DatabaseOptions.SectionName))
    .ValidateDataAnnotations();

43.9 Configuration per Deployment Target

Development (user secrets + appsettings):

# User secrets set locally
dotnet user-secrets set "ConnectionStrings:PostgreSqlConnection" "..."
dotnet run

Docker local (environment in docker-compose):

services:
  api:
    build: .
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      ConnectionStrings__PostgreSqlConnection: "Host=postgres;..."

Production Azure (Key Vault + managed identity):

# Deploy app to Azure App Service
az webapp up --name commandapi --resource-group myresourcegroup

# Add Key Vault reference
az webapp config appsettings set \
  --resource-group myresourcegroup \
  --name commandapi \
  --settings \
  "KeyVault:Url=https://mykeyvault.vault.azure.net/"

# Assign managed identity
az webapp identity assign \
  --resource-group myresourcegroup \
  --name commandapi

# Grant Key Vault access to managed identity
az keyvault set-policy \
  --name mykeyvault \
  --object-id <managed-identity-id> \
  --secret-permissions get list

43.10 Common Configuration Patterns

Feature flags (controlled rollout):

// appsettings.json
{
  "FeatureFlags": {
    "NewReportingEngine": false,
    "BetaPagination": false,
    "EnableNotifications": true
  }
}
public class CommandService
{
    private readonly IConfiguration _config;

    public CommandService(IConfiguration config)
    {
        _config = config;
    }

    public async Task ProcessCommandAsync(Command command)
    {
        var useNewEngine = _config.GetValue<bool>("FeatureFlags:NewReportingEngine");

        if (useNewEngine)
        {
            await NewReportingEngine.ProcessAsync(command);
        }
        else
        {
            await OldReportingEngine.ProcessAsync(command);
        }
    }
}

Logging configuration:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.EntityFrameworkCore": "Information"
    },
    "ApplicationInsights": {
      "InstrumentationKey": "..."
    }
  }
}

43.11 Security Checklist

Before shipping to production:

  • ✓ No secrets in appsettings.json
  • ✓ No secrets in appsettings.{Environment}.json
  • ✓ .gitignore includes secrets.json and *.secrets.json
  • ✓ User secrets not committed
  • ✓ All secrets in Key Vault or environment variables
  • ✓ Connection strings from Key Vault
  • ✓ API keys from Key Vault
  • ✓ JWT secrets from Key Vault
  • ✓ Configuration validated on startup
  • ✓ Error messages don’t expose secrets
  • ✓ Managed identity used (not connection strings in config)

43.12 What’s Next

You now have:

  • ✓ Understanding configuration hierarchy
  • ✓ appsettings.json files for different environments
  • ✓ User secrets for safe development
  • ✓ Environment variables for production
  • ✓ Azure Key Vault integration
  • ✓ Type-safe configuration classes
  • ✓ Configuration validation on startup
  • ✓ Per-environment configuration strategies
  • ✓ Feature flags and rollout control
  • ✓ Security checklist

Next: CI/CD Pipeline—Automating builds, tests, and deployments with GitHub Actions.