Mert Özen Further With Every Line
DevOps Configuration and Secret Management — #02

One Artifact, Many Environments: Configuration Layering in .NET, Environment Variables and Runtime Config in Angular

Mert Özen Nov 14, 2026 13 min 10 views
One Artifact, Many Environments: Configuration Layering in .NET, Environment Variables and Runtime Config in Angular

To run one artifact in many environments, put only shared defaults into the artifact and supply environment-specific values at deploy time through environment variables or a runtime config file. On .NET 10 we measure provider order (a BOOKSTORE_ prefix beats the command line, an ASPNETCORE_ prefix loses to appsettings), set up fail fast with ValidateOnStart and [OptionsValidator], and generate Angular's config.json at runtime with provideAppInitializer and an nginx entrypoint.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), Docker 29.2, Angular 22.2 (TypeScript 6.0, Vitest 5) and nginx 1.30.

Guess First

In the API of Bookstore, the online bookstore used as the shared example across this site's series, the free shipping threshold in appsettings.json is 500 TRY. You set the same value in four more places: 750 in appsettings.Staging.json, 1000 in the Shipping__FreeShippingThreshold environment variable, 1200 in the --Shipping:FreeShippingThreshold=1200 command-line argument, and 1300 in BOOKSTORE_Shipping__FreeShippingThreshold through a provider added in Program.cs with AddEnvironmentVariables(prefix: "BOOKSTORE_"). What is the threshold when the app starts in Staging? And one more: if you set ASPNETCORE_Shipping__FreeShippingThreshold=1400 and nothing else overrides it, is the result 1400 or 500?

One Artifact, Many Environments

In the Build Once, Deploy Many post of the CI/CD Fundamentals series we set the rule: the same build output goes to dev, staging and production unchanged, and the only thing that differs between environments is config. On the configuration side that means the artifact contains only defaults that are valid everywhere, while environment-specific values and secrets come from outside at deploy time. The Bookstore API's Docker image is built once; the difference between staging and production is only the environment variables passed to that image and, later in the series, its access to a secret store.

In .NET the mechanism that makes this possible is configuration layering: several providers are added in order and, for the same key, the provider added later wins. The basics of provider order and User Secrets were covered in the Configuration and Options Pattern post of the ASP.NET Core Web API series; here we look at what the order means in operations and at making the app refuse to start with bad config. In this post we arrange both Bookstore's API and its Angular client around the same principle.

Measuring Layering: One Setting, Five Sources

I ran the opening scenario layer by layer. Each line adds one source to the previous one; the output comes from /api/info:

# no ASPNETCORE_ENVIRONMENT
{"environment":"Production","freeShippingThreshold":500,...}
# + ASPNETCORE_ENVIRONMENT=Staging
{"environment":"Staging","freeShippingThreshold":750,...}
# + Shipping__FreeShippingThreshold=1000
{"environment":"Staging","freeShippingThreshold":1000,...}
# + --Shipping:FreeShippingThreshold=1200
{"environment":"Staging","freeShippingThreshold":1200,...}
# + BOOKSTORE_Shipping__FreeShippingThreshold=1300
{"environment":"Staging","freeShippingThreshold":1300,...}
# only ASPNETCORE_Shipping__FreeShippingThreshold=1400
{"environment":"Production","freeShippingThreshold":500,...}

