Configuration and Secrets in .NET 8: appsettings.json, Environment Variables, and User Secrets
Published:
This post covers how configuration works in modern .NET applications and how to keep secrets out of source control. The core idea is simple: settings should come from configuration providers, secrets should be stored outside your repo, and your code should read configuration through strongly typed options whenever possible.
Why configuration matters
Applications rarely run with the same settings everywhere. Local development, test environments, staging, and production each need different values for:
- connection strings
- API keys
- logging levels
- feature flags
- third-party endpoints
Hard-coding these values leads to brittle deployments and leaked secrets. The .NET configuration system exists to solve that problem cleanly.
Default configuration providers in ASP.NET Core
When you create an ASP.NET Core app with:
var builder = WebApplication.CreateBuilder(args);
the framework already wires up common configuration sources, including:
appsettings.jsonappsettings.{Environment}.json- user secrets in Development
- environment variables
- command-line arguments
Later providers override earlier ones. That means an environment variable can replace a value from appsettings.json without changing the file itself.
Using appsettings.json
appsettings.json holds default configuration for the application.
Example:
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=StoreDb;Trusted_Connection=True;TrustServerCertificate=True"
},
"Mail": {
"Host": "smtp.example.com",
"Port": 587,
"Sender": "noreply@example.com"
},
"Features": {
"EnableDetailedErrors": false
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
This file is the right place for:
- defaults
- non-sensitive settings
- environment-independent application behavior
It is not the right place for:
- API keys
- passwords
- production certificates
- any secret that would be dangerous if committed to Git
Environment-specific settings
You can override defaults with environment-specific files such as:
appsettings.Development.jsonappsettings.Staging.jsonappsettings.Production.json
Example appsettings.Development.json:
{
"Features": {
"EnableDetailedErrors": true
},
"Mail": {
"Host": "localhost"
}
}
If ASPNETCORE_ENVIRONMENT=Development, those values override the base file.
This is useful for:
- enabling verbose logs only in development
- using local infrastructure endpoints
- replacing real services with sandboxes or emulators
Reading configuration directly
You can access values through builder.Configuration:
var builder = WebApplication.CreateBuilder(args);
string? connectionString =
builder.Configuration.GetConnectionString("Default");
string? sender =
builder.Configuration["Mail:Sender"];
This works, but it does not scale well if your code accesses many related values across many classes. That is where strongly typed options help.
Binding configuration to a class
Create a settings class:
public sealed class MailOptions
{
public const string SectionName = "Mail";
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string Sender { get; set; } = string.Empty;
}
Bind it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<MailOptions>(
builder.Configuration.GetSection(MailOptions.SectionName));
Consume it in a service:
using Microsoft.Extensions.Options;
public sealed class MailService(IOptions<MailOptions> options)
{
private readonly MailOptions _mail = options.Value;
public void PrintSettings()
{
Console.WriteLine($"{_mail.Host}:{_mail.Port} from {_mail.Sender}");
}
}
This approach gives you:
- grouped settings
- one place to validate defaults
- clearer dependencies
- easier testing
Environment variables
Environment variables are essential in containers, CI/CD pipelines, and cloud deployments. They are easy to inject without modifying files on disk.
Nested keys use double underscores:
ConnectionStrings__Default=Server=db;Database=StoreDb;User Id=app;Password=secret
Mail__Host=smtp.internal.local
Mail__Port=2525
Examples for Windows PowerShell:
$env:ASPNETCORE_ENVIRONMENT="Development"
$env:Mail__Sender="dev@example.com"
dotnet run
Examples for bash:
export ASPNETCORE_ENVIRONMENT=Development
export Mail__Sender=dev@example.com
dotnet run
Because environment variables override JSON files, they are a common way to provide deployment-specific values without editing the app bundle.
User secrets for local development
User secrets are designed for local development only. They keep sensitive settings out of the project directory while still integrating with the .NET configuration system.
Initialize secrets in a project:
dotnet user-secrets init
Set a secret:
dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;Database=StoreDb;User Id=sa;Password=LocalDevOnly123!"
dotnet user-secrets set "Mail:Sender" "dev-secrets@example.com"
List stored secrets:
dotnet user-secrets list
Why user secrets matter:
- they do not live in
appsettings.json - they are loaded automatically in Development
- they reduce the chance of leaking credentials into source control
Important limitation: user secrets are not a production secret store. For production, use the secret system offered by your host or cloud platform.
A complete configuration example
The following setup reads JSON, environment variables, and options:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<MailOptions>()
.Bind(builder.Configuration.GetSection(MailOptions.SectionName))
.Validate(o => !string.IsNullOrWhiteSpace(o.Host), "Mail host is required.")
.Validate(o => o.Port > 0, "Mail port must be greater than zero.")
.ValidateOnStart();
var app = builder.Build();
app.MapGet("/config-check", (IOptions<MailOptions> options) =>
{
var mail = options.Value;
return Results.Ok(new
{
mail.Host,
mail.Port,
mail.Sender
});
});
app.Run();
That code keeps configuration outside of business logic and fails early if required settings are missing.
How configuration files reach build output
Files like appsettings.json are copied to the build output so the app can read them at runtime. You usually do not need to edit the .csproj for the default templates, but it is useful to know that MSBuild controls this behavior.
If you ever need a custom file copied to the output directory, you can configure it explicitly:
<ItemGroup>
<None Update="seed-data.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
That tells the build system to place the file in bin/Debug/net8.0/ or the matching Release directory.
Production guidance
A simple rule set works well:
- keep defaults in
appsettings.json - keep machine- or environment-specific overrides outside Git
- use environment variables or a platform secret store in hosted environments
- use user secrets only for local development
- validate required settings on startup so bad config fails fast
If your app runs in Azure, AWS, Kubernetes, or another platform, the same pattern still applies. The provider changes, but the design principle stays the same: configuration is external, and secrets are never hard-coded.
Common mistakes to avoid
Watch for these issues:
- storing passwords in
appsettings.json - checking user secrets into documentation or screenshots
- scattering raw string keys like
"Mail:Host"across the entire codebase - assuming development settings will exist in production
- delaying validation until the first live request fails
Configuration bugs are often simple, but they cause real outages. Treat configuration as part of application architecture, not as an afterthought.
Next Article: Logging and Diagnostics in .NET 8: ILogger, Structured Logging, and Log Levels
