env.dev

C# / .NET Environment Variables & IConfiguration

In C# / .NET, prefer IConfiguration over Environment.GetEnvironmentVariable, and map nested keys with double underscores (ConnectionStrings__Default).

By env.dev Updated

.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 bare Environment.GetEnvironmentVariable — you get layering, typing, and validation.
  • __ (double underscore) becomes : in a key; Servers__0__Host binds an array index. Bash rejects : in names, so __ is the portable separator on every platform.
  • Environment variables override every earlier configuration source, and ASPNETCORE_ENVIRONMENT picks which appsettings.{Environment}.json file loads.
  • Neither environment variables nor dotnet user-secrets are 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.

csharp
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 it

Why 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):

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. User secrets (Development environment only)
  4. Environment variables
  5. Command-line arguments
csharp
// 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:

bash
# 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:

bash
# 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"
csharp
// 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:

bash
# Switches between appsettings.Development.json and appsettings.Production.json
export ASPNETCORE_ENVIRONMENT=Production

# Same machinery for non-web workers
export DOTNET_ENVIRONMENT=Production
csharp
// 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:

bash
# 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:

csharp
// 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:

InterfaceDI lifetimeReads changes after startUse when
IOptions<T>SingletonNoConfig is fixed for the process lifetime (the common case).
IOptionsSnapshot<T>ScopedYes, recomputed per requestPer-request values; cannot be injected into a singleton.
IOptionsMonitor<T>SingletonYes, live with change callbacksLong-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 with IOptionsMonitor<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.Secrets package layers Key Vault into IConfiguration so 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 Main before WebApplication.CreateBuilder returns has no IConfiguration yet. Read Environment.GetEnvironmentVariable directly 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. Use IOptionsSnapshot<T> or IOptionsMonitor<T> instead.

References

Was this helpful?

Frequently Asked Questions

Why use IConfiguration instead of Environment.GetEnvironmentVariable?

IConfiguration layers multiple sources — appsettings.json, environment variables, command-line args, user secrets, Azure Key Vault — with a documented precedence. You get strong typing via IOptions<T>, validation via data annotations, and reload support without scattering Environment calls across your codebase.

How do double underscores work in .NET env vars?

Environment variables can't contain colons (forbidden by most shells), so .NET adopted __ as the universal nesting separator. ConnectionStrings__Default sets the IConfiguration key ConnectionStrings:Default. Logging__LogLevel__Default → Logging:LogLevel:Default. Any nested config key can be overridden via env vars.

When should I use User Secrets vs DotNetEnv?

User Secrets is the built-in .NET mechanism for local-development secrets — values live in your user profile, never in the project. Use it for solo or small-team work. DotNetEnv is better when you have a multi-language team or Docker Compose dev environment that already standardizes on .env files.

What does ASPNETCORE_ENVIRONMENT control?

It picks which appsettings.{Environment}.json file is loaded and gates dev-only middleware like the developer exception page and user secrets. Conventional values are Development, Staging, Production, but any string works. DOTNET_ENVIRONMENT does the same for non-web workers.

Can I bind an array or list from environment variables in .NET?

Yes. IConfiguration uses the index as a key segment, so Servers__0__Host and Servers__1__Host bind to Servers[0].Host and Servers[1].Host when you call GetSection("Servers").Get<List<ServerOptions>>(). The same __<index>__ pattern fills any List<T> or array via object-graph binding.

Are .NET environment variables and User Secrets encrypted?

No. Environment variables are plaintext and readable from /proc/<pid>/environ, logs, and crash dumps. Microsoft is explicit that "Secret Manager doesn't encrypt the stored secrets and shouldn't be treated as a trusted store" — it is development-only. For production secrets use Azure Key Vault via the IConfiguration provider.

Stay up to date

Get notified about new guides, tools, and cheatsheets.