Skip to content

Deployment Configuration

How configuration — connection strings, secrets, and per-environment values — is supplied to each deployed environment, and how it flows from committed appsettings*.json files, through the Aspire AppHost, into Azure Container Apps and App Service.

This page focuses on the deployment-time mechanics. For the full layered-loading model (the load order of every JSON file, the two token-replacement engines, precedence rules and local-dev caveats) read the Configuration Reference; for an exhaustive key-by-key catalogue see the appsettings reference.

TL;DR

  • Two independent environment selectors: SPARK_ENVIRONMENT chooses the Hub DB / blob / service bus; AZURE_ENVIRONMENT chooses the Default (Spark) DB, Redis, RabbitMQ and the public service URLs.
  • There is no Azure Key Vault. Connection strings and API keys live in committed appsettings*.json layers and CI secrets — treat the whole repo as containing live credentials.
  • Config is layered by AddServiceDefaults() in Cargonerds.ServiceDefaults, then forwarded as environment variables by the Aspire AppHost (Container Apps) or baked into the deployed zip (App Service).

The two environment axes

Every deployed instance is described by two orthogonal environment variables. They are deliberately decoupled — the Hub domain database and the application (Spark/Default) database are selected independently.

Variable Question it answers Files it selects
SPARK_ENVIRONMENT Which Hub backend (Hub DB / blob storage / service bus)? appsettings.spark.<env>.json (+ .user.json)
AZURE_ENVIRONMENT Which deployment shape (public URLs, Default DB, Redis, RabbitMQ)? src/Cargonerds.ServiceDefaults/appsettings.azure.<dotted-name>.json

SPARK_ENVIRONMENT is one of local, dev, test, prod, prod-read-only. It is parsed by SparkEnvironment.FromName: a null/empty value falls back to dev, but an unrecognised name throws at startup (a typo crashes the host rather than silently defaulting).

// modules/hub/src/Hub.Domain.Shared/Consts/SparkEnvironment.cs
public static SparkEnvironment FromName(string? name)
{
    if (string.IsNullOrWhiteSpace(name))
    {
        return Default; // = Dev
    }

    return Environments.TryGetValue(name, out var environment)
        ? environment
        : throw new ArgumentException($"Unknown SparkEnvironment name: {name}");
}

AZURE_ENVIRONMENT uses a dotted name that progressively layers files. The loader splits on . and adds each cumulative file in turn, so dev.hub-prod loads appsettings.azure.jsonappsettings.azure.dev.jsonappsettings.azure.dev.hub-prod.json. The last file overrides only what it needs (here ConfigurationParameters and SPARK_ENVIRONMENT).

// src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs
public IConfigurationBuilder AddAzureJsonFiles(string azureEnvironment, string? additionalSettingsPrefix = null)
{
    var environmentParts = azureEnvironment.Split('.');

    builder.AddJsonFile($"appsettings.azure.json", optional: true);
    var environmentName = "";

    foreach (string part in environmentParts)
    {
        environmentName += $".{part}";
        builder.AddJsonFile($"appsettings.azure{environmentName}.json", optional: true);
        // ... migrator overlay (see DbMigrator section)
    }

    builder.Add(new ConfigurationParameterReplacementSource(builder.Build()));
    return builder;
}

The two axes are independent

SPARK_ENVIRONMENT and AZURE_ENVIRONMENT are kept in sync in the cloud only because the appsettings.azure.* overlay files set SPARK_ENVIRONMENT explicitly (e.g. appsettings.azure.dev.json sets "SPARK_ENVIRONMENT": "dev"). On a developer machine nothing keeps them aligned — set them together.

Shipped Azure layer files

src/Cargonerds.ServiceDefaults/
  appsettings.azure.json              # Services block (public URLs) with {spark-dev-domain} tokens
  appsettings.azure.dev.json          # Default DB + redis + messaging + SPARK_ENVIRONMENT=dev
  appsettings.azure.dev.hub-test.json # overlay: ConfigurationParameters + SPARK_ENVIRONMENT=test
  appsettings.azure.dev.hub-prod.json # overlay: ConfigurationParameters + SPARK_ENVIRONMENT=prod-read-only
  appsettings.azure.prod.json         # prod Default DB + rt3.rohlig.com Services
  appsettings.azure.prod.staging.json # overlay: ConfigurationParameters + SPARK_ENVIRONMENT=prod-read-only