The first four lines follow the expected order: appsettings.json, appsettings.{Environment}.json, environment variables, command line. The fifth surprises most people: the prefixed provider beat even the command line. The last one surprises in the other direction: the value passed with the ASPNETCORE_ prefix lost to appsettings.json. Both are explained by the provider list. Printing the list that WebApplication.CreateBuilder builds makes it clear (I removed the file-based sample app's own settings files from the list):

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables(prefix: "BOOKSTORE_");
var i = 0;
foreach (var p in ((IConfigurationRoot)builder.Configuration).Providers)
    Console.WriteLine($"{i++}: {p}");
0: MemoryConfigurationProvider
1: EnvironmentVariablesConfigurationProvider Prefix: 'ASPNETCORE_'
2: MemoryConfigurationProvider
3: EnvironmentVariablesConfigurationProvider Prefix: 'DOTNET_'
4: CommandLineConfigurationProvider
5: JsonConfigurationProvider for 'appsettings.json' (Optional)
6: JsonConfigurationProvider for 'appsettings.Production.json' (Optional)
9: EnvironmentVariablesConfigurationProvider
10: CommandLineConfigurationProvider
11: Microsoft.Extensions.Configuration.ChainedConfigurationProvider
12: EnvironmentVariablesConfigurationProvider Prefix: 'BOOKSTORE_'

The ASPNETCORE_ and DOTNET_ prefixed providers sit at the top of the list; they exist for the host's own settings (environment name, URLs, content root) and rank below the appsettings files. If you pass an app setting with that prefix, the file wins. The BOOKSTORE_ provider we added in Program.cs sits at the very end, above everything. A prefixed provider has a real benefit: variables of other apps on the same host do not leak into your configuration. But once you add it you need to know it has the last word and agree on a single convention as a team. Bookstore's rule: app settings use the prefix, host settings use ASPNETCORE_.

GetDebugView() is the quickest way to see where a setting came from, but as we saw in post one it prints every value in plain text. Since .NET 7 an overload lets you pass every value through a function; combined with an allow-list it gives you a safe diagnostic endpoint:

public static class ConfigDebug
{
    // Only these prefixes are shown with their values; everything else is masked.
    private static readonly string[] VisiblePrefixes = ["Shipping:", "Jwt:Issuer", "Email:FromAddress"];

    public static string SafeDebugView(IConfigurationRoot root) =>
        root.GetDebugView(context =>
            VisiblePrefixes.Any(p => context.Path.StartsWith(p, StringComparison.OrdinalIgnoreCase))
                ? context.Value ?? ""
                : "***");
}
if (app.Environment.IsDevelopment())
{
    // Which value came from which provider; secrets masked
    app.MapGet("/debug/config", (IConfiguration config) =>
        ConfigDebug.SafeDebugView((IConfigurationRoot)config));
}
Email:
  ApiKey=*** (EnvironmentVariablesConfigurationProvider)
  FromAddress=orders@bookstore.example.com (JsonConfigurationProvider for 'appsettings.json' (Optional))
...
Jwt:
  Issuer=https://bookstore.example.com (JsonConfigurationProvider for 'appsettings.json' (Optional))
  SigningKey=*** (EnvironmentVariablesConfigurationProvider)
...
Shipping:
  FreeShippingThreshold=1000 (EnvironmentVariablesConfigurationProvider)
  StandardFee=49.90 (JsonConfigurationProvider for 'appsettings.json' (Optional))

The value is hidden but its source is visible: knowing that "SigningKey came from an environment variable" is enough to solve most config problems. The endpoint is mapped only in Development, and because masking uses an allow-list, a newly added secret is hidden by default. The opposite, a deny-list approach such as "hide anything containing Password", leaks the first secret that happens to be named differently.

Environment Variables in Containers, systemd and App Service

Most environment-specific values are passed as environment variables on the deploy target; only the delivery mechanism changes per platform. In Docker an env file instead of individual -e flags makes it easy to run the same image with different files:

# staging.env (not in the repo; generated at deploy time)
ASPNETCORE_ENVIRONMENT=Staging
Shipping__FreeShippingThreshold=900
Jwt__SigningKey=...
Email__ApiKey=...
$ docker run -d --name secretseries-api -p 18082:8080 --env-file staging.env secretseries-api:02
$ curl -s localhost:18082/api/info
{"environment":"Staging","freeShippingThreshold":900,"secrets":{"ConnectionStrings:Bookstore":false,"Jwt:SigningKey":true,"Email:ApiKey":true,"Payments:Webhook:Secret":false}}

If you notice secrets in the env file, you are right: this post is about layering, and moving secrets out of this file into a secret store is the job of the next posts. For an app run by systemd on a Linux server the EnvironmentFile= line plays the same role; the unit file we built in the systemd post of the Web Servers and Hosting series used it. In Azure App Service, app settings reach the app as environment variables; according to the App Service configuration page on Microsoft Learn, setting names on Linux may contain only letters, digits, periods and underscores, so : must be written as __. App Service restarts the app when an app setting changes, so every value delivered as an environment variable means a restart when it changes.

Build one habit for the separator: __ everywhere. A variable name containing : passes through Docker and .NET reads it, but the shell cannot define it:

$ docker run ... -e "Shipping:FreeShippingThreshold=950" secretseries-api:02
$ curl -s localhost:18082/api/info
{"environment":"Staging","freeShippingThreshold":950,"secret...

$ bash -c 'export Shipping:FreeShippingThreshold=950'
bash: line 0: export: `Shipping:FreeShippingThreshold=950': not a valid identifier

A setting that works on one platform and is silently ignored on another is one of the biggest time sinks when moving between environments.

Fail Fast: ValidateOnStart and the Source-Generated Validator

The most dangerous side of layering is that a missing value raises no error. If JwtOptions.SigningKey comes from no provider, the binder quietly leaves an empty string and the problem appears on the first login attempt, maybe at midnight. The right behavior is for the app to refuse to start with missing or invalid config. I added DataAnnotations attributes to Bookstore's options classes:

public sealed class JwtOptions
{
    public const string SectionName = "Jwt";

    [Required, Url]
    public string Issuer { get; set; } = "";

    // At least 256 bits for HMAC-SHA256: 44 characters in base64
    [Required, MinLength(44)]
    public string SigningKey { get; set; } = "";
}

To validate with code generated at compile time instead of reflection there is the [OptionsValidator] attribute; it arrived with .NET 8 and also works with trimming and Native AOT. You write a partial class with an empty body and the source generator produces the rest:

[OptionsValidator]
public sealed partial class ShippingOptionsValidator : IValidateOptions<ShippingOptions>;

[OptionsValidator]
public sealed partial class JwtOptionsValidator : IValidateOptions<JwtOptions>;

[OptionsValidator]
public sealed partial class EmailOptionsValidator : IValidateOptions<EmailOptions>;
public static IServiceCollection AddBookstoreOptions(this IServiceCollection services)
{
    services.AddOptions<ShippingOptions>().BindConfiguration(ShippingOptions.SectionName).ValidateOnStart();
    services.AddOptions<JwtOptions>().BindConfiguration(JwtOptions.SectionName).ValidateOnStart();
    services.AddOptions<EmailOptions>().BindConfiguration(EmailOptions.SectionName).ValidateOnStart();
    services.AddOptions<PaymentWebhookOptions>().BindConfiguration(PaymentWebhookOptions.SectionName);

    // Validators produced by the source generator instead of reflection
    services.AddSingleton<IValidateOptions<ShippingOptions>, ShippingOptionsValidator>();
    services.AddSingleton<IValidateOptions<JwtOptions>, JwtOptionsValidator>();
    services.AddSingleton<IValidateOptions<EmailOptions>, EmailOptionsValidator>();
    return services;
}

ValidateOnStart() runs validation while the host starts instead of on first use. Started without secrets, the app exited without opening a port:

fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      System.AggregateException: One or more errors occurred. (SigningKey: The JwtOptions.SigningKey field is required.) (ApiKey: The EmailOptions.ApiKey field is required.)
       ---> Microsoft.Extensions.Options.OptionsValidationException: SigningKey: The JwtOptions.SigningKey field is required.

Both errors are reported together, so the operations team sees everything missing in one attempt. The second attempt contained two classic mistakes at once: a short key and an environment variable left empty (Email__FromAddress=):

Unhandled exception. System.AggregateException: One or more errors occurred. (SigningKey: The field JwtOptions.SigningKey must be a string or array type with a minimum length of '44'.) (FromAddress: The EmailOptions.FromAddress field is required.)

The second one matters: an empty string does not mean "not set". Even though FromAddress has a value in appsettings.json, the empty environment variable overrode it and the value became an empty string. It was caught because [Required] rejects empty strings by default; without validation the app would have tried to send e-mail with an empty sender address. The "define the variable now, fill in the value later" habit in Kubernetes and CI produces exactly this result.

In containers and Kubernetes fail fast has a second benefit: a pod that does not start stops the rollout. The old version keeps serving traffic and the new one cannot reach production with bad config.

IOptions, IOptionsSnapshot, IOptionsMonitor and Reload

What the app sees when configuration changes depends on the interface it reads the value through. I put all three side by side in one endpoint:

// What the three options interfaces see at the same moment
app.MapGet("/api/shipping/threshold", (
    IOptions<ShippingOptions> options,
    IOptionsSnapshot<ShippingOptions> snapshot,
    IOptionsMonitor<ShippingOptions> monitor) => new
{
    Options = options.Value.FreeShippingThreshold,
    Snapshot = snapshot.Value.FreeShippingThreshold,
    Monitor = monitor.CurrentValue.FreeShippingThreshold
});

While the app was running I changed the threshold from 500 to 650 in appsettings.json in the published folder (reloadOnChange is on for the default providers):

$ curl -s localhost:5090/api/shipping/threshold
{"options":500,"snapshot":500,"monitor":500}
# appsettings.json: 500 → 650
$ curl -s localhost:5090/api/shipping/threshold
{"options":500,"snapshot":650,"monitor":650}

IOptions<T> is a singleton and keeps the value it first read for the app's lifetime. IOptionsSnapshot<T> is scoped and recomputed for every request. IOptionsMonitor<T> is a singleton but listens for change notifications; it is the right choice in singleton services and background jobs. This distinction is critical for rotation: in post seven we will see how an old secret read through IOptions stays alive after rotation.

What if an invalid value is written to the file? I set the threshold to -5:

$ curl -s -w " %{http_code}" localhost:5090/api/shipping/threshold
 500
fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Microsoft.Extensions.Options.OptionsValidationException: FreeShippingThreshold: The field ShippingOptions.FreeShippingThreshold must be between 0 and 100000.

I tried the same against /api/info, which uses only IOptionsMonitor; it returned HTTP 500 too. ValidateOnStart protects only startup. An invalid value arriving on reload does not stop the app, but it fails every request that reads those options. That is why config that can change at runtime (files, ConfigMaps, App Configuration) must pass a validation step before it changes; we will come back to this in post four when managing App Configuration changes through pull requests.

When you need several sets of the same settings type, use named options. In Bookstore the settings of two carriers bind to the same class under different names:

// Same type, two names: each carrier binds from its own section
builder.Services.Configure<CarrierOptions>("Standard", builder.Configuration.GetSection("Shipping:Carriers:Standard"));
builder.Services.Configure<CarrierOptions>("Express", builder.Configuration.GetSection("Shipping:Carriers:Express"));

var app = builder.Build();
var carriers = app.Services.GetRequiredService<IOptionsMonitor<CarrierOptions>>();
foreach (var name in new[] { "Standard", "Express" })
{
    var carrier = carriers.Get(name);
    Console.WriteLine($"{name}: {carrier.BaseUrl}, {carrier.DeliveryDays} days");
}
Standard: https://standard-carrier.example.com, 3 days
Express: https://express-carrier.example.com, 1 days

Named options are read with IOptionsSnapshot.Get(name) and IOptionsMonitor.Get(name); IOptions<T> only gives you the unnamed default.

Angular: environment.ts Is Build-Time

Angular's environment.ts and fileReplacements work at build time, which means a separate build per environment. So the bundle you tested in staging is not the bundle that goes to production, and "build once, deploy many" is broken. The fix is to read non-secret config from a JSON file at runtime. Since Angular 19 this is done with provideAppInitializer instead of the APP_INITIALIZER token; Angular 22's type definitions mark the old token @deprecated.

// Every value in this file is public in the browser: no secrets.
export interface AppConfig {
  environment: string;
  apiBaseUrl: string;
  freeShippingThreshold: number;
}

@Injectable({ providedIn: 'root' })
export class AppConfigService {
  private readonly http = inject(HttpClient);
  private readonly config = signal<AppConfig | null>(null);

  get value(): AppConfig {
    const config = this.config();
    if (!config) {
      throw new Error('App config is not loaded yet.');
    }
    return config;
  }

  async load(): Promise<void> {
    const config = await firstValueFrom(this.http.get<AppConfig>('/config.json'));
    if (!config.apiBaseUrl) {
      throw new Error('config.json has no apiBaseUrl.');
    }
    this.config.set(config);
  }
}
export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideHttpClient(withFetch()),
    // The app does not start until config.json is loaded
    provideAppInitializer(() => inject(AppConfigService).load()),
    provideRouter(routes),
  ],
};

The app does not bootstrap until the promise returned by the initializer completes; if the file is missing or apiBaseUrl is absent, the app does not start. It is the front-end counterpart of the API's ValidateOnStart. The three Vitest tests I wrote for the service (loading the file, rejecting a missing field, refusing reads before loading) and the component test pass:

$ npx ng test --watch=false
 Test Files  2 passed (2)
      Tests  4 passed (4)

For development, public/config.json holds local values. The real question is how that file differs per environment in the container image.

Generating config.json When the Container Starts

The official nginx image runs the scripts in /docker-entrypoint.d/ in order at startup. We put a template and a small script that fills it from environment variables into the image:

{
  "environment": "${APP_ENVIRONMENT}",
  "apiBaseUrl": "${API_BASE_URL}",
  "freeShippingThreshold": ${FREE_SHIPPING_THRESHOLD}
}
#!/bin/sh
set -eu

# Do not start the container at all with a missing variable (fail fast)
: "${APP_ENVIRONMENT:?APP_ENVIRONMENT is required}"
: "${API_BASE_URL:?API_BASE_URL is required}"
: "${FREE_SHIPPING_THRESHOLD:?FREE_SHIPPING_THRESHOLD is required}"

envsubst '${APP_ENVIRONMENT} ${API_BASE_URL} ${FREE_SHIPPING_THRESHOLD}' \
  < /etc/bookstore/config.template.json \
  > /usr/share/nginx/html/config.json

echo "runtime config written for ${APP_ENVIRONMENT}"
FROM node:24-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx ng build

FROM nginx:1.30-alpine
COPY docker/default.conf /etc/nginx/conf.d/default.conf
COPY docker/config.template.json /etc/bookstore/config.template.json
COPY docker/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
COPY --from=build /src/dist/bookstore-web/browser /usr/share/nginx/html
# Remove the development config from the image; config.json is only generated at runtime
RUN rm /usr/share/nginx/html/config.json

Passing envsubst an explicit list of variables matters; without it, any other $ sign in the template turns into an empty string. On the nginx side I added Cache-Control: no-store for config.json, otherwise the browser may serve a previous environment's config from cache. I ran the same image with two environments and rendered the page with headless Chrome:

$ docker run -d --name secretseries-web -p 18090:80 -e APP_ENVIRONMENT=Staging \
    -e API_BASE_URL=https://api.staging.bookstore.example.com -e FREE_SHIPPING_THRESHOLD=750 secretseries-web:02
$ docker logs secretseries-web 2>&1 | grep runtime
/docker-entrypoint.sh: Launching /docker-entrypoint.d/40-runtime-config.sh
runtime config written for Staging
$ curl -s -D - localhost:18090/config.json | grep -i cache-control
Cache-Control: no-store
# rendered page
<header><h1>Bookstore</h1><p id="env">Staging · API: https://api.staging.bookstore.example.com</p><p>Free shipping over 750 TRY</p></header>

# same image, production values
<p id="env">Production · API: https://api.bookstore.example.com</p>

With a variable missing, the container exited without starting nginx:

$ docker run --name secretseries-web -e APP_ENVIRONMENT=Staging secretseries-web:02
/docker-entrypoint.d/40-runtime-config.sh: line 6: API_BASE_URL: API_BASE_URL is required
$ docker inspect secretseries-web -f '{{.State.Status}} {{.State.ExitCode}}'
exited 2

In Kubernetes you can also mount the same file from a ConfigMap; we showed that route in the ConfigMap and Secret post of the Kubernetes series. The entrypoint approach also works where there is no ConfigMap, such as Docker Compose, App Service and plain docker run, which is why it is Bookstore's default.

Production Notes

It should be traceable which config every deploy ran with: set environment variables from a file supplied by the deploy pipeline or IaC rather than by hand, so the answer to "why is it 900 in staging?" lives in a commit. Logging sources, not values, once at startup (a summary of the masked debug view) saves a lot of time during an incident. Write validation rules against real limits: a [Range] of 0 to 100,000 for the shipping threshold catches a value with an accidental extra zero before it reaches production. Keep in mind that an app setting change triggers a restart in App Service and an environment variable change needs a new rollout in Kubernetes; for frequently changing values, App Configuration in post four is a better fit.

Common Pitfalls

Using : in environment variable names: it works in Docker but not in the shell or App Service; use __ everywhere. Passing app settings with the ASPNETCORE_ prefix: they lose to the appsettings files. Adding a prefixed provider and assuming the command line still wins. Treating an empty string as "not set": an empty environment variable overrides a filled value in the file. Committing appsettings.Production.json with secrets; that file is only acceptable for non-secret production defaults. Building Angular separately per environment and putting environment-specific URLs in environment.ts. Leaving config.json cacheable. Finally, forgetting that reloaded config is not validated: ValidateOnStart only protects startup.

A Small Experiment

★☆☆ Print the provider list in your own API and pass a setting through appsettings, an environment variable and the command line in turn. Then add a prefixed provider and watch how the order changes.

★★☆ Add DataAnnotations and [OptionsValidator] to one of your options classes and enable ValidateOnStart(). Start the app with missing, empty and out-of-range values and compare the error messages. Then write an invalid value to appsettings.json in the running app and note which endpoints break.

★★★ Reduce environment.ts in your Angular app to build-time settings only and read the rest from config.json with provideAppInitializer. Build the image once, run it with two different environments and confirm that the same image ID starts with two different configs.

Non-secret settings now move correctly between environments; the secrets waiting in the env file are still in the wrong place. In the next post we move them to Azure Key Vault and let the app reach them with a managed identity instead of a password.