Skip to content

Configuration Settings

How .NET configuration is composed in CargonerdsApp, what each layer is for, and how to change values for a given scenario.

The mechanism lives in src/Cargonerds.ServiceDefaults/. Every host project (Cargonerds.HttpApi.Host, Cargonerds.AuthServer, Cargonerds.Web.Public, Cargonerds.Blazor, Cargonerds.DbMigrator) calls builder.AddServiceDefaults() during startup, which wires up the layered loading and the two token-replacement systems described below.


TL;DR — where to put a value

You want to change… Edit this file
Local default for a single service src/<Project>/appsettings.json
Local dev-only override for a single service src/<Project>/appsettings.Development.json
Aspire-orchestrated values (URLs that depend on running services) src/<Project>/appsettings.aspire.json
White-label appearance, OIDC tenant, third-party API keys src/Cargonerds.AppHost/appsettings.rohlig.json
Spark-environment-specific value (local/dev/test/prod/prod-read-only) src/<Project>/appsettings.spark.<env>.json
Personal overrides on your machine (gitignored for local) src/<Project>/appsettings.spark.<env>.user.json
Azure-deployed environment (e.g. dev, dev.hub-prod, prod, prod.staging) src/Cargonerds.ServiceDefaults/appsettings.azure.<env>.json
Migrator-only Azure overrides src/Cargonerds.DbMigrator/appsettings.azure.migrator.<env>.json
Reusable parameter substituted into other values via {token} ConfigurationParameters section of the relevant azure file

After choosing a file, restart the affected host (or rebuild the AppHost graph) — only Spark JSON files are loaded with reloadOnChange: true.


What the environment axes mean

The loading pipeline combines several layers, but two axes carry most of the per-deployment meaning: Spark and Azure. They are orthogonal — each answers a different question — and understanding the split is the fastest way to decide where a value belongs.

Spark environment — which external backend am I talking to?

Controlled by SPARK_ENVIRONMENT (one of local, dev, test, prod, prod-read-only; default dev). The matching appsettings.spark.<env>.json file pins the connections and identifiers that define the external data stack:

  • ConnectionStrings:Hub — the Cargonerds hub SQL database (each Spark env has its own server and database: sql-cargonerds-db-gerwc-development, …-test, …-production, etc.).
  • ConnectionStrings:BlobStorage — the Azure Storage account (e.g. cnhubgerwcdevelopment, cnhubgerwctest, cnhubgerwcproduction) used for file uploads and derived artifacts.
  • ConnectionStrings:hubServiceBus — the Azure Service Bus namespace (e.g. cn-hub-gerwc-development) used for inter-service messaging.
  • App:CargonerdsCustomerId — the tenant identifier that scopes data access.
  • App:BookingNumberPrefix and other domain values that differ per backend.
  • (Optionally) third-party API overrides under WhiteLabelingOptions:ExternalApis when a tenant-specific key is needed.

Concretely:

Spark env Hub DB Blob storage Intended use
local localhost SQL on :1401 Azurite (devstoreaccount1) Fully offline development with the Aspire stack.
dev sql-cargonerds-db-gerwc-development cnhubgerwcdevelopment Shared development backend.
test sql-cargonerds-db-gerwc-test cnhubgerwctest QA / UAT.
prod sql-cargonerds-db-gerwc-production (read-write user) cnhubgerwcproduction Live production.
prod-read-only same production DB with a read-only SQL user cnhubgerwcproduction Production backend without write risk — used by the prod.staging Azure slot and for prod investigations.

The same Spark env is often used by multiple running instances. That's the whole reason the Spark layer is separate from the Azure layer: the backend (DB, storage, bus) is a property of a data stack; the front-of-cluster config (which app URL, which tenant domain, which white-label) can vary per deployment while pointing at the same backend. For example, both appsettings.azure.dev.json and appsettings.azure.dev.hub-prod.json set SPARK_ENVIRONMENT but to different values (dev vs. prod-read-only) — one is a real dev instance, the other is a staging copy of prod running on dev infrastructure.

Only Cargonerds.HttpApi.Host currently ships per-Spark-env files, because it's the service that owns the Hub-side connections; other hosts pick up their Spark env via the SparkEnvironment singleton and environment-variable propagation.

Azure environment — which App Service shape am I impersonating?

