This post walks through how to orchestrate Keycloak, Platform, and Portal applications using .NET Aspire — complete with OpenTelemetry integration, configurable RUN_MODE, and a flexible multi-project structure that scales from infra-only to a full stack.
1. Setting up the Infra layer
Start by preparing your .NET and Aspire projects:
# Set SDK version
dotnet new globaljson --sdk-version 9.0.304
# Aspire orchestration projects
dotnet new aspire-apphost -n AppHost -o infra/aspire/AppHost -f net9.0
dotnet new aspire-servicedefaults -n ServiceDefaults -o infra/aspire/ServiceDefaults -f net9.0
2. Backend services
We’ll define two web APIs — Platform and Portal — both using shared authentication logic via Keycloak.
dotnet new webapi -n Platform -o services/backend/Platform -f net9.0 --use-controllers
dotnet new webapi -n Portal -o services/backend/Portal -f net9.0 --use-controllers
dotnet add services/backend/Platform/Platform.csproj reference infra/aspire/ServiceDefaults/ServiceDefaults.csproj
dotnet add services/backend/Portal/Portal.csproj reference infra/aspire/ServiceDefaults/ServiceDefaults.csproj
dotnet add infra/aspire/AppHost/AppHost.csproj reference services/backend/Platform/Platform.csproj
dotnet add infra/aspire/AppHost/AppHost.csproj reference services/backend/Portal/Portal.csproj
# Shared authentication library
mkdir -p services/backend/_shared/Common.Auth
dotnet new classlib -n Common.Auth -f net9.0 -o services/backend/_shared/Common.Auth
dotnet add services/backend/Platform/Platform.csproj reference services/backend/_shared/Common.Auth/Common.Auth.csproj
dotnet add services/backend/Portal/Portal.csproj reference services/backend/_shared/Common.Auth/Common.Auth.csproj
3. RUN_MODE: controlling what to launch
Your RUN_MODE variable defines which part of the system Aspire starts.
Examples:
| Mode | Description |
|---|---|
infra-only | Only observability + databases + Keycloak |
platform:be | Platform backend only |
platform:be+fe | Platform backend + frontend |
platform:be,portal:be+fe | Platform backend + Portal stack |
platform:be+fe,portal:be+fe | Full stack |
.env file (one level above AppHost)
RUN_MODE=platform:be+fe,portal:be
Aspire reads it automatically via:
Env.Load("../.env");
Or run inline:
RUN_MODE="infra-only" dotnet run --project infra/aspire/AppHost
4. OpenTelemetry Collector extension
To send Keycloak’s OTLP data into Aspire’s dashboard, a lightweight OpenTelemetry Collector container is added through a custom extension:
var otelCollector = builder.AddOpenTelemetryCollector("e01-otelcollector", "../../observability/collector_config.yaml");
otelCollector.WithParentRelationship(observability_group);
Each Keycloak instance uses the Java agent to emit traces and metrics directly to this collector:
-javaagent:/otel/opentelemetry-javaagent.jar
-Dotel.exporter.otlp.endpoint=http://e01-otelcollector:4318
-Dotel.service.name=keycloak
5. Keycloak setup
Keycloak runs inside a Docker container, connected to a dedicated Postgres instance and automatically configured with metrics, health checks, and realm import.
var keycloak_group = builder.AddGroup("a-keycloak");
var keycloak_db = builder.AddContainer("a01-keycloak-db", "postgres:17.6-alpine3.22")
.WithLifetime(ContainerLifetime.Persistent)
.WithEnvironment("POSTGRES_USER", Environment.GetEnvironmentVariable("KEYCLOAK_DB_USER"))
.WithEnvironment("POSTGRES_PASSWORD", Environment.GetEnvironmentVariable("KEYCLOAK_DB_PASSWORD"))
.WithEnvironment("POSTGRES_DB", Environment.GetEnvironmentVariable("KEYCLOAK_DB"))
.WithVolume("keycloak_db_data", "/var/lib/postgresql/data")
.WithEndpoint(15432, 5432, name: "port");
var keycloak = builder.AddDockerfile("a02-keycloak", "../../keycloak")
.WaitFor(keycloak_db)
.WaitFor(otelCollector)
.WithLifetime(ContainerLifetime.Persistent)
.WithContainerRuntimeArgs("--restart", "on-failure:5")
.WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", Environment.GetEnvironmentVariable("KEYCLOAK_ADMIN"))
.WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", Environment.GetEnvironmentVariable("KEYCLOAK_ADMIN_PASSWORD"))
.WithEnvironment("KC_DB", "postgres")
.WithEnvironment("KC_DB_URL_HOST", keycloak_db.Resource.Name)
.WithEnvironment("KC_DB_URL_PORT", keycloak_db.Resource.GetEndpoint("port").TargetPort.ToString())
.WithEnvironment("KC_METRICS_ENABLED", "true")
.WithEnvironment("KC_HEALTH_ENABLED", "true")
.WithContainerFiles("/otel/opentelemetry-javaagent.jar", "../../keycloak/otel/opentelemetry-javaagent.jar")
.WithContainerFiles("/opt/keycloak/data/import", "../../keycloak/realms/dev")
.WithHttpEndpoint(8080, 8080, "http")
.WithHttpEndpoint(9000, 9000, "management")
.WithArgs("start-dev", "--import-realm", "--features=scripts", "--spi-script-upload-enabled=true")
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("http://localhost:9000/health/ready");
This gives you:
- Keycloak Admin Console:
http://localhost:8080/ - Management Interface:
http://localhost:9000/
6. Conditional Platform & Portal sections
Based on RUN_MODE, Aspire conditionally launches Platform and Portal stacks.
if (platformBE || platformFE)
{
var platform_db = builder.AddContainer("b01-platform-db", "postgres:17.6-alpine3.22")
.WithEnvironment("POSTGRES_USER", Environment.GetEnvironmentVariable("PLATFORM_DB_USER"))
.WithEnvironment("POSTGRES_PASSWORD", Environment.GetEnvironmentVariable("PLATFORM_DB_PASSWORD"))
.WithEnvironment("POSTGRES_DB", Environment.GetEnvironmentVariable("PLATFORM_DB"))
.WithVolume("platform_db_data", "/var/lib/postgresql/data");
var platform_be = builder.AddProject<Projects.Platform>("b02-platform-be")
.WaitFor(platform_db)
.WithEnvironment("Auth__KeycloakBase", Environment.GetEnvironmentVariable("KEYCLOAK_BASE_URL"))
.WithExternalHttpEndpoints();
if (platformFE)
AddFrontend("b03-platform-fe", "platform", 4300);
}
Each section uses a similar pattern for the Portal stack, ensuring clean separation and optional startup.
7. Running the environment
Example .env
RUN_MODE=platform:be+fe,portal:be
KEYCLOAK_BASE_URL=http://localhost:8080
KEYCLOAK_MANAGEMENT_URL=http://localhost:9000
KEYCLOAK_DB_USER=keycloak
KEYCLOAK_DB_PASSWORD=keycloak
KEYCLOAK_DB=keycloak
PLATFORM_DB_USER=platform
PLATFORM_DB_PASSWORD=platform
PLATFORM_DB=platform
PORTAL_DB_USER=portal
PORTAL_DB_PASSWORD=portal
PORTAL_DB=portal
Run it:
dotnet run --project infra/aspire/AppHost
Access:
- 🧭 Aspire Dashboard – Observability & service topology
- 🗝️ Keycloak Admin Console – http://localhost:8080
- ⚙️ Management UI – http://localhost:9000
- 💬 Platform FE (if enabled) – http://localhost:4300
- 💬 Portal FE (if enabled) – http://localhost:4400
8. Publishing to Docker Compose
aspire publish -o docker-compose-artifacts
The generated docker-compose.yaml will include:
- Aspire dashboard
- Keycloak + Postgres
- OTEL Collector
- Platform & Portal stacks (based on RUN_MODE)
You can run it directly:
cd docker-compose-artifacts
docker compose up -d
9. Why this architecture works
✅ Dynamic composition — RUN_MODE allows instant toggling between minimal infra and full environments.
✅ Observability-first — Keycloak telemetry flows to Aspire via the embedded OTEL Collector.
✅ Group-based clarity — Keycloak, Platform, and Portal are isolated in logical groups.
✅ Self-contained orchestration — no external dependencies; one command brings it all up.
This pattern is ideal for enterprise authentication and app orchestration — combining Keycloak, .NET Aspire, and OpenTelemetry into a clean, modular, and observable environment for both local and published deployments.
That’s all folks!
Cheers!
Gašper Rupnik
{End.}

Leave a comment