The App Services pipeline maps friendly switch values to these dotted names: DeployToAppServices.ps1 -AzureEnvironment accepts Prod-Staging (→ prod.staging), Dev-HubProd (→ dev.hub-prod) or Dev-HubTest (→ dev.hub-test). See Azure App Service.

Connection strings

Connection-string names are defined as constants in ConnectionStringNames and read via IConfiguration.GetConnectionString(...):

public static class ConnectionStringNames
{
    public const string HubServiceBus = "hubServiceBus";
    public const string HubDb         = "Hub";
    public const string SparkDb       = "Default";
    public const string BlobStorage   = nameof(BlobStorage);
}
Name Database / resource Selected by Used for
Default (SparkDb) App DB AZURE_ENVIRONMENT ABP Identity, OpenIddict, Setting/Feature management, audit logging, CmsKit, BLOB-in-DB, Hangfire storage
Hub (HubDb) Hub domain DB SPARK_ENVIRONMENT Shipments, organizations, tracking, pricing
BlobStorage Azure Blob / Azurite SPARK_ENVIRONMENT File storage
hubServiceBus Azure Service Bus SPARK_ENVIRONMENT Notification consumer (empty for local)
redis Redis AZURE_ENVIRONMENT ABP distributed cache (AddRedisClient("redis"))
messaging RabbitMQ AZURE_ENVIRONMENT Distributed event bus

The Default (Spark) connection string

Defined in the appsettings.azure.<env>.json layer. The database name is a {spark-db-name} token resolved from the ConfigurationParameters section in the same file:

// src/Cargonerds.ServiceDefaults/appsettings.azure.dev.json
{
  "ConnectionStrings": {
    "Default": "Server=tcp:sql-spark-dev.database.windows.net,1433;User ID=spark-app;Password='…';Initial Catalog={spark-db-name}",
    "redis": "spark-prod-redis.redis.cache.windows.net,password=…,ssl=True,abortConnect=False",
    "messaging": "amqp://sparkuser:…@rabbitmq.dev.spark.cargonerds.dev:{rabbit-mq-port}/"
  },
  "SPARK_ENVIRONMENT": "dev",
  "ConfigurationParameters": {
    "spark-dev-domain": "dev",
    "rabbit-mq-port": 5672,
    "spark-db-name": "spark-dev"
  }
}

This is the mechanism behind the overlay environments: appsettings.azure.dev.hub-test.json keeps the dev server and spark-app user but re-points only the parameters (and the RabbitMQ vhost port), so a single SQL server hosts several logical databases:

// src/Cargonerds.ServiceDefaults/appsettings.azure.dev.hub-prod.json
{
  "ConfigurationParameters": {
    "spark-dev-domain": "hub-prod-dev",
    "rabbit-mq-port": 5673,
    "spark-db-name": "spark-dev-hub-prod"
  },
  "SPARK_ENVIRONMENT": "prod-read-only"
}

The Hub connection string

Defined in appsettings.spark.<env>.json (in the HttpApi.Host project, copied to output). It is read directly by the Hub DbContextPool (pool size 256, second-level cache 1-minute TTL):

// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/HubEntityFrameworkCoreModule.cs
context.Services.AddDbContextPool<HubDbContext>((sp, opts) =>
{
    var cs = sp.GetRequiredService<IConfiguration>().GetConnectionString(ConnectionStringNames.HubDb);
    opts.UseSqlServer(cs, sql =>
    {
        sql.EnableRetryOnFailure();
        sql.CommandTimeout((int)TimeSpan.FromMinutes(3).TotalSeconds);
    });
    // … interceptors
});

The local spark file points everything at local containers and leaves the service bus empty:

// src/Cargonerds.HttpApi.Host/appsettings.spark.local.json
{
  "ConnectionStrings": {
    "Hub": "Server=localhost,1401;Database=hub-main;Integrated Security=false;User ID=sa;Password=securePassword-2021;TrustServerCertificate=True;",
    "BlobStorage": "AccountName=devstoreaccount1;…;BlobEndpoint=http://localhost:10100/devstoreaccount1;…",
    "hubServiceBus": ""
  }
}

