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_ENVIRONMENTchooses the Hub DB / blob / service bus;AZURE_ENVIRONMENTchooses 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*.jsonlayers and CI secrets — treat the whole repo as containing live credentials. - Config is layered by
AddServiceDefaults()inCargonerds.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.json → appsettings.azure.dev.json → appsettings.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.{Environment}.json"]
B["appsettings.aspire.json<br/>(when USE_ASPIRE_CONFIG=true)"]
C["appsettings.azure.<dotted>.json<br/>(when AZURE_ENVIRONMENT set)"]
D["appsettings.rohlig.json<br/>(white-label; WHITE_LABEL_SETTINGS_PATH)"]
E["appsettings.spark.<env>.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 {spark-db-name} +<br/>ServiceDiscovery {api}/{auth}/{web}"]
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.
ConfigurationParameterstokens —ConfigurationParameterReplacementProvidersubstitutes values like{spark-db-name},{rabbit-mq-port},{spark-dev-domain}from theConfigurationParameterssection (regex\{([^{}]+)\}).- Service-discovery tokens —
ServiceDiscoveryConfigurationProviderresolves{api},{auth},{web},{admin},{realtime}(and any connection-string name) fromservices:<name>:https:0→…:http:0→ConnectionStrings:<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 /
azdlogin — GitHub Actions secrets (AZURE_CLIENT_ID,AZURE_TENANT_ID,AZURE_SUBSCRIPTION_ID,AZURE_CLIENT_SECRETfor Azure OIDC/azdlogin;GIT_ADMIN_TOKENfor the version-bump push). See CI/CD. - Committed app configuration — the
appsettings.azure.*,appsettings.spark.*andappsettings.rohlig.jsonlayers shipped with the app (connection strings, external API keys). - ABP license —
appsettings.secrets.json(holding onlyAbpLicenseCode) loaded via ABP'sAddAppSettingsSecretsJson()in each host'sProgram.cs. - Local dev only — .NET User Secrets (the AppHost project has a
UserSecretsId) and the git-ignoredappsettings.spark.<env>.user.jsonoverrides.
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 theServicesURLs. The ConfigurationParameters source is appended insideAddAzureJsonFiles; the service-discovery source is added later inAddServiceDiscoveryConfiguration— order matters. SPARK_ENVIRONMENTtypos crash startup.FromNamethrows on unknown values (but returnsDevfor null/empty).USE_ASPIRE_CONFIGmust 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-Migrationneeds that file pointing at a reachableDefaultDB. appsettings.rohlig.jsonis 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¶
- Configuration Reference — full layered-loading model, token precedence, and local-dev caveats.
- appsettings reference — exhaustive key-by-key catalogue.
- Azure Container Apps and Azure App Service — the two deployment paths.
- CI/CD — pipelines, OIDC login, version-bump flow, GitHub environment gates.
- Deployment overview and Local development.
- Theming & White-Labeling —
WhiteLabelingOptionsandEntraLogins.