Skip to content

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.

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.

dotnet run --project src/Cargonerds.AppHost

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:

pwsh etc/docker/up.ps1     # Redis + RabbitMQ
pwsh etc/docker/down.ps1

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:0services:{name}:http:0ConnectionStrings:{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 uses myPassw0rd (SA) and a fixed Kestrel cert passphrase. These are deliberate local dev values.
  • ABP user-secrets file. Each host calls ABP's AddAppSettingsSecretsJson(), which adds appsettings.secrets.json; in this repo that file holds only the AbpLicenseCode.
  • Per-developer Spark overrides. A git-ignored appsettings.spark.local.user.json can override the local Spark 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 / 4200 are hard-coded and the SQL container's 14330 is un-proxied — kill anything already bound there.
  • USE_ASPIRE_CONFIG is 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 extra azure.migrator prefix, 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