SPARK data-source selection

SPARK_ENVIRONMENT picks appsettings.spark.<env>.json (and an optional, git-ignored appsettings.spark.<env>.user.json overlay). Each file carries the Hub connection string, BlobStorage, hubServiceBus, and environment-specific white-label sub-sections (e.g. production E-adapter / pricing URLs).

SPARK_ENVIRONMENT Hub DB Notes
local localhost,1401 db hub-main (sa) empty hubServiceBus; Azurite blob on localhost:10100
dev sql-spark-dev app user full Azure blob + service bus
test test server, app user
prod production server, app user prod E-adapter + pricing URLs
prod-read-only production server, read user read-only credential — safe for investigation

Investigating production safely

The overlay environments (dev.hub-prod, prod.staging) set SPARK_ENVIRONMENT=prod-read-only, which uses the read-only Hub credential. Prefer these over prod whenever you need production data without write risk.

The load order is fixed in AddExtraConfigFiles, and SparkEnvironment is also registered as a DI singleton so it can be read at runtime (e.g. the static-data seeding guard runs only for Local):

// src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs
var sparkEnvironment = SparkEnvironment.FromName(builder.Configuration["SPARK_ENVIRONMENT"]);
builder.Configuration.AddJsonFile($"appsettings.spark.{sparkEnvironment.Name}.json",      optional: true, reloadOnChange: true);
builder.Configuration.AddJsonFile($"appsettings.spark.{sparkEnvironment.Name}.user.json", optional: true, reloadOnChange: true);
builder.Services.AddSingleton(sparkEnvironment);

How config flows: appsettings → Aspire → Azure

flowchart TD
    subgraph files["Committed appsettings layers (in project / ServiceDefaults)"]
        A["appsettings.json + appsettings.&#123;Environment&#125;.json"]
        B["appsettings.aspire.json<br/>(when USE_ASPIRE_CONFIG=true)"]
        C["appsettings.azure.&lt;dotted&gt;.json<br/>(when AZURE_ENVIRONMENT set)"]
        D["appsettings.rohlig.json<br/>(white-label; WHITE_LABEL_SETTINGS_PATH)"]
        E["appsettings.spark.&lt;env&gt;.json (+ .user.json)<br/>(by SPARK_ENVIRONMENT)"]
    end

    A --> SD["AddServiceDefaults()<br/>AddExtraConfigFiles + AddServiceDiscoveryConfiguration"]
    B --> SD
    C --> SD
    D --> SD
    E --> SD

    SD --> TOK["Token replacement:<br/>ConfigurationParameters &#123;spark-db-name&#125; +<br/>ServiceDiscovery &#123;api&#125;/&#123;auth&#125;/&#123;web&#125;"]

    TOK --> ACA["Azure Container Apps<br/>(azd up via AppHost)<br/>env vars injected per container"]
    TOK --> APPSVC["Azure App Service<br/>(dotnet publish zip)<br/>files shipped + app settings"]

Local & Container Apps: the Aspire AppHost

When the Cargonerds.AppHost runs (locally or as the azd up publish target), it sets and forwards environment variables onto each child resource. The canonical list of names it reads/forwards is EnvVars.cs:

Env var Role
USE_ASPIRE_CONFIG Set to true on every project so hosts load appsettings.aspire.json. Mandatory under Aspire.
SPARK_ENVIRONMENT Forwarded so the api host knows which Hub DB / blob to use.
WHITE_LABEL_SETTINGS_PATH Absolute path to appsettings.rohlig.json (see white-label note).
DEPLOYMENT_DOMAIN Required in publish mode (throws if unset). Service-discovery tokens resolve to https://<name>.<DEPLOYMENT_DOMAIN>.
VALIDATE_MIGRATIONS_ONLY When 1, the migrator validates migrations without applying them.
APPHOST_AZURE_ENVIRONMENT / APPHOST_AZURE_DATABASE_COPY_SUFFIX Run-mode only — point a local AppHost at an Azure Spark DB (or a suffixed copy).

