.NET Aspire Integration¶
.NET Aspire is a cloud-ready stack for building and
running observable, distributed .NET applications. Cargonerds uses it as the single entry point
for running the whole system locally (one dotnet run brings up SQL Server, Redis, RabbitMQ, the
.NET services and the Node frontends) and as the publishing model for Azure (azd reads the
Aspire manifest to provision Container Apps / App Service plus the backing Azure resources).
ABP ships first-class guidance for this layout; see ABP's .NET Aspire integration for the framework's take on orchestrating an ABP solution with Aspire.
Version pinning
The Aspire package version is pinned by AspireVersion in common.props
(currently 13.2.2) and flows through Directory.Packages.props. The AppHost MSBuild SDK
is pinned separately in src/Cargonerds.AppHost/Cargonerds.AppHost.csproj
(<Sdk Name="Aspire.AppHost.Sdk" Version="13.1.0" />). All projects target net10.0.
Two projects own the integration:
| Project | Role | Marker |
|---|---|---|
src/Cargonerds.AppHost |
The distributed-application host / orchestrator. Declares every resource and wires their dependencies, endpoints, env vars, health checks and start order. | <IsAspireHost>true</IsAspireHost>, OutputType=Exe |
src/Cargonerds.ServiceDefaults |
The shared cross-cutting host setup referenced by every service. AddServiceDefaults() adds OpenTelemetry, health checks, HTTP resilience, service discovery and the multi-layer config pipeline. |
<IsAspireSharedProject>true</IsAspireSharedProject> |
A heavily-used helper package is Nextended.Aspire (10.1.0, pinned in
Directory.Packages.props). Several extension methods used in the AppHost
(EnsureDockerRunningIfLocalDebug, WithExplicitStart/WithExplicitStartIf,
GetFirstExistingEndpoint, AddJavaScriptApp's NPM-workspace discovery) come from there rather than
from the Aspire SDK itself.
flowchart TD
AppHost["Cargonerds.AppHost<br/>(Program.cs)"]
subgraph infra["Infrastructure resources"]
DB[("sqlserver / db<br/>spark-db :14330")]
REDIS[("redis<br/>spark-redis")]
MQ[("messaging<br/>spark-rabbitmq")]
end
subgraph svc[".NET service projects"]
MIG["db-migrator"]
AUTH["auth :44345"]
API["api :44354"]
ADMIN["admin (Blazor) :44381"]
end
subgraph fe["Frontends (auto-discovered)"]
RT["realtime :4200"]
end
DOCS["documentation<br/>(MkDocs, explicit start)"]
AppHost --> infra
AppHost --> svc
AppHost --> fe
AppHost --> DOCS
MIG -- WaitForCompletion --> AUTH
MIG -- WaitForCompletion --> API
AUTH -- WaitFor --> API
API -- WaitFor --> ADMIN
API -- WaitFor --> RT
DB --> MIG
DB --> AUTH
DB --> API
REDIS --> AUTH
REDIS --> API
MQ --> AUTH
MQ --> API
The AppHost¶
src/Cargonerds.AppHost/Program.cs is a top-level program that builds the entire resource graph and
ends with:
EnsureDockerRunningIfLocalDebug() (from Nextended.Aspire) fails fast with a clear message if
Docker Desktop is not running during a local debug session.
Resource names are centralised in CargonerdsConsts.Aspire.Service
(src/Cargonerds.Domain.Shared/CargonerdsConsts.cs) — a nested class hierarchy whose instances each
carry a Name:
| Field | Name |
|---|---|
UiLib |
@cargonerds/ui-library |
Auth |
auth |
Public |
web |
Admin |
admin |
Api |
api |
Redis |
redis |
Messaging |
messaging |
Migrator |
db-migrator |
Documentation |
documentation |
CargonerdsConsts.Aspire.Directories holds AppHost-relative paths (SolutionRoot = "../../",
Frontends = ../../frontend, Documentation = ../../docs), and CargonerdsConsts also defines
InternalServicePassword = "UnsecurePassword" used as the static password for internal-only services.
Run mode vs publish mode¶
The single Program.cs dual-targets two modes, branching on
builder.ExecutionContext.IsRunMode / IsPublishMode:
- Run mode — local
dotnet run/ F5. Real containers, fixed local ports, the spark-DB-vs-container decision, dev speed-up toggles. - Publish mode —
azd/ manifest generation for Azure Container Apps / App Service. SQL/Redis/RabbitMQ resolve to their Azure equivalents and service URLs are computed fromDEPLOYMENT_DOMAIN.
The local helper IfRunMode(...) in Extensions/ResourceBuilderExtensions.cs is just
builder.If(ExecutionContext.IsRunMode, action) and is used throughout to gate run-mode-only wiring.
Parameters and secrets¶
Two Aspire parameters are declared up front:
var unsecurePassword = builder.AddParameter(
nameof(CargonerdsConsts.InternalServicePassword),
CargonerdsConsts.InternalServicePassword); // Redis + RabbitMQ
var dbPassword = builder.AddParameter("dbPassword", "securePassword-2026!"); // SQL container
What it provisions¶
Database — branches on SparkDbRunModeConfiguration (see below):
- If a spark-DB run-mode config is present (a local AppHost pointed at an Azure DB), the
Defaultconnection string is supplied literally viabuilder.AddConnectionString("Default", …). - Otherwise an Azure SQL Server is declared and run as a container locally:
builder.AddAzureSqlServer("sqlserver")
.RunAsContainer(s => s
.WithEndpoint("tcp", e => { e.Port = 14330; e.IsProxied = false; })
.WithDataVolume("spark-db")
.WithContainerName("spark-db")
.WithLifetime(ContainerLifetime.Persistent)
.WithPassword(dbPassword))
.AddDatabase("db")
.WithDefaultAzureSku();
The container uses a fixed, un-proxied TCP endpoint on port 14330, a persistent data volume and
a persistent lifetime, and is exposed to services as the Default connection string.
Redis — Azure Redis run as a container, bundled with RedisInsight and Redis Commander for
inspection, a persistent spark-redis data volume and the shared internal password:
#pragma warning disable CS0618 // Switching to AzureManagedRedis increases costs
builder.AddAzureRedis(CargonerdsConsts.Aspire.Service.Redis.Name)
.WithAccessKeyAuthentication()
.RunAsContainer(o => o
.WithPassword(unsecurePassword)
.WithRedisInsight(...).WithRedisCommander(...)
.WithDataVolume(name: "spark-redis")
.WithContainerName("spark-redis")
.WithLifetime(ContainerLifetime.Persistent));
#pragma warning restore CS0618
AddAzureRedis is obsolete on purpose
The call is wrapped in #pragma warning disable CS0618 with an explicit comment
("Switching to AzureManagedRedis increases costs"). Do not "fix" the warning by switching
to AddAzureManagedRedis.
RabbitMQ — the messaging resource, management plugin enabled, persistent, container-named only
in run mode:
builder.AddRabbitMQ(CargonerdsConsts.Aspire.Service.Messaging.Name, password: unsecurePassword)
.WithManagementPlugin()
.WithExternalHttpEndpoints()
.WithLifetime(ContainerLifetime.Persistent)
.IfRunMode(c => c.WithContainerName("spark-rabbitmq"))
.PublishAsContainer();
.NET service projects — db-migrator, auth, api and admin (Blazor), each registered with
the local AddProjectWithDefaults helper:
var migrator = builder.AddProjectWithDefaults<Cargonerds_DbMigrator>(CargonerdsConsts.Aspire.Service.Migrator);
var authServer = builder.AddProjectWithDefaults<Cargonerds_AuthServer>(CargonerdsConsts.Aspire.Service.Auth);
var apiHost = builder.AddProjectWithDefaults<Cargonerds_HttpApi_Host>(CargonerdsConsts.Aspire.Service.Api);
var adminFrontend = builder.AddProjectWithDefaults<Cargonerds_Blazor>(CargonerdsConsts.Aspire.Service.Admin);
AddProjectWithDefaults is the project equivalent of "give me an Aspire project that knows it is
running under the AppHost": it calls AddProject<TProject>(service.Name, launchProfile) and always
sets USE_ASPIRE_CONFIG=true so the child loads its appsettings.aspire.json
(see Cross-service wiring). The launch profile defaults to
the project file name. (Cargonerds_* are the Aspire source-generated Projects.* metadata types.)
public static IResourceBuilder<ProjectResource> AddProjectWithDefaults<TProject>(
this IDistributedApplicationBuilder builder,
CargonerdsConsts.Aspire.Service service,
string? launchProfileName = null)
where TProject : IProjectMetadata, new() =>
builder
.AddProject<TProject>(service.Name, launchProfileName ?? ProjectName<TProject>())
.WithEnvironment(EnvVars.UseAspireConfig, "true");
Frontends — every subfolder of frontend/ that contains a Dockerfile is auto-discovered by
AddAllFrontends() (→ AddAllNpmAppsInPath) and registered as a JavaScript app (currently only
realtime). For each app the AppHost generates the NPM-workspaces package.json, picks the start
script (first script whose name contains aspire or start, else start), and wires:
builder.AddJavaScriptApp(appName, app, startScript)
.WithEnvironment("BROWSER", "none")
.WithHttpEndpoint(port: fixedPort, env: "PORT", name: "http") // realtime pinned to 4200 in run mode
.WithGenerateProxyCommandCommand() // dashboard "Generate Proxies" command
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("/", 200)
.WithOtlpExporter()
.PublishAsDockerFile(...) // ui-library workspace ⇒ build context = frontend/
.IfRunMode(b => b.WithEnvironment("NODE_TLS_REJECT_UNAUTHORIZED", "0"));
appName is normalised by EscapeProjectname (strip a leading cargonerds-, lowercase,
dash-separate). In publish mode, frontends in IgnoredOnDeploy (["angular"]) are skipped.
Back in Program.cs each frontend additionally gets WaitFor(apiHost),
WithNextAuth(apiHost, whiteLabelOptions, authServer) and WithExplicitStartIf(explicitFrontendStart).
See Realtime frontend for the NextAuth env-var contract.
Documentation — this MkDocs site, built from docs/Dockerfile and started on demand:
builder.AddDockerfile(CargonerdsConsts.Aspire.Service.Documentation.Name,
CargonerdsConsts.Aspire.Directories.Documentation, "Dockerfile")
.WithLifetime(ContainerLifetime.Persistent)
.WithHttpEndpoint(port: builder.ExecutionContext.IsPublishMode ? 80 : 8001, targetPort: 8000, name: "http")
.WithExternalHttpEndpoints()
.WithExplicitStart(); // must be started manually from the dashboard
Fixed local ports¶
Run-mode ports are hard-coded in src/Cargonerds.AppHost/RunModeServicePorts.cs:
| Service | Port | Scheme |
|---|---|---|
| Auth | 44345 |
HTTPS |
| Api | 44354 |
HTTPS |
| Admin (Blazor) | 44381 |
HTTPS |
| Realtime | 4200 |
HTTP |
Db (DbPort) |
53938 |
— |
Fixed ports collide if already bound
RunModeServicePorts hard-codes 44345 / 44354 / 44381 / 4200, and the SQL container's TCP
14330 is un-proxied (IsProxied = false). A stray local SQL Server on 14330, or anything
already bound to those HTTPS ports, will prevent the AppHost from starting cleanly.
How services are wired¶
Start order and health gates¶
Each project's WaitFor / WaitForCompletion calls are conditioned by two dev-speed-up env vars,
read once at the top of Program.cs:
SKIP_WAITING_IN_BACKEND(EnvVars.SkipWaitingInBackend) → skip backend health/completion waits.EXPLICIT_FRONTEND_START(EnvVars.ExplicitFrontendStart) → require a manual start of the frontends in the dashboard.
The wiring per resource:
- migrator —
WaitFor(db),WithReference(db, "Default"); if the spark-DB config requested validation-only it setsVALIDATE_MIGRATIONS_ONLY=true. - adminFrontend (Blazor) — run-mode HTTPS on
44381, external endpoints,WithHttpHealthCheck("/", 200),WaitForIf(!skip, authServer, apiHost), explicit-start gate,WithWhiteLabelSettingsPath(). - authServer — run-mode HTTPS
44345, external,WaitFor(redis, rabbitmq, db), white-label path,WaitForCompletionIf(!skip, migrator),WithReference(db, "Default"). - apiHost — run-mode HTTPS
44354and (unless skipping) an HTTP health check against the ABP endpoint/api/abp/application-configuration; external;WaitForIf(!skip, authServer);WaitFor(redis, rabbitmq, db);WithHealthStatusCheck();WithWhiteLabelSettingsPath();WaitForCompletionIf(!skip, migrator);WithReference(db, "Default"); andPassConfigurationValue(SPARK_ENVIRONMENT)to forward the host'sSPARK_ENVIRONMENTto the child.
The local WaitFor(params IResourceBuilder<IResource>?[]) helper is null-tolerant
(resources.WhereNotNull().Aggregate(...)), so passing an optional/absent resource is safe.
WithHealthStatusCheck() adds the API's /health-status HTTP check (200), sets
App__HealthCheckUrl, and registers dashboard URLs including /health-ui:
builder.WithHttpHealthCheck("/health-status", 200);
builder.WithEnvironment("App__HealthCheckUrl", "/health-status");
// TODO: Enable Health UI but following code is only working locally.
builder
.WithUrlForEndpoint("https", url => url.DisplayText = $"{builder.Resource.Name.ToPascalCase()}-Home")
.WithUrlForEndpoint("https", _ => new() { Url = "/health-ui", DisplayText = "Health UI" });
Health UI is local-only (TODO)
The Health-UI URL wiring in WithHealthStatusCheck carries a code comment that it only works
locally; treat it as a dashboard convenience, not a deployed endpoint.
Cross-service wiring (service discovery)¶
After all resources are declared, Program.cs fans out references so every service can resolve the
others:
var projects = new List<IResourceBuilder<ProjectResource>> { authServer, apiHost, adminFrontend };
IResourceBuilder<IResourceWithEndpoints>[] servicesWithEndpoints = [.. frontends];
IResourceBuilder<IResourceWithConnectionString>[] servicesWithConnectionString = [rabbitmq, redis];
foreach (var project in projects.Append(migrator))
{
project.WithServiceReference(projects); // project → project
project.WithServiceReference(servicesWithEndpoints); // project → frontends
foreach (var service in servicesWithConnectionString)
project.WithReference(service); // rabbitmq + redis connection strings
}
WithServiceReference is the producer side of service discovery. It sets an env var
services__{targetName}__https__0 whose value is mode-dependent:
var configPath = $"services__{target.Resource.Name}__https__0";
return builder.ApplicationBuilder.ExecutionContext.IsPublishMode
? builder.WithEnvironment(configPath, GetDeploymentDomain(target)) // https://{name}.{DEPLOYMENT_DOMAIN}
: builder.WithEnvironment(configPath, target.GetFirstExistingEndpoint()); // resolved local endpoint
The consumer side lives in each child's committed appsettings.aspire.json (these files are
static, not generated). For example src/Cargonerds.HttpApi.Host/appsettings.aspire.json:
{
"App": { "SelfUrl": "{api}", "AllowedCorsOrigins": "{realtime}", "CorsOrigins": "{realtime},{admin}", "Realtime": "{realtime}" },
"AuthServer": { "Authority": "{auth}", "MetaAddress": "{auth}", "WellKnownConfigAddress": "{auth}/.well-known/openid-configuration" },
"Redis": { "Configuration": "{redis}" },
"RabbitMQ": { "Connections": { "Default": { "HostName": "{messaging}", "Override": true } } }
}
At child startup the custom ServiceDiscoveryConfigurationProvider (registered by
AddServiceDiscoveryConfiguration()) walks every config value, and for each {name} token resolves
the first of:
services:{name}:https:0services:{name}:http:0ConnectionStrings:{name}
If none exist it throws an InvalidOperationException that lists the available services — good
DX, but a hard failure. The token regex is \{([\w-]+)\}.
Service-discovery tokens fail fast at child startup
A {name} token in any appsettings.aspire.json value that has no matching services:*
endpoint or ConnectionStrings:* entry will crash the child on boot — not just disable a single
feature. If you add a token, remember to wire the corresponding resource in the AppHost.
Note the AppHost emits services__{name}__https__0 (double-underscore env-var form) while the
provider reads services:{name}:https:0 — these are the same key in .NET configuration, just the
env-var vs colon spellings.
Frontend env wiring (WithNextAuth)¶
Extensions/NodeAppExtensions.cs injects the NextAuth contract into each JavaScriptAppResource:
return builder
.WithSelfEnvironmentHttpsEndpoint("APP_URL")
.WithEnvironmentHttpsEndpoint("API_URL", api)
.WithEnvironmentHttpsEndpoint("AuthServer__Authority", authServer)
.WithEnvironment("AUTH_CLIENT_ID", builder.Resource.Name)
.WithEnvironment("MAPTILER_API_KEY", mapTilerApiKey);
So OIDC and API calls from the React app resolve to the orchestrated endpoints. AUTH_CLIENT_ID is
literally the resource name, and MAPTILER_API_KEY is read from
WhiteLabelingOptions.ExternalApis.MapTiler.ApiKey. WithEnvironmentHttpsEndpoint itself is
mode-aware: publish mode emits https://{name}.{DEPLOYMENT_DOMAIN}, run mode resolves the live
endpoint via GetFirstExistingEndpoint().
Service defaults¶
Cargonerds.ServiceDefaults exposes AddServiceDefaults()
(src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs, namespaced
Microsoft.Extensions.Hosting so it is discoverable without an extra using). Every host calls it as
the first builder step:
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.AddExtraConfigFiles(); // multi-layer config pipeline (see below)
builder.AddServiceDiscoveryConfiguration(); // registers ServiceDiscoveryConfigurationSource
builder.ConfigureOpenTelemetry();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler(); // retries, circuit breaker, timeouts
http.AddServiceDiscovery(); // resolve service names in HttpClients
});
return builder;
}
It bundles four cross-cutting concerns:
| Concern | What AddServiceDefaults does |
|---|---|
| Service discovery | AddServiceDiscovery() for the DI container + the custom config source that resolves {name} tokens (above). |
| HTTP resilience | ConfigureHttpClientDefaults adds the standard resilience handler (retry, circuit breaker, timeouts) to all HttpClients, plus service discovery so logical names resolve. |
| Health checks | MapDefaultEndpoints maps /health and /alive (see below). |
| OpenTelemetry | logging, metrics and traces with an OTLP exporter (see below). |
OpenTelemetry¶
ConfigureOpenTelemetry enables all three signals:
- Logging —
AddOpenTelemetrywithIncludeFormattedMessage+IncludeScopes. - Metrics — ASP.NET Core +
HttpClient+ Runtime instrumentation. - Tracing — source =
builder.Environment.ApplicationName, ASP.NET Core +HttpClientinstrumentation (gRPC instrumentation is present but commented out).
Exporters (AddOpenTelemetryExporters) are conditional and can both be active:
if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]))
builder.Services.AddOpenTelemetry().UseOtlpExporter();
var aiConn = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
if (!string.IsNullOrEmpty(aiConn))
builder.Services.AddOpenTelemetry().UseAzureMonitor(o => o.ConnectionString = aiConn);
The AppHost injects OTEL_EXPORTER_OTLP_ENDPOINT into each resource, which is what makes the
dashboard's trace and metric views light up locally; APPLICATIONINSIGHTS_CONNECTION_STRING adds
Azure Monitor in the cloud.
Health endpoints¶
MapDefaultEndpoints (called explicitly, e.g. in Cargonerds.AuthServer/Program.cs) maps the
generic endpoints only in Development, with a security note in the code:
if (app.Environment.IsDevelopment())
{
app.MapHealthChecks("/health"); // all checks
app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") });
}
These are distinct from the API's custom /health-status and /health-ui endpoints wired by
WithHealthStatusCheck in the AppHost.
The config pipeline (AddExtraConfigFiles)¶
AddServiceDefaults also layers Cargonerds' bespoke configuration files under ABP's config
system (because hosts call AddServiceDefaults() before the ABP module bootstrap). In order, each
file optional:
appsettings.aspire.json— only whenUSE_ASPIRE_CONFIG=true.appsettings.azure[.<part>...].json— whenAZURE_ENVIRONMENTis set, viaAddAzureJsonFiles, which expands the dotted environment name cumulatively and appends aConfigurationParameterReplacementSource({token}substitution from theConfigurationParameterssection).WHITE_LABEL_SETTINGS_PATH(defaultappsettings.rohlig.json).appsettings.spark.{env}.json+appsettings.spark.{env}.user.json(reload-on-change), whereenv = SparkEnvironment.FromName(SPARK_ENVIRONMENT).SparkEnvironmentis also registered as a DI singleton.
This pipeline — the two token engines, USE_ASPIRE_CONFIG, AZURE_ENVIRONMENT vs SPARK_ENVIRONMENT,
and the ConfigurationParameters replacement — is documented end-to-end in
Configuration reference and Deployment configuration.
Environment variables the AppHost reads and forwards¶
src/Cargonerds.AppHost/EnvVars.cs is the single source of truth for env-var names and typed
accessors:
| Variable | Read by | Purpose |
|---|---|---|
USE_ASPIRE_CONFIG |
child services | Load appsettings.aspire.json. Set automatically by AddProjectWithDefaults. Mandatory under the AppHost. |
SPARK_ENVIRONMENT |
api host (forwarded) | Selects the Hub DB / blob storage / service bus (the SPARK data source). |
VALIDATE_MIGRATIONS_ONLY |
migrator | Validate migrations without applying them; fails if open migrations exist. |
WHITE_LABEL_SETTINGS_PATH |
child services | Absolute path to appsettings.rohlig.json (run mode only). |
APPHOST_AZURE_ENVIRONMENT |
AppHost | Selects which Azure settings the AppHost loads for spark-DB run mode. |
APPHOST_AZURE_DATABASE_COPY_SUFFIX |
AppHost | Appended to spark-db-name so a local AppHost runs against a copy of an Azure DB. |
SKIP_WAITING_IN_BACKEND |
AppHost | Skip backend health/completion waits to speed up local starts. |
EXPLICIT_FRONTEND_START |
AppHost | Frontends require manual start in the dashboard. |
DEPLOYMENT_DOMAIN |
AppHost (publish) | Public domain used to compute service URLs; throws if missing in publish mode. |
USE_ASPIRE_CONFIG is mandatory for orchestrated services
Per the EnvVars.UseAspireConfig doc comment: "If not set the default ABP configuration will be
used which is not compatible with the Aspire AppHost." The AppHost always sets it for the four
AddProjectWithDefaults projects; if you run a host standalone (IIS Express / dotnet run
outside the AppHost) you must set it yourself or you'll get the non-Aspire config.
AZURE_ENVIRONMENT (child) vs APPHOST_AZURE_ENVIRONMENT (host)
These are different keys for different layers. The host key selects which Azure settings the
AppHost loads for spark-DB run mode; the child key selects which appsettings.azure.* the
service loads.
Running against an Azure spark DB¶
SparkDbRunModeConfiguration.Create lets a local AppHost point at a real Azure spark DB (or a
renamed copy) instead of spinning up a SQL container. It runs only in run mode and only when
APPHOST_AZURE_ENVIRONMENT is set. It loads appsettings.azure[.parts].json from the
ServiceDefaults folder, optionally renames the DB by appending
APPHOST_AZURE_DATABASE_COPY_SUFFIX to ConfigurationParameters:spark-db-name, then resolves the
Default connection string through the ConfigurationParameters replacement.
Guard rails (verified in source):
prod.*environments require the copy-suffix — you cannot accidentally run local code against the real prod DB.- Missing
spark-db-name(when a suffix is used) or a missing resolvedDefaultconnection string throws. ValidateMigrationsOnlyis set totrueexactly when no copy-suffix is used — i.e. you're pointed at a shared DB, so the migrator must only validate, never apply.
return new SparkDbRunModeConfiguration(
defaultConnectionString,
string.IsNullOrWhiteSpace(azureDatabaseCopySuffix)); // ValidateMigrationsOnly
The dashboard¶
Running the AppHost launches the Aspire dashboard automatically; its URL is printed to the console (the port is assigned by Aspire, not fixed). From it you can:
- View the health/state of every resource and its dependency graph.
- Read live logs and structured traces and metrics (powered by the OTLP exporter that
AddServiceDefaultswires up). - Inspect the environment variables and endpoints passed to each resource.
- Manually start on-demand resources such as
documentation, and (ifEXPLICIT_FRONTEND_START) the frontends. - Run the per-frontend "Generate Proxies" command, which executes ABP's
abp generate-proxy -t ngagainst thefrontend/directory (Helper/CommandHelper.cs, surfaced viaWithGenerateProxyCommandCommand).
Running with Aspire¶
Docker Desktop must be running (EnsureDockerRunningIfLocalDebug()). See
Running locally and Debugging for the local
workflow, the dev speed-up toggles and the "Generate Proxies" command.
The manifest and deployment¶
Aspire also drives publishing. In publish mode the AppHost emits a manifest that azd consumes:
SQL Server, Redis and RabbitMQ are declared with AddAzureSqlServer / AddAzureRedis /
AddRabbitMQ(...).PublishAsContainer(), so they provision Azure equivalents (or containers); service
URLs are computed from DEPLOYMENT_DOMAIN; and frontends listed in IgnoredOnDeploy (angular) are
omitted. The full flow — azure.yaml, Container Apps vs App Service, the spark-DB-vs-container
decision and CI/CD — is covered in:
Local ↔ Azure mapping¶
| Resource | Run mode (local) | Publish mode (Azure) |
|---|---|---|
| SQL Server | spark-db container, TCP 14330 (un-proxied), persistent volume — or an Azure spark DB via SparkDbRunModeConfiguration |
Azure SQL (AddAzureSqlServer + WithDefaultAzureSku) |
| Redis | spark-redis container + RedisInsight + Redis Commander |
Azure Cache for Redis (AddAzureRedis, access-key auth) |
| RabbitMQ | spark-rabbitmq container + management plugin |
Container (PublishAsContainer) |
Service URLs (services__*, API_URL, …) |
GetFirstExistingEndpoint() (live local endpoint) |
https://{resourceName}.{DEPLOYMENT_DOMAIN} |
| Frontends | all frontend/* with a Dockerfile |
same, minus IgnoredOnDeploy (angular) |
| Documentation | :8001 → target 8000, explicit start |
:80 → target 8000 |
| OTel exporter | OTLP → dashboard | Azure Monitor (APPLICATIONINSIGHTS_CONNECTION_STRING) + OTLP if configured |
ABP integration notes¶
- Bootstrap order. Every host (
HttpApi.Host,AuthServer,Blazor) callsbuilder.AddServiceDefaults()before the ABP module bootstrap (await builder.AddApplicationAsync<…Module>()/app.InitializeApplicationAsync()), alongside ABP'sUseAutofac()andAddAppSettingsSecretsJson(). Aspire config and OpenTelemetry are therefore layered in under ABP's configuration system. See ABP modularity and dependency injection. - Readiness via ABP. The API's run-mode readiness probe hits ABP's
/api/abp/application-configurationendpoint. - Auto-generated client proxies. The dashboard "Generate Proxies" command runs ABP's
dynamic proxy generation
/
abp generate-proxy -t ngfor the React frontend. - ABP DbMigrator orchestration.
Cargonerds.DbMigratoris an ABP migrator hosted as an Aspire project whose completion gates the API and Auth servers (see Migrations). - Redis distributed cache.
Cargonerds.HttpApi.Hostcallsbuilder.AddRedisClient("redis")immediately afterAddServiceDefaults(), feeding ABP's distributed cache (StackExchange.Redis); the{redis}token inappsettings.aspire.jsonsupplies the connection string. RabbitMQ similarly backs ABP's distributed event bus. See Caching and Messaging. - OpenIddict / auth. The AppHost compile-links
OpenIddictAppConfiguration.csand referencesVolo.Abp.OpenIddict.Pro.Domain.Shared; the React frontend's NextAuth is pointed at the ABP OpenIddict auth server authority viaAuthServer__Authority+AUTH_CLIENT_ID.
DbMigrator does NOT use AddServiceDefaults()
Cargonerds.DbMigrator/Program.cs manually re-implements the config layering
(USE_ASPIRE_CONFIG → appsettings.aspire.json, AddAzureJsonFiles(env, "azure.migrator") with an
extra azure.migrator prefix for DB-admin credentials, and the service-discovery source). As a
result it has no OpenTelemetry / resilience / health checks from ServiceDefaults, and its config
pipeline can drift from the shared one. It is a generic Host worker unless run with --enable-api
(then a slim WebApplication exposing a token-guarded /migration/status); it also honours
--disable-redis.
Gotchas¶
Committed secrets
src/Cargonerds.ServiceDefaults/appsettings.azure.*.json contain real SQL / Redis / RabbitMQ
credentials checked into the repo (e.g. a live SQL password and a redis.cache.windows.net access
key in appsettings.azure.dev.json). These are copied to build output and to publish. Treat
this as a real secret-exposure risk; any rotated values must not be re-committed. (The same concern
applies to the spark and white-label files — see Configuration reference.)
- Three sources of extension methods. Helpers used in
Program.cscome from three places: the localAppHost/Extensions/ResourceBuilderExtensions.cs(IfRunMode,WaitFor,WithHealthStatusCheck,WithServiceReference, …);Nextended.Aspire(EnsureDockerRunningIfLocalDebug,WithExplicitStart/WithExplicitStartIf,GetFirstExistingEndpoint,AddJavaScriptApp); andNextended.Core.Extensions(If/Apply). The rest (RunAsContainer,PublishAsContainer,WithDefaultAzureSku) are Aspire built-ins. When tracing a method, check all three. - Filename typo. The AppHost has
ConfigruationExtensions.cs(sic — the misspelling is in the filename); search by that exact spelling. RedisResourceExtensions.ConfigureRedisis effectively dead. It targets a plainRedisResource, butProgram.csusesAddAzureRedis(...).RunAsContainer(...)inline and never calls it.appsettings.aspire.jsonfiles are committed per service (HttpApi.Host, AuthServer, Blazor, Web.Public, DbMigrator). They are the static consumer half of service discovery — not generated by the AppHost.
Related pages¶
- Architecture overview and Solution structure — where the AppHost and ServiceDefaults sit in the solution.
- ABP patterns — the framework conventions referenced above.
- Configuration reference and appsettings reference — the full config layering, token engines and connection strings.
- Deployment configuration, Deployment overview,
Azure Container Apps — publish mode and
azd. - Caching, Messaging, Migrations — the orchestrated infrastructure resources.
- Running locally and Debugging — dev workflow.
- Realtime frontend —
AddAllFrontends, NextAuth env wiring.