.NET reads environment variables through Environment.GetEnvironmentVariable — but in any modern ASP.NET Core or worker-service app, you should almost never call it directly. The right abstraction is IConfiguration, the layered configuration system introduced with ASP.NET Core 1.0 (2016), which reads from appsettings.json, environment variables, command-line flags, user secrets, and Azure Key Vault with documented precedence. The convention to remember: __ (double underscore) in an environment variable name maps to : (colon) in the JSON path, so ConnectionStrings__Default becomes ConnectionStrings:Default. For a true .env loader, the community ships DotNetEnv; for local-development secrets, .NET has its own dotnet user-secrets mechanism. This guide covers the lot, plus the ASPNETCORE_ENVIRONMENT trick that swaps configuration files per stage.
TL;DR
- Read config through
IConfiguration, not bareEnvironment.GetEnvironmentVariable— you get layering, typing, and validation. __(double underscore) becomes:in a key;Servers__0__Hostbinds an array index. Bash rejects:in names, so__is the portable separator on every platform.- Environment variables override every earlier configuration source, and
ASPNETCORE_ENVIRONMENTpicks whichappsettings.{Environment}.jsonfile loads. - Neither environment variables nor
dotnet user-secretsare encrypted — both are development conveniences. Use Azure Key Vault for production secrets. - On .NET 10 (LTS, November 2025) the minimal hosting model —
WebApplication.CreateBuilder— wires all of this up by default.
How do you read an environment variable in C#?
The lowest-level API is System.Environment. It returns nullable strings — null for unset, never an empty string for "not present" — and supports three target scopes on Windows (Machine, User, Process); on Unix only Process is meaningful.
using System;
string? dbUrl = Environment.GetEnvironmentVariable("DATABASE_URL");
string port = Environment.GetEnvironmentVariable("PORT") ?? "3000";
// Whole environment as a non-generic IDictionary
foreach (System.Collections.DictionaryEntry de in Environment.GetEnvironmentVariables())
{
Console.WriteLine($"{de.Key}={de.Value}");
}
// Set / unset (process scope only on Unix)
Environment.SetEnvironmentVariable("MY_VAR", "value");
Environment.SetEnvironmentVariable("MY_VAR", null); // null removes itWhy prefer IConfiguration over GetEnvironmentVariable?
ASP.NET Core's IConfiguration layers multiple sources with a precedence order that survives refactoring better than scattered Environment.GetEnvironmentVariable calls. The default web host wires up these sources in order (later ones override earlier):
appsettings.jsonappsettings.{Environment}.json- User secrets (Development environment only)
- Environment variables
- Command-line arguments
// Program.cs (minimal hosting model, .NET 6+)
var builder = WebApplication.CreateBuilder(args);
// Bind a section to a typed POCO
builder.Services.Configure<DatabaseOptions>(
builder.Configuration.GetSection("Database"));
var app = builder.Build();
// Inject via IOptions<T>
app.MapGet("/", (IOptions<DatabaseOptions> opts) =>
new { connectionString = opts.Value.ConnectionString });
public class DatabaseOptions
{
public string ConnectionString { get; set; } = "";
public int PoolSize { get; set; } = 10;
}How do double underscores map to nested keys?
Environment variable names can't contain colons (forbidden on most shells), so .NET adopted __ as the universal nesting separator. IConfiguration rewrites it to : automatically:
# appsettings.json equivalent
# {
# "ConnectionStrings": { "Default": "..." },
# "Logging": { "LogLevel": { "Default": "Information" } }
# }
export ConnectionStrings__Default="Server=localhost;Database=app"
export Logging__LogLevel__Default="Debug"
export Database__PoolSize="20"This means any nested config key can be overridden via environment variables in production without touching JSON files — the standard pattern for Kubernetes ConfigMaps or AWS Parameter Store. Microsoft is blunt about why __ exists at all: "Bash doesn't support colon (:) as a separator. All platforms support the double underscore (__) syntax and automatically replace it with a colon." So : works in appsettings.json and in dotnet user-secrets keys, but never in an exported environment variable name.
The index trick is the part people miss: a numeric segment binds an array or list, so a Docker or Compose env block can populate a whole List<T> without a JSON file:
# appsettings.json equivalent
# { "Servers": [ { "Host": "db-a", "Port": 5432 },
# { "Host": "db-b", "Port": 5432 } ] }
export Servers__0__Host="db-a"
export Servers__0__Port="5432"
export Servers__1__Host="db-b"
export Servers__1__Port="5432"// Object-graph binding fills the list from the indexed keys
var servers = builder.Configuration
.GetSection("Servers")
.Get<List<ServerOptions>>();
public class ServerOptions
{
public string Host { get; set; } = "";
public int Port { get; set; }
}What does ASPNETCORE_ENVIRONMENT control?
ASPNETCORE_ENVIRONMENT picks which appsettings.{Environment}.json file is loaded and gates development-only middleware like the developer exception page and user secrets. The convention values are Development, Staging, and Production, but any string works:
# Switches between appsettings.Development.json and appsettings.Production.json
export ASPNETCORE_ENVIRONMENT=Production
# Same machinery for non-web workers
export DOTNET_ENVIRONMENT=Production// In Program.cs
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}When should you use User Secrets vs DotNetEnv?
For local-development secrets — API keys, dev DB passwords — .NET has a built-in mechanism that stores values in your user profile, never in the project directory:
# Initialize once per project
dotnet user-secrets init
# Store secrets — kept in ~/.microsoft/usersecrets/<id>/secrets.json
dotnet user-secrets set "Database:ConnectionString" "Server=localhost;..."
dotnet user-secrets set "Stripe:SecretKey" "sk_test_..."For projects where a .env file makes more sense (multi-language teams, Docker Compose dev environments), the DotNetEnv package loads .env files into Environment.GetEnvironmentVariable at startup:
// dotnet add package DotNetEnv
DotNetEnv.Env.Load(); // .env in working directory
DotNetEnv.Env.Load(".env.local");
// IConfiguration sees the loaded values
var config = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();Which IOptions interface should you inject?
Binding to a POCO is the easy part; picking the right accessor is where reload behaviour and dependency-injection lifetimes bite. There are three, and they are not interchangeable:
| Interface | DI lifetime | Reads changes after start | Use when |
|---|---|---|---|
IOptions<T> | Singleton | No | Config is fixed for the process lifetime (the common case). |
IOptionsSnapshot<T> | Scoped | Yes, recomputed per request | Per-request values; cannot be injected into a singleton. |
IOptionsMonitor<T> | Singleton | Yes, live with change callbacks | Long-lived services that must react to live config changes. |
All three support named options and data-annotation validation; add .ValidateDataAnnotations().ValidateOnStart() to fail fast at boot instead of on first request. One Docker-specific gotcha: change notifications rely on file watchers, and Docker bind mounts or network shares often miss them — set DOTNET_USE_POLLING_FILE_WATCHER=1 so IOptionsMonitor polls instead.
Practical patterns and gotchas
- Use IConfiguration, not Environment. Reach for the latter only at the very edges of your app (e.g. very early bootstrap before a host is built).
- Bind to a POCO with IOptions<T> rather than reading individual keys. You get strong typing, validation via
[Required]and friends, and automatic reloading withIOptionsMonitor<T>. - Never check user secrets into git. The whole point is that they live outside the repo. .NET's project file references the secrets-id GUID, not the values.
- Azure Key Vault for production secrets. The
Azure.Extensions.AspNetCore.Configuration.Secretspackage layers Key Vault intoIConfigurationso the rest of your code stays unchanged. - Validate your .env files with the .env validator before they hit your container build.
For language-agnostic security, validation, and rotation rules, see environment variable best practices.
When should you not reach for IConfiguration?
- Before the host exists — code that runs in
MainbeforeWebApplication.CreateBuilderreturns has noIConfigurationyet. ReadEnvironment.GetEnvironmentVariabledirectly for that narrow bootstrap window. - For real secrets in production — environment variables are plaintext in
/proc/<pid>/environ, process listings, logs, and crash dumps, and User Secrets is unencrypted dev-only storage. The env variable security guide covers why, and Azure Key Vault is the production answer. - For values that change mid-process if you injected
IOptions<T>— it snapshots once at startup. UseIOptionsSnapshot<T>orIOptionsMonitor<T>instead.
References
- Configuration in ASP.NET Core — the provider model, precedence order, and the
__separator rule - Options pattern in .NET — IOptions vs IOptionsSnapshot vs IOptionsMonitor, plus validation
- Safe storage of app secrets in development — how
dotnet user-secretsworks and why it is not encrypted - DotNetEnv — the community
.envloader for .NET