Service discovery wiring emits services__<name>__https__0. In publish mode the value is https://<name>.<DEPLOYMENT_DOMAIN>; in run mode it is the live local endpoint. This is how Container Apps services resolve each other by custom domain.

Why WHITE_LABEL_SETTINGS_PATH exists

Aspire runs each process with its project directory as the working directory, not the build output. Because the white-label file is copied next to the build output, the AppHost passes the absolute path so each host can still find appsettings.rohlig.json.

USE_ASPIRE_CONFIG is required under Aspire

Per the EnvVars doc-comment: "If not set the default ABP configuration will be used which is not compatible with the Aspire AppHost." The AppHost sets it via AddProjectWithDefaults. Running a host directly (dotnet run / IIS Express) leaves it false and falls back to the static appsettings.json chain.

The full Aspire resource graph and azd up flow are documented in Azure Container Apps and Local development.

App Service: published zip

The App Service path does not use Aspire. PublishApp.ps1 produces zips that include the appsettings.azure.* / appsettings.spark.* / appsettings.rohlig.json layers, and DeployToAppServices.ps1 deploys them into the slot for the chosen -AzureEnvironment. After deploy, slots are warmed and swapped (SwapAppServiceSlots.ps1) and the DB is migrated via the gated DbMigrator handshake (MigrateAppServiceDb.ps1). See Azure App Service and CI/CD.

The two token engines

Both run after the JSON files are layered, and both use {…} syntax — they are distinct and can nest.

  1. ConfigurationParameters tokensConfigurationParameterReplacementProvider substitutes values like {spark-db-name}, {rabbit-mq-port}, {spark-dev-domain} from the ConfigurationParameters section (regex \{([^{}]+)\}).
  2. Service-discovery tokensServiceDiscoveryConfigurationProvider resolves {api}, {auth}, {web}, {admin}, {realtime} (and any connection-string name) from services:<name>:https:0…:http:0ConnectionStrings:<name> (regex \{([\w-]+)\}). It throws a helpful "Available: …" error when a token cannot be resolved.

The Services block in appsettings.azure.json deliberately nests a ConfigurationParameters token inside a service-discovery target:

// src/Cargonerds.ServiceDefaults/appsettings.azure.json
"api":  { "https": ["https://api.{spark-dev-domain}.spark.cargonerds.dev"] },
"auth": { "https": ["https://auth.{spark-dev-domain}.spark.cargonerds.dev"] }

