Environments
Set the hosting environment correctly, layer appsettings.{Environment}.json, and keep environment checks out of business logic.
ASPNETCORE_ENVIRONMENT=Staging dotnet Api.dll
// appsettings.json, then appsettings.Staging.json (exact case on Linux)
if (app.Environment.IsDevelopment()) app.MapOpenApi();The host has one environment name, exposed as IHostEnvironment.EnvironmentName (builder.Environment, app.Environment). It decides which extra settings file loads and switches a few developer conveniences on. When nothing sets it, the name is Production, which is the safe default.
Where the name comes from
ASPNETCORE_ENVIRONMENT: read by web apps only.DOTNET_ENVIRONMENT: read by every host, including workers.--environment Stagingon the command line, which beats both variables.- Code:
WebApplicationOptions.EnvironmentNameorHostApplicationBuilderSettings.EnvironmentName.
With WebApplication.CreateBuilder, if both variables are set, DOTNET_ENVIRONMENT wins, because the ASPNETCORE_ source is added first. The older Host.CreateDefaultBuilder().ConfigureWebHostDefaults() style does the opposite. A worker built with Host.CreateApplicationBuilder ignores ASPNETCORE_ENVIRONMENT completely. Set exactly one variable per process: ASPNETCORE_ENVIRONMENT for APIs, DOTNET_ENVIRONMENT for workers.
Locally, Properties/launchSettings.json sets ASPNETCORE_ENVIRONMENT=Development for dotnet run and the IDE. It is not part of the publish output, so it never affects a deployed app.
What the environment changes
| Behaviour | Development | Other environments |
|---|---|---|
appsettings.{Environment}.json | Loaded after appsettings.json | Same |
| User secrets | Loaded | Not loaded |
| Developer exception page | On | Off |
DI ValidateScopes and ValidateOnBuild | On | Off |
IsDevelopment(), IsStaging(), IsProduction() and IsEnvironment("QA") compare case-insensitively. The settings file lookup does not: on Linux, appsettings.production.json is not loaded for Production. It works on a Windows or macOS laptop and silently fails in the container.
Never branch business logic on the environment
if (env.IsProduction()) await gateway.CaptureAsync(payment) means Staging can never exercise the real code path, and a QA environment added later behaves like neither. Put the difference in configuration: Payments:Mode = Sandbox in Staging, Live in Production, bound to an options class and validated at start-up. Reserve environment checks for developer tooling: the exception page, the OpenAPI UI, verbose diagnostics.
What goes wrong in production
- A Dockerfile with
ENV ASPNETCORE_ENVIRONMENT=Developmentleft in, which turns on detailed error pages that leak stack traces. - A base image or Helm chart setting
DOTNET_ENVIRONMENTthat quietly overrides theASPNETCORE_ENVIRONMENTyou set. - A lower-case settings file name that only some machines load.
- Staging configured with production endpoints because its file failed to load and the base file held production values. Keep
appsettings.jsonfree of real endpoints.
Example
When to use it
- Run the same orders-api image in staging and production, with ASPNETCORE_ENVIRONMENT selecting appsettings.Staging.json or appsettings.Production.json.
- Diagnose a container that ignores appsettings.production.json because Linux matched the file name case-sensitively against Production.
- Replace an if (env.IsProduction()) around card capture with a validated Payments:Mode setting that Staging can switch on.
More examples
launchSettings.json for local runs only
<code>dotnet run</code> and IDEs apply the first profile (or the one named with <code>--launch-profile</code>), which is why a fresh project runs as Development on your machine. The file is not published, so a deployed app falls back to Production unless the platform sets the variable. The second profile lets you try Staging settings locally with <code>dotnet run --launch-profile staging-local</code>.
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": false,
"applicationUrl": "http://localhost:5180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"staging-local": {
"commandName": "Project",
"applicationUrl": "http://localhost:5181",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Staging"
}
}
}
}Setting the environment per cluster
The same image runs in every cluster; only the environment variables differ. The environment name selects <code>appsettings.Staging.json</code>, while the behaviour that matters to the business, the payment mode, is an explicit setting (<code>Payments__Mode</code> maps to <code>Payments:Mode</code>). Do not also set <code>DOTNET_ENVIRONMENT</code> here: in a web app it would win.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: shop-staging
spec:
replicas: 2
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: api
image: registry.example.com/shop/orders-api:1.14.2
ports:
- containerPort: 8080
env:
- name: ASPNETCORE_ENVIRONMENT
value: Staging
- name: Payments__Mode
value: SandboxConfiguration instead of environment checks
The broken version would be <code>if (app.Environment.IsProduction())</code> around the capture call: Staging could never test it and a new <code>QA</code> environment would silently take the sandbox path. Here the mode is a required, validated setting, so a missing value stops the app at start-up, and only the debug endpoint looks at the environment.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<PaymentOptions>()
.BindConfiguration("Payments")
.ValidateDataAnnotations()
.ValidateOnStart();
var app = builder.Build();
// Fine: developer tooling may depend on the environment.
if (app.Environment.IsDevelopment())
{
app.MapGet("/debug/payments", (IOptions<PaymentOptions> o) => o.Value);
}
// Business behaviour depends on configuration, so Staging can run the Live path if needed.
app.MapPost("/orders/{id:int}/capture", (int id, IOptions<PaymentOptions> options) =>
options.Value.Mode == PaymentMode.Live
? Results.Accepted($"/payments/{id}", new { id, mode = "live" })
: Results.Ok(new { id, mode = "sandbox", captured = true }));
app.Run();
enum PaymentMode { Sandbox, Live }
sealed class PaymentOptions
{
[Required] public PaymentMode? Mode { get; set; }
[Range(0, 1440)] public int CaptureDelayMinutes { get; set; }
}
Discussion