Running Keycloak with Observability and Multi-App Orchestration in .NET Aspire

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:

ModeDescription
infra-onlyOnly observability + databases + Keycloak
platform:bePlatform backend only
platform:be+fePlatform backend + frontend
platform:be,portal:be+fePlatform backend + Portal stack
platform:be+fe,portal:be+feFull 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:

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 compositionRUN_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.

[Source code]

That’s all folks!

Cheers!
Gašper Rupnik

{End.}

Leave a comment

Website Powered by WordPress.com.

Up ↑