The production layer overrides these with the public Röhlig URLs (https://api.rt3.rohlig.com, etc.) in appsettings.azure.prod.json.

Secrets

There is no Azure Key Vault — secrets are committed

This solution does not use a secret store. DB passwords, the SMTP password, the Entra client secret, third-party API keys (Google, Algolia, MapTiler, E-adapter, Pricing), StringEncryption:DefaultPassPhrase, the OpenIddict CertificatePassPhrase, and the ABP license code are all present in committed appsettings*.json files. Treat the repository as containing live production credentials, and rotate anything exposed (project memory already flags rotating a leaked API key from git history — the same concern applies broadly).

Secrets reach a deployed app through three channels:

  • CI / azd login — GitHub Actions secrets (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, AZURE_CLIENT_SECRET for Azure OIDC/azd login; GIT_ADMIN_TOKEN for the version-bump push). See CI/CD.
  • Committed app configuration — the appsettings.azure.*, appsettings.spark.* and appsettings.rohlig.json layers shipped with the app (connection strings, external API keys).
  • ABP licenseappsettings.secrets.json (holding only AbpLicenseCode) loaded via ABP's AddAppSettingsSecretsJson() in each host's Program.cs.
  • Local dev only — .NET User Secrets (the AppHost project has a UserSecretsId) and the git-ignored appsettings.spark.<env>.user.json overrides.

Production safety on a local machine

Never set AZURE_ENVIRONMENT=prod (or any prod.*) locally unless you are certain — your process will connect to production data. Use dev, or prod.staging /dev.hub-prod (backed by the read-only Spark user) for investigation. See the caveats in the Configuration Reference.

DbMigrator credentials and the migration handshake

The Cargonerds.DbMigrator uses a separate, admin-privileged Default connection string so migrations have DDL rights. It passes an additionalSettingsPrefix of azure.migrator, which layers an extra appsettings.azure.migrator.<env>.json alongside the standard Azure files:

// src/Cargonerds.DbMigrator/Program.cs
configuration.AddAzureJsonFiles(azureEnvironment, "azure.migrator");
// src/Cargonerds.DbMigrator/appsettings.azure.migrator.dev.json
{
  "ConnectionStrings": {
    "Default": "Server=tcp:sql-spark-dev.database.windows.net,1433;User ID=spark-db-admin-dev;Password='…';Database={spark-db-name}"
  },
  "SPARK_ENVIRONMENT": "dev"
}

The migrator ships these admin overlays per environment: dev, dev.hub-test, dev.hub-prod, prod, prod.staging.

When run with --enable-api, it exposes GET /migration/status?token=…, guarded by a PIPELINE_AUTH_TOKEN config value (compared to the ?token= query). The App Service pipeline generates a short-lived 50-char token, sets it as an app setting, polls /migration/status until Completed, then deletes the token. The full handshake lives in Azure App Service.

Admin vs runtime credentials

Migrations run as spark-db-admin-*, but the running app uses spark-app. When debugging permission errors, check which credential a given step uses.

White-label settings

Per-tenant appearance, OIDC/Entra logins and third-party API keys live in a single white-label JSON file (appsettings.rohlig.json), edited only in the AppHost project. It is linked into Cargonerds.ServiceDefaults and copied to every host's output, so all hosts ship the same file:

<!-- src/Cargonerds.ServiceDefaults/Cargonerds.ServiceDefaults.csproj -->
<Content Include="..\Cargonerds.AppHost\appsettings.rohlig.json">
  <Link>appsettings.rohlig.json</Link>
  <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>

The path can be overridden with WHITE_LABEL_SETTINGS_PATH. appsettings.spark.<env>.json files override sub-sections of WhiteLabelingOptions:ExternalApis per environment. See Theming & White-Labeling.

Edit only the AppHost copy

There is exactly one editable copy of appsettings.rohlig.json (in the AppHost project). All other copies are build artifacts — editing a bin/.../appsettings.rohlig.json has no lasting effect.

Per-environment cache isolation

The ABP distributed cache key prefix is environment-suffixed, so multiple environments can safely share one Redis instance:

// src/Cargonerds.HttpApi.Host/CargonerdsHttpApiHostModule.cs
options.KeyPrefix = "Cargonerds:";
if (configuration.GetAzureEnvironment() is string azureEnv)
{
    options.KeyPrefix += $"{azureEnv}:";
}

An empty AZURE_ENVIRONMENT shares the base Cargonerds: prefix; switching environments against a shared Redis intentionally isolates caches.

Observability

Telemetry exporters are wired in AddServiceDefaults() and enabled purely by environment variables (see HostApplicationBuilderExtensions.cs):

  • OTEL_EXPORTER_OTLP_ENDPOINT — when set (non-empty), enables OTLP export (UseOtlpExporter).
  • APPLICATIONINSIGHTS_CONNECTION_STRING — when set (non-empty), enables Azure Monitor (UseAzureMonitor).

Both are no-ops when their variable is unset, so local runs export nothing by default.

Gotchas

  • Two {…} token engines coexist. ConfigurationParameters ({spark-db-name}) and service-discovery ({api}) tokens both use brace syntax and deliberately nest in the Services URLs. The ConfigurationParameters source is appended inside AddAzureJsonFiles; the service-discovery source is added later in AddServiceDiscoveryConfiguration — order matters.
  • SPARK_ENVIRONMENT typos crash startup. FromName throws on unknown values (but returns Dev for null/empty).
  • USE_ASPIRE_CONFIG must be set under Aspire, otherwise the host loads the Aspire-incompatible default config.
  • The design-time EF factory hardcodes the DbMigrator path and reads only appsettings.json (no secrets/azure layering) — Add-Migration needs that file pointing at a reachable Default DB.
  • appsettings.rohlig.json is duplicated to output — there is one editable copy (AppHost).
  • Filename quirk: the AppHost helper is misspelled ConfigruationExtensions.cs — grep for that exact spelling.

See also