Controlled by AZURE_ENVIRONMENT. Unset by default. When set, the progressive azure layers kick in (see the loading pipeline section below). The files in src/Cargonerds.ServiceDefaults/appsettings.azure*.json hold the deployment-shape values:

  • ConnectionStrings:Default — the ABP application database (separate from the Hub DB) for this deployment.
  • ConnectionStrings:redis / ConnectionStrings:messaging — the Redis cache and RabbitMQ instance for this deployment.
  • Services:<name>:https — the public URLs that {service-name} tokens resolve to at runtime. In the azure layer these are real hostnames (e.g. https://api.rt3.rohlig.com, https://api.dev.spark.cargonerds.dev) instead of the localhost Aspire endpoints.
  • ConfigurationParameters — the static values (spark-dev-domain, spark-db-name, rabbit-mq-port) that get stamped into the connection strings and URLs above via {token} substitution.
  • SPARK_ENVIRONMENT — each azure file pins its expected backend, which is how prod.staging can point at prod-read-only while prod uses live prod.

Primary use: deployed Azure App Services. Each App Service sets AZURE_ENVIRONMENT to the dotted name of its slot (dev, dev.hub-prod, prod, prod.staging, …), and the host composes the right layer stack automatically.

Secondary use: debug locally with production-shape neighbors. If you set AZURE_ENVIRONMENT on your machine you do not need to run the full Aspire stack. Start only the one service you want to debug; the service-discovery rewriter will resolve {api}, {auth}, {realtime}, {web}, {admin} against the Services block from the azure files — i.e. against the live deployed URLs. This is the recommended way to step through a single service while it interacts with a realistic set of neighbors.

Caveats

  • Never set AZURE_ENVIRONMENT=prod (or any prod.* value) unless you are absolutely sure. Your local process will connect to production databases, storage, and service bus, and any write you trigger hits real customer data. Prefer dev or prod.staging (which uses prod-read-only) for investigation.
  • If you start more than one local service with AZURE_ENVIRONMENT set, they do not talk to each other — they each independently talk to the corresponding deployed App Service, because {service} tokens resolve to the public URLs, not to your localhost ports. This is usually fine (and often the point), but don't expect cross-service traffic between two locally running hosts in this mode.
  • USE_ASPIRE_CONFIG and AZURE_ENVIRONMENT can be set at the same time, but then {service} tokens in appsettings.aspire.json are re-resolved against the Services entries from the azure layer (the rewriter runs once, last). If you want local Aspire wiring, leave AZURE_ENVIRONMENT unset.

The loading pipeline

AddServiceDefaults() calls (in order) AddExtraConfigFiles() and AddServiceDiscoveryConfiguration(). The full effective ordering, including the framework defaults that already loaded before AddServiceDefaults runs, is:

  1. Framework defaults (always loaded by Host.CreateApplicationBuilder or WebApplication.CreateBuilder):

    1. appsettings.json
    2. appsettings.{ASPNETCORE_ENVIRONMENT}.json — typically Development / Production
    3. User Secrets (only in Development)
    4. Environment variables
    5. Command-line args
  2. Aspire layer — only when USE_ASPIRE_CONFIG=true:

    • appsettings.aspire.json
  3. Azure layers — only when AZURE_ENVIRONMENT is set:

    1. appsettings.azure.json
    2. For each dot-separated segment of AZURE_ENVIRONMENT, append progressively:
      • e.g. AZURE_ENVIRONMENT=dev.hub-prod loads appsettings.azure.dev.json then appsettings.azure.dev.hub-prod.json.
    3. (DbMigrator only) the same progression with prefix azure.migrator, e.g. appsettings.azure.migrator.dev.json.
    4. ConfigurationParameterReplacementSource — re-reads everything loaded so far and rewrites any value containing a {key} token by looking it up in the ConfigurationParameters section.
  4. White-label layer:

    • Path is WHITE_LABEL_SETTINGS_PATH if set, otherwise appsettings.rohlig.json (relative to the host's content root).
  5. Spark layer:

    • SPARK_ENVIRONMENT env var (default dev; valid: local, dev, test, prod, prod-read-only) is parsed into a SparkEnvironment and registered as a singleton service.
    • appsettings.spark.<env>.json (reloadOnChange: true)
    • appsettings.spark.<env>.user.json (reloadOnChange: true) — developer-local overrides; only the local variant is in .gitignore.
  6. Service-discovery rewriterServiceDiscoveryConfigurationSource rewrites any value containing {service-name} using:

    1. services:<name>:https:0 — Aspire service endpoint (HTTPS)
    2. services:<name>:http:0 — Aspire service endpoint (HTTP)
    3. ConnectionStrings:<name> — named connection string

Each later layer wins. Within the framework's standard chain, environment variables override JSON files. Within the custom layers, the order above is the precedence.

See: src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs:54 for AddExtraConfigFiles, and :163 for AddAzureJsonFiles.


Driving environment variables

Variable Read by Effect Default
ASPNETCORE_ENVIRONMENT Framework Picks appsettings.{Env}.json and toggles User Secrets. Production
USE_ASPIRE_CONFIG AddExtraConfigFiles Loads appsettings.aspire.json when true. AppHost sets it via AddProjectWithDefaults. false
AZURE_ENVIRONMENT AddExtraConfigFiles, GetAzureEnvironment() Triggers the Azure layered load; the dotted value drives which files are appended. unset
SPARK_ENVIRONMENT AddExtraConfigFiles, SparkEnvironment.FromName Picks appsettings.spark.<env>.json and registers SparkEnvironment. dev
WHITE_LABEL_SETTINGS_PATH AddExtraConfigFiles Absolute or relative path to white-label JSON. AppHost passes the absolute path during run mode (WithWhiteLabelSettingsPath). appsettings.rohlig.json
DEPLOYMENT_DOMAIN AppHost (ResourceBuilderExtensions) Hostname suffix used in publish mode for services__* env vars. required in publish mode
OTEL_EXPORTER_OTLP_ENDPOINT AddOpenTelemetryExporters Enables the OTLP exporter when non-empty. unset
APPLICATIONINSIGHTS_CONNECTION_STRING AddOpenTelemetryExporters Enables Azure Monitor when non-empty. unset

Token replacement — two systems with the same syntax

Both run as IConfigurationSources and both look like {name} — but they have different lookup tables and run at different points.

1. ConfigurationParameters (static parameter inheritance)

Source: ConfigurationParameterReplacementProvider.cs. Runs at the end of the Azure layer load.

Use this to keep multi-layer azure files DRY. Define a value once, reference it everywhere:

// appsettings.azure.dev.json
{
  "ConnectionStrings": {
    "Default": "Server=tcp:sql-spark-dev.database.windows.net,1433;Initial Catalog={spark-db-name}",
    "messaging": "amqp://user:pwd@rabbitmq.dev.spark.cargonerds.dev:{rabbit-mq-port}/"
  },
  "ConfigurationParameters": {
    "spark-db-name": "spark-dev",
    "rabbit-mq-port": 5672
  }
}

A nested layer can override just the parameter to retarget every reference:

// appsettings.azure.dev.hub-prod.json
{
  "ConfigurationParameters": {
    "spark-db-name": "spark-dev-hub-prod",
    "rabbit-mq-port": 5673
  }
}

If a token has no matching parameter, the literal {name} remains in the value. If a parameter is neither matched by a Configuration Parameter nor by Service Discovery (next step), an error will be thrown and the app will shut down.

2. Service-discovery tokens (runtime endpoint resolution)

Source: ServiceDiscoveryConfigurationProvider.cs. Runs last, after every layer is loaded.

Use this in any value that should resolve to a sibling service's URL or a named connection string. The canonical place is appsettings.aspire.json:

// appsettings.aspire.json (HttpApi.Host)
{
  "App": {
    "SelfUrl": "{api}",
    "CorsOrigins": "{realtime},{admin}"
  },
  "AuthServer": {
    "Authority": "{auth}"
  },
  "Redis": {
    "Configuration": "{redis}"
  }
}

Lookup order for {name}:

  1. services:<name>:https:0 — Aspire injects this via services__<name>__https__0 env vars from WithServiceReference() in the AppHost.
  2. services:<name>:http:0
  3. ConnectionStrings:<name>

If none match, the host throws on startup with a list of available service names — failing fast prevents silent misconfiguration.

Note: appsettings.azure.*.json files use the ConfigurationParameters mechanism (static), while appsettings.aspire.json uses service-discovery (runtime). The braces look identical but the resolution scope is different — keep that in mind when copying snippets between files.


How AppHost composes the variables

Cargonerds.AppHost controls which env vars each project receives. Key extension methods in src/Cargonerds.AppHost/Extensions/ResourceBuilderExtensions.cs:

  • AddProjectWithDefaults<T>() — wraps AddProject and sets USE_ASPIRE_CONFIG=true so the project picks up appsettings.aspire.json.
  • WithSparkEnvironmentName() — propagates the AppHost's own SPARK_ENVIRONMENT env var down to the child project.
  • WithWhiteLabelSettingsPath() — in run mode, sets WHITE_LABEL_SETTINGS_PATH to the absolute path of appsettings.rohlig.json so the child finds it regardless of working directory.
  • WithServiceReference(target) — emits services__<name>__https__0 so the child can resolve {name} tokens. In publish mode it uses https://<name>.<DEPLOYMENT_DOMAIN> instead of the local Aspire endpoint.
  • WithEnvironmentHttpsEndpoint(...) — same dual run/publish behavior for one-off env vars that must hold a sibling URL.

Recipes

Debug one service locally against a deployed Azure environment

Use this to step through a single host while it interacts with the real dev (or staging) neighbors — without spinning up the Aspire graph.

  1. Pick a non-production Azure env (e.g. dev, or prod.staging which is backed by prod-read-only). Do not use prod or a bare prod.* name unless you have a specific, verified reason.
  2. Launch just the project you want to debug (from your IDE or dotnet run --project src/Cargonerds.HttpApi.Host) with:
    • AZURE_ENVIRONMENT=dev (or the env you chose)
    • ASPNETCORE_ENVIRONMENT=Development if you want dev-friendly logging
  3. Do not start the AppHost. Skip USE_ASPIRE_CONFIG. The azure layer provides both the backend connections and the Services block, so {api}, {auth}, {realtime}, {web}, {admin} resolve to the public URLs.
  4. If you also start a second local service with the same AZURE_ENVIRONMENT, note that they won't see each other — each still points at the deployed sibling. That's expected.

Add a new Azure deployment environment

  1. Pick a dotted name, e.g. dev.hub-test.
  2. Create src/Cargonerds.ServiceDefaults/appsettings.azure.dev.hub-test.json with only the values that differ from the parent layer (appsettings.azure.dev.json). Typically that's a few ConfigurationParameters overrides and any per-tenant Services entries.
  3. Set AZURE_ENVIRONMENT=dev.hub-test on the deployment target.
  4. (Optional) If the migrator needs different settings, add src/Cargonerds.DbMigrator/appsettings.azure.migrator.dev.hub-test.json.

Override a value just on your machine

  1. Create src/<Project>/appsettings.spark.local.user.json (gitignored).
  2. Put the override under the right section.
  3. Run dotnet run --project src/Cargonerds.AppHost with SPARK_ENVIRONMENT=local.

Point a service at a different sibling URL when running under Aspire

Edit appsettings.aspire.json in the consuming project and add the token:

{ "Some": { "Url": "{web}/some-path" } }

Then ensure the AppHost wires the reference: consumer.WithServiceReference(web). The service-discovery rewriter resolves {web} to the live Aspire endpoint in run mode and to https://web.<DEPLOYMENT_DOMAIN> in publish mode.

Add a parameter shared across azure layers

  1. In the most general layer that should define a default (often appsettings.azure.json or appsettings.azure.<top>.json), add the value under ConfigurationParameters.
  2. Reference it as {my-parameter} anywhere in any value of any azure file.
  3. Override it in nested layers (appsettings.azure.<top>.<child>.json) by re-declaring the same key under ConfigurationParameters.

Switch white-label tenant locally

Set WHITE_LABEL_SETTINGS_PATH to a different file (e.g. appsettings.othertenant.json) before launching, or change the file referenced by WithWhiteLabelSettingsPath() in the AppHost.


Reading values in code

src/Cargonerds.Domain.Shared/Extensions/ConfigurationExtensions.cs exposes typed helpers as C# extension members:

  • configuration.GetRequiredValue<T>("Section") — throws if the section can't bind to T.
  • configuration.GetAppConfiguration() — binds the App: section to AppConfiguration.
  • configuration.GetRequiredConnectionString("Default") — throws if missing.
  • configuration.GetAzureEnvironment() — returns the current AZURE_ENVIRONMENT (nullable).

For the Spark environment, inject SparkEnvironment directly — it's registered as a singleton during AddExtraConfigFiles().


Source map

Concern File
Pipeline entry point src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs
Azure layer expansion same file, ConfigurationBuilderExtensions.AddAzureJsonFiles
{key} parameter rewriter src/Cargonerds.ServiceDefaults/ConfigurationParameterReplacementProvider.cs
{service-name} discovery rewriter src/Cargonerds.ServiceDefaults/ServiceDiscoveryConfigurationProvider.cs
Spark environment enum modules/hub/src/Hub.Domain.Shared/Consts/SparkEnvironment.cs
AppHost env-var wiring src/Cargonerds.AppHost/Extensions/ResourceBuilderExtensions.cs
Typed config helpers src/Cargonerds.Domain.Shared/Extensions/ConfigurationExtensions.cs