Local Deployment¶
This page treats "local" as a deployment target: how the whole Cargonerds stack runs on a developer machine, what containers back it, how each process is configured, and where the secrets come from. There are three distinct ways to run locally, each with different trade-offs:
| Mode | What runs | Backing infra | Use when |
|---|---|---|---|
| Aspire AppHost (recommended) | Every .NET host + every frontend + infra, orchestrated | SQL Server, Redis, RabbitMQ as containers spun up by Aspire | Day-to-day development and debugging |
| Docker Compose | The four .NET hosts + migrator as containers | azure-sql-edge + redis containers |
A containerised run that mirrors production images, no Aspire |
| Infrastructure-only | You run one host from your IDE | Redis + RabbitMQ containers (DB is up to you) | Debugging a single service with minimal overhead |
Just want the happy path?
Run dotnet run --project src/Cargonerds.AppHost, open the Aspire dashboard, and skip to
Running Locally for ports and credentials. The rest of
this page explains why it works and the alternatives.
1. The Aspire AppHost (recommended)¶
The recommended way to run the whole solution locally is the .NET Aspire AppHost
(src/Cargonerds.AppHost), which orchestrates every backend service, every frontend and the
supporting infrastructure as Docker containers.
This single command provisions the infrastructure containers, runs the db-migrator, starts
auth / api / admin / realtime, and opens the Aspire dashboard. The orchestration is driven
entirely from the top-level program in src/Cargonerds.AppHost/Program.cs — it is the single source
of truth for what runs locally. The architecture of the AppHost and the shared ServiceDefaults
library is described in Aspire Integration; ABP's own guide
to this pattern is .NET Aspire integration.
Docker must be running
The host ends with builder.Build().EnsureDockerRunningIfLocalDebug().Run() (a
Nextended.Aspire helper). If Docker Desktop is not running during a local debug session it
fails fast with a clear message instead of timing out on container startup.
What the AppHost starts (run mode)¶
flowchart TD
subgraph infra["Infrastructure containers (persistent)"]
SQL["spark-db<br/>Azure SQL Edge<br/>TCP 14330 (un-proxied)"]
REDIS["spark-redis<br/>+ insight + commander"]
RMQ["spark-rabbitmq<br/>mgmt plugin"]
end
MIG["db-migrator<br/>(ABP DbMigrator)"]
AUTH["auth — AuthServer<br/>https 44345"]
API["api — HttpApi.Host<br/>https 44354"]
ADMIN["admin — Blazor<br/>https 44381"]
RT["realtime — Next.js<br/>http 4200"]
MIG -->|WaitFor| SQL
AUTH -->|WaitFor| REDIS & RMQ & SQL
AUTH -.->|WaitForCompletion| MIG
API -->|WaitFor| REDIS & RMQ & SQL
API -.->|WaitForCompletion| MIG
API -->|WaitFor| AUTH
ADMIN -->|WaitFor| AUTH & API
RT -->|WaitFor| API
The four .NET projects are registered through a local helper, AddProjectWithDefaults<TProject>,
which always sets USE_ASPIRE_CONFIG=true so each child loads its appsettings.aspire.json (see
Configuration):
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);
Frontends are auto-discovered: builder.AddAllFrontends() enumerates every subfolder of frontend/
that contains a Dockerfile, registers each as a JavaScript app, and wires NextAuth into it.
The documentation site (this MkDocs site) is also part of the graph as a container resource
(AddDockerfile("documentation", "../../docs", "Dockerfile")), exposed on 8001 locally, but it is
marked WithExplicitStart() — you must start it manually from the dashboard.
Run-mode containers (fixed names, ports and volumes)¶
In run mode the AppHost spins real containers with stable names and persistent data volumes, so
your data survives restarts. The relevant slice of Program.cs:
var dbPassword = builder.AddParameter("dbPassword", "securePassword-2026!");
builder
.AddAzureSqlServer("sqlserver")
.RunAsContainer(s =>
{
s.WithEndpoint("tcp", endpoint =>
{
endpoint.Port = 14330;
endpoint.IsProxied = false;
})
.WithDataVolume("spark-db")
.WithContainerName("spark-db")
.WithLifetime(ContainerLifetime.Persistent)
.WithPassword(dbPassword);
})
.AddDatabase("db")
.WithDefaultAzureSku();
| Resource | Container name | Port(s) | Data volume | Lifetime |
|---|---|---|---|---|
SQL Server (sqlserver/db) |
spark-db |
TCP 14330 (un-proxied) |
spark-db |
Persistent |
Redis (redis) |
spark-redis |
dynamic + Insight/Commander HTTP | spark-redis |
Persistent |
RabbitMQ (messaging) |
spark-rabbitmq |
mgmt + AMQP (dynamic) | — | Persistent |
Redis is added through AddAzureRedis(...).WithAccessKeyAuthentication().RunAsContainer(...) with
RedisInsight and RedisCommander side-cars (both external HTTP, persistent, named
spark-redis-insight / spark-redis-commander). It is deliberately wrapped in
#pragma warning disable CS0618 because AddAzureRedis is obsolete — the code comment explains
"Switching to AzureManagedRedis increases costs", so do not "fix" the warning. RabbitMQ is added
with AddRabbitMQ("messaging", password: unsecurePassword).WithManagementPlugin(). The shared cache
and messaging concepts are covered in Caching and
Messaging.
The fixed local HTTPS ports for the .NET hosts (and the realtime frontend's HTTP port) come from
src/Cargonerds.AppHost/RunModeServicePorts.cs:
public const int AuthServerHttpsPort = 44345;
public const int ApiHttpsPort = 44354;
public const int AdminHttpsPort = 44381;
public const int RealtimeHttpPort = 4200;
public const int DbPort = 53938;
Fixed ports can collide
These ports (44345 / 44354 / 44381 / 4200) are hard-coded, and the SQL container's TCP
endpoint 14330 is un-proxied (IsProxied = false). A stray local SQL Server on 14330,
or any other process already bound to one of these ports, will conflict with the AppHost.
Speeding up local starts¶
Two environment variables, read once in Program.cs via EnvVars, let you skip the dependency
waits while iterating:
| Env var | Effect when =1 |
|---|---|
SKIP_WAITING_IN_BACKEND |
Auth/Api/Admin no longer WaitFor/WaitForCompletion their dependencies (incl. the migrator and health checks) |
EXPLICIT_FRONTEND_START |
Frontends require a manual start from the dashboard instead of auto-starting |
The wait helpers are null-tolerant, so partially-wired graphs still start. See Debugging for how to attach to an individual service.
Pointing the local AppHost at a real Azure DB¶
A special run mode lets the local AppHost target an Azure Spark database (or a renamed copy of
it) instead of spinning up the SQL container. It is governed by SparkDbRunModeConfiguration.Create
and only activates in run mode when APPHOST_AZURE_ENVIRONMENT is set:
if (azureEnvironment.StartsWith("prod.", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(azureDatabaseCopySuffix))
{
throw new InvalidOperationException(
$"Azure environment '{azureEnvironment}' requires {EnvVars.AzureDatabaseCopySuffixName}.");
}
When APPHOST_AZURE_DATABASE_COPY_SUFFIX is supplied, the suffix is appended to the
ConfigurationParameters:spark-db-name, so you work against a copy. ValidateMigrationsOnly is set
to true exactly when no suffix is given (you are pointed at a shared DB, so the migrator may
only validate, never apply, migrations).
Never run local code against the production database
The guard above makes a prod.* Azure environment require the copy-suffix, so you cannot
accidentally run local code against the real prod DB. Treat APPHOST_AZURE_ENVIRONMENT (the
host selector) as a sharp tool. Note it is a different key from AZURE_ENVIRONMENT (the
child selector — see the Deployment Configuration page).
2. Docker Compose (containerised, no Aspire)¶
For a containerised run that does not involve Aspire, the repo ships a Compose setup under
etc/docker-compose/. This builds and runs the four .NET hosts as production-style images against an
Azure SQL Edge + Redis pair.
pwsh etc/docker-compose/build-images-locally.ps1 # build images first (tag: latest)
pwsh etc/docker-compose/run-docker.ps1 # or run-docker.sh on Linux/macOS
pwsh etc/docker-compose/stop-docker.ps1 # docker-compose down
build-images-locally.ps1 runs dotnet publish -c Release for DbMigrator, Blazor, HttpApi.Host,
Web.Public and AuthServer, then docker build -f Dockerfile.local for each (Blazor publishes with
-p:PublishTrimmed=false). run-docker.ps1 first generates a dev HTTPS certificate if one is
missing, then brings the stack up detached:
dotnet dev-certs https -v -ep localhost.pfx -p 77e14de6-8f93-449d-877e-5cd58bd055b1 -t
# ...
docker-compose up -d
docker-compose.yml defines the full stack on an isolated cargonerds-network bridge:
| Service | Image | Host port → container | Notes |
|---|---|---|---|
cargonerds-authserver |
cargonerds-authserver:latest |
44345 → 8081 |
HTTPS via mounted localhost.pfx |
cargonerds-api |
cargonerds-api:latest |
44354 → 8081 |
depends_on SQL + Redis (healthy) |
cargonerds-blazor |
cargonerds-blazor:latest |
44381 → 8081 |
depends_on api |
cargonerds-web-public |
cargonerds-web-public:latest |
44332 → 8081 |
|
db-migrator |
cargonerds-db-migrator:latest |
— | seeds OpenIddict client RootUrls |
sql-server |
mcr.microsoft.com/azure-sql-edge:1.0.7 |
1434 → 1433 |
healthcheck via sqlcmd |
redis |
redis:alpine |
6379 → 6379 |
healthcheck via redis-cli ping |
Configuration here is supplied inline as environment variables in the Compose file (not via the
Aspire/Spark layering). Each host gets ASPNETCORE_URLS=https://+:8081;http://+:8080;, a Kestrel
cert path/password, an App__SelfUrl, an AuthServer__Authority, a ConnectionStrings__Default
pointing at the sql-server container, and Redis__Configuration=redis. The Blazor SPA also bind-
mounts etc/docker-compose/appsettings.json over its served appsettings.json so the WASM client
knows the auth/remote URLs:
{
"App": { "SelfUrl": "http://localhost:44381" },
"AuthServer": { "Authority": "https://localhost:44345", "ClientId": "Cargonerds_Blazor", "ResponseType": "code" },
"RemoteServices": {
"Default": { "BaseUrl": "https://localhost:44354" },
"AbpAccountPublic": { "BaseUrl": "https://localhost:44345" }
}
}
See etc/docker-compose/README.md for the build/run details.
Runtime images target .NET 9; the solution is .NET 10
The src/**/Dockerfile and Dockerfile.local images build on
mcr.microsoft.com/dotnet/aspnet:9.0 even though the solution targets net10.0. The Compose
path is the one most affected by this mismatch — verify image behaviour before relying on it.
Compose is local-only
The Compose stack (and the Helm chart, below) is for local development. CI/CD never uses it; the
cloud paths are described in Deployment Overview. The Compose SA password
(myPassw0rd) and Kestrel cert password are static, checked-in dev values.
Infrastructure-only (run one host from your IDE)¶
If you only want to run/debug a single .NET host from your IDE while using containerised
dependencies, start just the infra from etc/docker:
up.ps1 creates an external cargonerds Docker network and brings up two containers from
etc/docker/containers/:
| Container | Image | Ports |
|---|---|---|
redis |
redis:7.2.2-alpine |
6379:6379 |
rabbitmq |
rabbitmq:3.12.7-management-alpine |
5672:5672, 15672:15672 (mgmt UI) |
These are the dependencies ABP Studio's Solution Runner also configures, so if you launch from ABP
Studio you do not need to run them by hand (etc/docker/README.md). Note this set does not
include a database — run the DbMigrator once and point your host at a reachable Default DB
yourself. Then start the host(s) you need; see Debugging.
Local Kubernetes (Helm)¶
There is also an ABP-Studio-generated umbrella Helm chart under etc/helm/cargonerds/ for a local
Kubernetes cluster (Docker Desktop with Kubernetes enabled). It uses mkcert for TLS, requires
hosts-file entries, and is documented separately in Helm and etc/helm/README.md. Like
Compose, it is local-only and not used by CI.
3. Configuration: how each process is wired¶
Local configuration is layered. Every host begins with the standard
appsettings.json + appsettings.{Environment}.json chain, then AddServiceDefaults() (the first
builder step in each host's Program.cs) layers on extra files and two token-substituting
configuration sources. The full model — the Spark vs. Azure axes, both token engines, and precedence
— lives in the Configuration Reference and the
Deployment Configuration page; this section covers what matters locally.
The layering is implemented in src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs:
if (builder.Configuration.GetValue("USE_ASPIRE_CONFIG", false))
builder.Configuration.AddJsonFile("appsettings.aspire.json", optional: true);
var azureEnvironment = builder.Configuration.GetAzureEnvironment(); // AZURE_ENVIRONMENT
if (!string.IsNullOrWhiteSpace(azureEnvironment))
builder.Configuration.AddAzureJsonFiles(azureEnvironment);
var whiteLabelSettingsPath =
builder.Configuration.GetValue<string?>("WHITE_LABEL_SETTINGS_PATH") ?? "appsettings.rohlig.json";
builder.Configuration.AddJsonFile(whiteLabelSettingsPath, optional: true);
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);
Service discovery via {token} substitution¶
Under the AppHost, services find each other through Aspire service discovery. The producer side
is Program.cs, which sets services__{name}__https__0 env vars on each project via
WithServiceReference. The consumer side is the committed appsettings.aspire.json in each host —
for example src/Cargonerds.HttpApi.Host/appsettings.aspire.json:
{
"App": { "SelfUrl": "{api}", "CorsOrigins": "{realtime},{admin}", "Realtime": "{realtime}" },
"AuthServer": { "Authority": "{auth}", "WellKnownConfigAddress": "{auth}/.well-known/openid-configuration" },
"Redis": { "Configuration": "{redis}" },
"RabbitMQ":{ "Connections": { "Default": { "HostName": "{messaging}", "Override": true } } }
}
At child startup the ServiceDiscoveryConfigurationProvider rewrites each {name} to the first of
services:{name}:https:0 → services:{name}:http:0 → ConnectionStrings:{name}. If none exist it
throws an InvalidOperationException that lists the available services, which makes
misconfiguration obvious.
USE_ASPIRE_CONFIG is mandatory under the AppHost
EnvVars.UseAspireConfig documents: "If not set the default ABP configuration will be used
which is not compatible with the Aspire AppHost." The AppHost sets it for all four
AddProjectWithDefaults projects. If you run a host standalone (IIS Express / bare
dotnet run), you must set it yourself — otherwise the {token} files never load and the host
falls back to its static appsettings.json (the LocalDB connection string).
Add a token, wire the resource
Token resolution is fail-fast at child startup. Adding a {newService} token to any
appsettings.aspire.json without wiring that resource in the AppHost breaks startup, not just
that one feature.
White-label settings path¶
ABP white-labeling (branding, Entra logins, external API keys) lives in appsettings.rohlig.json.
There is exactly one editable copy, in the AppHost project; it is linked into ServiceDefaults and
copied to every host's output. Because Aspire runs each child process with its project directory
as the working directory (not the build output), the AppHost forwards the absolute path via
WHITE_LABEL_SETTINGS_PATH (WithWhiteLabelSettingsPath(), run mode only). See
Theming & White-Labeling.
Editing a copy under bin/ has no effect
Only the AppHost's appsettings.rohlig.json (and the TestBase copy) are real source. The others
are build artifacts that get overwritten on the next build.
4. Secrets locally¶
This solution does not use Azure Key Vault. The secret story locally is:
- Static, checked-in dev passwords. The AppHost declares the internal-service password
(
CargonerdsConsts.InternalServicePassword = "UnsecurePassword", used for Redis + RabbitMQ) and the SQL container password (builder.AddParameter("dbPassword", "securePassword-2026!")) inline. The Compose stack usesmyPassw0rd(SA) and a fixed Kestrel cert passphrase. These are deliberate local dev values. - ABP user-secrets file. Each host calls ABP's
AddAppSettingsSecretsJson(), which addsappsettings.secrets.json; in this repo that file holds only theAbpLicenseCode. - Per-developer Spark overrides. A git-ignored
appsettings.spark.local.user.jsoncan override thelocalSpark settings (e.g. container-mapped ports). This is the "spark.local.user.json trick" used by the integration tests.
Real cloud secrets are committed in this repo
src/Cargonerds.ServiceDefaults/appsettings.azure.*.json, the Spark files, and
appsettings.rohlig.json contain real SQL/Redis/RabbitMQ credentials, an Entra client
secret, third-party API keys, the StringEncryption:DefaultPassPhrase, and the OpenIddict
certificate passphrase. Any rotated value must not be re-committed. This is the same class
of exposure already tracked for the hard-coded AI key. Treat the repository as containing live
secrets and rotate accordingly.
Do not point local code at production
Never set AZURE_ENVIRONMENT=prod (or any prod.*) on a local machine unless you are certain —
your process will connect to production data. For the local AppHost's Azure-DB mode the
prod.* guard forces a database copy-suffix; for plain child config there is no such guard, so
prefer dev or prod.staging (the read-only Spark user) when investigating.
Gotchas¶
- Docker must be running before
dotnet run --project src/Cargonerds.AppHost(EnsureDockerRunningIfLocalDebug). - Fixed ports collide.
44345/44354/44381/4200are hard-coded and the SQL container's14330is un-proxied — kill anything already bound there. USE_ASPIRE_CONFIGis required for standalone runs of a host, or it loads the wrong (non- Aspire) config and uses the LocalDB fallback connection string.- Service-discovery tokens fail fast at startup if the matching resource is not wired in
Program.cs. - DbMigrator does not use
AddServiceDefaults()— it re-implements config layering itself with an extraazure.migratorprefix, so its pipeline can drift from the shared one. See Migrations. - Compose/Helm/local-K8s are local-only and build .NET 9 runtime images while the solution is .NET 10.
- The documentation container is
WithExplicitStart()— it will not run until you start it from the Aspire dashboard. - Filename typo: the AppHost config helper is
ConfigruationExtensions.cs(sic) — search for the misspelling when grepping.
See also¶
- Running Locally — quick start, ports, default credentials.
- Aspire Integration — the AppHost / ServiceDefaults design.
- Deployment Configuration and Configuration Reference — full layering, token engines, the two environment axes.
- Deployment Overview — how the cloud paths differ from local.
- Helm — local Kubernetes install.
- Debugging — attaching to a single service.
- Caching and Messaging — the Redis and RabbitMQ resources.
- Migrations — the DbMigrator orchestration.