Back to blog
Backend Systemsintermediate

Orchestrating .NET Multi-Service Apps: Manual Setup vs .NET Aspire vs Docker Compose

Picking the wrong orchestration approach for a .NET multi-service app costs hours per week in config drift, broken connection strings, and missing observability. This guide covers all three approaches in depth β€” with architecture diagrams, real code, and a decision framework.

LearnixoApril 19, 202617 min read
.NETC#ASP.NET Core.NET AspireDockerDocker ComposeDevExMicroservicesObservability
Share:𝕏

Running a single API against a single database is simple. Add a second service. Add a cache. Add a message broker. Add a worker. Suddenly you have five processes that need to find each other, share secrets, emit consistent telemetry, and start in the right order.

That coordination problem is what orchestration solves.

There are three mainstream approaches for .NET teams. This guide explains each one at depth β€” what it does internally, what it costs you, and the exact conditions that make it the right call.


The Problem Space

Before comparing tools, let's be precise about what we're orchestrating:

A typical .NET distributed system at development time:

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  What needs to run:                                              β”‚
  β”‚                                                                  β”‚
  β”‚  Process 1: Web API          β†’ localhost:5001                   β”‚
  β”‚  Process 2: Worker Service   β†’ background, no port              β”‚
  β”‚  Process 3: Blazor Frontend  β†’ localhost:5002                   β”‚
  β”‚                                                                  β”‚
  β”‚  Infrastructure:                                                 β”‚
  β”‚  PostgreSQL                  β†’ localhost:5432                   β”‚
  β”‚  Redis                       β†’ localhost:6379                   β”‚
  β”‚  RabbitMQ                    β†’ localhost:5672 (amqp)            β”‚
  β”‚                              β†’ localhost:15672 (management UI)  β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  What each service needs to know:
  - Where is PostgreSQL? (host, port, username, password, database name)
  - Where is Redis?
  - Where is RabbitMQ?
  - Where are the other services? (for service-to-service HTTP calls)

  What can go wrong:
  - Developer A has Postgres on port 5432, Developer B has it on 5433
  - Config differs between dev machines β†’ "works on my machine"
  - Service starts before its dependency is ready β†’ crash on startup
  - No unified logs β†’ debugging requires 5 terminal windows
  - No health checks β†’ a crashed worker isn't noticed until orders are lost

Orchestration answers: how do all these pieces find each other, and who starts them?


Approach 1: Manual Setup

The manual approach means each service reads its own appsettings.json, each developer manages their own local infrastructure, and nothing coordinates startup order.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Manual Setup β€” each service manages itself                      β”‚
β”‚                                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚   Web API        β”‚   β”‚  Worker Service  β”‚   β”‚    Blazor     β”‚  β”‚
β”‚  β”‚                  β”‚   β”‚                  β”‚   β”‚               β”‚  β”‚
β”‚  β”‚ appsettings.json β”‚   β”‚ appsettings.json β”‚   β”‚appsettings    β”‚  β”‚
β”‚  β”‚ (its own config) β”‚   β”‚ (its own config) β”‚   β”‚.json          β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚           β”‚                      β”‚                      β”‚          β”‚
β”‚           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
β”‚                          β”‚                                          β”‚
β”‚                          β–Ό                                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚  Local infrastructure β€” started manually by each developer    β”‚ β”‚
β”‚  β”‚  PostgreSQL  Redis  RabbitMQ                                  β”‚ β”‚
β”‚  β”‚  (brew services start / docker run / installed locally)       β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

What It Looks Like

Each service has its own appsettings.Development.json:

JSON
// WebApi/appsettings.Development.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Port=5432;Database=myapp;Username=postgres;Password=dev"
  },
  "Redis": {
    "ConnectionString": "localhost:6379"
  },
  "RabbitMq": {
    "Host": "localhost",
    "Port": 5672,
    "Username": "guest",
    "Password": "guest"
  },
  "Services": {
    "WorkerBaseUrl": "http://localhost:5003"
  }
}
JSON
// Worker/appsettings.Development.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Port=5432;Database=myapp;Username=postgres;Password=dev"
  },
  "RabbitMq": {
    "Host": "localhost",
    "Port": 5672,
    "Username": "guest",
    "Password": "guest"
  }
  // RabbitMQ config duplicated here β€” must stay in sync manually
}

The README.md becomes the orchestrator:

## Getting started

1. Install PostgreSQL 16 locally (or run: docker run -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16)
2. Install Redis (or run: docker run -p 6379:6379 redis:alpine)
3. Install RabbitMQ (or run: docker run -p 5672:5672 -p 15672:15672 rabbitmq:3-management)
4. cd src/WebApi && dotnet run
5. cd src/Worker && dotnet run
6. cd src/Blazor && dotnet run

If the API crashes on startup, check that Postgres is running and the connection string matches.

The Config Drift Problem

  Week 1:
    Developer A: Postgres password = "dev"
    Developer B: Postgres password = "dev"   ← in sync

  Week 6 (after a security review):
    Developer A updates password to "dev_secure_2026" in their appsettings
    Developer B is on holiday
    Developer B returns β†’ Worker crashes with auth failure
    Developer B checks the API config β†’ "oh there's a different password now"
    
  Week 12:
    WebApi appsettings.Development.json:
      RabbitMq port: 5672
    Worker appsettings.Development.json:
      RabbitMq port: 5673  ← someone changed this to avoid a conflict
    Worker can't connect to API's RabbitMQ instance
    Debugging takes 45 minutes

  This is config drift. It's the real cost of manual setup at scale.

When Manual Setup Is Right

βœ… Single API + one database
βœ… Team of 1–2 developers who own every config file
βœ… No shared infrastructure between services
βœ… Greenfield project where you want zero overhead

❌ 2+ services sharing databases, caches, or brokers
❌ Team of 3+ developers (config drift becomes inevitable)
❌ New developers joining (onboarding becomes a config debugging session)

Approach 2: Docker Compose

Docker Compose defines your entire environment in a single YAML file. Every service, every infrastructure component, every network and volume β€” one source of truth, run with one command.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Docker Compose β€” YAML describes the entire environment          β”‚
β”‚                                                                  β”‚
β”‚  docker-compose.yml                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  services:                                               β”‚   β”‚
β”‚  β”‚    api:    image or build context                        β”‚   β”‚
β”‚  β”‚    worker: image or build context                        β”‚   β”‚
β”‚  β”‚    web:    image or build context                        β”‚   β”‚
β”‚  β”‚    postgres: image: postgres:16                          β”‚   β”‚
β”‚  β”‚    redis:    image: redis:alpine                         β”‚   β”‚
β”‚  β”‚    rabbitmq: image: rabbitmq:3-management                β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚            β”‚                                                     β”‚
β”‚            β–Ό docker compose up                                   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  Docker network: myapp_default                           β”‚   β”‚
β”‚  β”‚                                                          β”‚   β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”               β”‚   β”‚
β”‚  β”‚  β”‚   api    β”‚  β”‚  worker  β”‚  β”‚   web    β”‚               β”‚   β”‚
β”‚  β”‚  β”‚ :8080    β”‚  β”‚          β”‚  β”‚ :3000    β”‚               β”‚   β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜               β”‚   β”‚
β”‚  β”‚       β”‚             β”‚             β”‚                       β”‚   β”‚
β”‚  β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                       β”‚   β”‚
β”‚  β”‚                     β”‚                                     β”‚   β”‚
β”‚  β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”‚   β”‚
β”‚  β”‚       β–Ό             β–Ό             β–Ό                       β”‚   β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                β”‚   β”‚
β”‚  β”‚  β”‚postgres β”‚  β”‚  redis   β”‚  β”‚ rabbitmq β”‚                β”‚   β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                  β”‚
β”‚  Services reach each other by container name:                    β”‚
β”‚  api β†’ "http://worker:80" (not localhost)                        β”‚
β”‚  api β†’ "Host=postgres;Port=5432;..."                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The docker-compose.yml

YAML
# docker-compose.yml
version: "3.9"

services:
  api:
    build:
      context: ./src/WebApi
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=myapp;Username=postgres;Password=dev
      - Redis__ConnectionString=redis:6379
      - RabbitMq__Host=rabbitmq
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
      rabbitmq:
        condition: service_healthy
    networks:
      - myapp

  worker:
    build:
      context: ./src/Worker
      dockerfile: Dockerfile
    environment:
      - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=myapp;Username=postgres;Password=dev
      - RabbitMq__Host=rabbitmq
    depends_on:
      postgres:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    networks:
      - myapp

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: dev
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - myapp

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    networks:
      - myapp

  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    environment:
      RABBITMQ_DEFAULT_USER: guest
      RABBITMQ_DEFAULT_PASS: guest
    ports:
      - "15672:15672"  # management UI accessible from host
    healthcheck:
      test: rabbitmq-diagnostics -q ping
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - myapp

volumes:
  postgres_data:
  redis_data:

networks:
  myapp:
    driver: bridge

What Docker Compose Solves

Config drift:        βœ… Fixed β€” one file, all config in one place
Startup order:       βœ… Fixed β€” depends_on with health checks
Network discovery:   βœ… Fixed β€” services reach each other by name
Onboarding:          βœ… Fixed β€” new dev runs docker compose up, done
Polyglot support:    βœ… Fixed β€” Node.js, Python, .NET, Go β€” doesn't care

What Docker Compose Doesn't Solve

Inner loop speed:    ❌ Every code change requires docker build β†’ slow
                     (unless you mount source + use dotnet watch inside container)

Observability:       ❌ No built-in tracing, metrics, or log aggregation
                     You need to add Prometheus + Grafana + Jaeger manually

.NET integration:    ❌ YAML doesn't know about .NET projects
                     Ports, connection strings β€” all manual

Debugging:           ❌ Attaching a debugger to a container requires
                     additional configuration (VSDBG, SSH tunneling, etc.)

The Dockerfile Every .NET Service Needs

DOCKERFILE
# Dockerfile β€” multi-stage build for minimal image size
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# Copy project files first β€” layer cache optimization
COPY *.csproj .
RUN dotnet restore

# Copy everything else and publish
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

# Non-root user β€” security best practice
RUN adduser --disabled-password --gecos "" appuser
USER appuser

EXPOSE 8080
ENTRYPOINT ["dotnet", "WebApi.dll"]

When Docker Compose Is Right

βœ… Polyglot stack: .NET API + Node.js frontend + Python ML service
βœ… Need production-like environment locally
βœ… CI/CD pipelines: docker compose up for integration tests
βœ… Infrastructure-heavy: multiple databases, caches, brokers
βœ… Team uses multiple languages across services

❌ Pure .NET shop with fast inner loop requirement
❌ Team wants C# config instead of YAML
❌ Need built-in observability without manual wiring

Approach 3: .NET Aspire

.NET Aspire is Microsoft's answer to the orchestration problem specifically for .NET-heavy solutions. It replaces YAML with C# and replaces manual connection string wiring with automatic service discovery.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  .NET Aspire β€” AppHost orchestrates everything in C#             β”‚
β”‚                                                                  β”‚
β”‚  AppHost (C# project)                                            β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  var postgres  = builder.AddPostgres("postgres")         β”‚   β”‚
β”‚  β”‚               .AddDatabase("myapp");                     β”‚   β”‚
β”‚  β”‚                                                          β”‚   β”‚
β”‚  β”‚  var redis     = builder.AddRedis("redis");              β”‚   β”‚
β”‚  β”‚                                                          β”‚   β”‚
β”‚  β”‚  var rabbitmq  = builder.AddRabbitMQ("rabbitmq");        β”‚   β”‚
β”‚  β”‚                                                          β”‚   β”‚
β”‚  β”‚  var api       = builder.AddProject()   β”‚   β”‚
β”‚  β”‚               .WithReference(postgres)                   β”‚   β”‚
β”‚  β”‚               .WithReference(redis)                      β”‚   β”‚
β”‚  β”‚               .WithReference(rabbitmq);                  β”‚   β”‚
β”‚  β”‚                                                          β”‚   β”‚
β”‚  β”‚  var worker    = builder.AddProject()   β”‚   β”‚
β”‚  β”‚               .WithReference(postgres)                   β”‚   β”‚
β”‚  β”‚               .WithReference(rabbitmq);                  β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚            β”‚                                                     β”‚
β”‚            β–Ό dotnet run (AppHost)                                β”‚
β”‚                                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  Aspire Dashboard  :18888                                β”‚   β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚   β”‚
β”‚  β”‚  β”‚  Resources    Logs    Traces    Metrics           β”‚   β”‚   β”‚
β”‚  β”‚  β”‚                                                   β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  ● api     running  :5001  view logs / traces     β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  ● worker  running         view logs / traces     β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  ● postgres running :5432                         β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  ● redis   running  :6379                         β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  ● rabbitmq running :5672  management: :15672     β”‚   β”‚   β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                  β”‚
β”‚  .NET projects run as processes (no Docker for your code)        β”‚
β”‚  Infrastructure runs as Docker containers (Postgres, Redis, etc) β”‚
β”‚  Connection strings auto-injected via environment variables      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

AppHost: The C# Orchestrator

C#
// AppHost/Program.cs β€” the entire dev environment in C#
var builder = DistributedApplication.CreateBuilder(args);

// Infrastructure resources β€” run as Docker containers automatically
var postgres = builder.AddPostgres("postgres")
    .WithPgAdmin()                           // optional: pgAdmin UI
    .AddDatabase("myapp");

var redis = builder.AddRedis("redis")
    .WithRedisInsight();                     // optional: RedisInsight UI

var rabbitmq = builder.AddRabbitMQ("rabbitmq")
    .WithManagementPlugin();                 // RabbitMQ management UI

// .NET projects β€” run as processes, not containers
var worker = builder.AddProject<Projects.Worker>("worker")
    .WithReference(postgres)                 // injects connection string
    .WithReference(rabbitmq);               // injects RabbitMQ config

var api = builder.AddProject<Projects.WebApi>("api")
    .WithReference(postgres)
    .WithReference(redis)
    .WithReference(rabbitmq)
    .WithReference(worker);                  // injects worker's URL for service-to-service calls

builder.AddProject<Projects.Blazor>("web")
    .WithReference(api);                     // Blazor knows where the API is

builder.Build().Run();

What "WithReference" Actually Does

This is the part that saves the most time. When you call .WithReference(postgres), Aspire injects a connection string as an environment variable into the target project at runtime:

Without Aspire:
  Developer must manually write:
    "Host=localhost;Port=5432;Database=myapp;Username=postgres;Password=???"
  And update it in every service that uses Postgres.
  And keep it in sync across the team.

With Aspire + WithReference:
  Aspire generates the connection string automatically.
  Injects it as: ConnectionStrings__myapp=Host=localhost;Port=5432;...
  Your service reads it with the standard IConfiguration pattern.
  It's always correct. No manual sync needed.
C#
// WebApi/Program.cs β€” service doesn't know or care where Postgres lives
// It just reads the connection string Aspire injected
builder.AddNpgsqlDbContext<AppDbContext>("myapp"); // "myapp" matches the database name in AppHost

// Aspire sets this automatically:
// ConnectionStrings__myapp = Host=localhost;Port=65432;Database=myapp;Username=postgres;Password=<generated>
// Port is randomly assigned β€” no port conflicts between developers

Service Discovery

C#
// Worker needs to call the API. Without Aspire:
// β†’ hardcode "http://localhost:5001" in appsettings
// β†’ breaks when the API moves or the port changes

// With Aspire:
// AppHost: .WithReference(api) injects the API's URL
// Worker/Program.cs:
builder.Services.AddHttpClient<IOrderApiClient, OrderApiClient>(
    static client => client.BaseAddress = new Uri("http://api")); // "api" = the resource name
// Aspire resolves "http://api" to the actual running address at runtime

The Built-In Dashboard

When you run dotnet run in the AppHost project, the Aspire dashboard starts automatically at http://localhost:18888:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Aspire Dashboard                                                β”‚
β”‚                                                                  β”‚
β”‚  Resources tab:                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚ Name      Type      State    Endpoints        Logs        β”‚   β”‚
β”‚  β”‚ ─────────────────────────────────────────────────────     β”‚   β”‚
β”‚  β”‚ api       Project   Running  http://+:5001    [view]      β”‚   β”‚
β”‚  β”‚ worker    Project   Running  (none)            [view]     β”‚   β”‚
β”‚  β”‚ web       Project   Running  http://+:5002    [view]      β”‚   β”‚
β”‚  β”‚ postgres  Container Running  localhost:65432   [view]     β”‚   β”‚
β”‚  β”‚ redis     Container Running  localhost:65379   [view]     β”‚   β”‚
β”‚  β”‚ rabbitmq  Container Running  localhost:65672   [view]     β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                  β”‚
β”‚  Traces tab (OpenTelemetry β€” built-in):                         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚ TraceId         Service   Duration   Spans               β”‚   β”‚
β”‚  β”‚ ─────────────────────────────────────────────────────     β”‚   β”‚
β”‚  β”‚ abc123...       api       142ms      5 spans  [view]     β”‚   β”‚
β”‚  β”‚   └─ GET /orders           api        1ms                β”‚   β”‚
β”‚  β”‚      └─ SELECT orders      postgres   38ms               β”‚   β”‚
β”‚  β”‚      └─ GET cache          redis      2ms                β”‚   β”‚
β”‚  β”‚      └─ publish event      rabbitmq   4ms                β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                  β”‚
β”‚  Logs tab: structured logs from all services, unified           β”‚
β”‚  Metrics tab: request rates, durations, DB pool stats           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

This is OpenTelemetry wired up automatically β€” distributed traces that show exactly which service called what, how long each leg took, and where slowdowns are. No Jaeger to install. No Prometheus to configure. It's there from day one.

Adding Aspire to an Existing Solution

Bash
# Install the workload
dotnet workload install aspire

# Add the AppHost project to your existing solution
dotnet new aspire-apphost -n AppHost -o src/AppHost
dotnet sln add src/AppHost/AppHost.csproj

# Add the ServiceDefaults project (shared OpenTelemetry config, health checks)
dotnet new aspire-servicedefaults -n ServiceDefaults -o src/ServiceDefaults
dotnet sln add src/ServiceDefaults/ServiceDefaults.csproj

# Reference ServiceDefaults from each service
# Then add one line to each service's Program.cs:
builder.AddServiceDefaults(); // wires up health checks, OpenTelemetry, service discovery

What ServiceDefaults Wires Up Automatically

C#
// ServiceDefaults/Extensions.cs (generated)
public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
{
    builder.ConfigureOpenTelemetry();        // tracing + metrics β†’ Aspire dashboard
    builder.AddDefaultHealthChecks();        // /health and /alive endpoints
    builder.Services.AddServiceDiscovery();  // resolves "http://api" to actual address
    // ...
    return builder;
}

One line in each service. All observability infrastructure configured. No per-service boilerplate.

When .NET Aspire Is Right

βœ… Pure .NET stack (Web API + Worker + Blazor + etc.)
βœ… 2+ services sharing Postgres, Redis, or a message broker
βœ… Fast inner loop matters β€” dotnet watch works, no Docker rebuild
βœ… Want distributed tracing and structured logs without manual setup
βœ… Team wants config in C# instead of YAML
βœ… Want service discovery without a service mesh

❌ Stack includes non-.NET services (Python, Node, Go)
   (Aspire can run arbitrary containers, but the DX is better for .NET projects)
❌ Need production-like containerized environment locally
   (Aspire runs .NET projects as processes β€” not the same as running in Docker)
❌ Deploying to a non-Azure environment without a custom manifest adapter

Side-by-Side Comparison

Feature                  Manual Setup    Docker Compose   .NET Aspire
──────────────────────────────────────────────────────────────────────
Config location          Per-service     docker-compose   AppHost C#
                         appsettings     YAML             project

Startup command          5 terminals     docker compose   dotnet run
                                         up               (AppHost)

Service discovery        Manual URLs     Container DNS    WithReference()
                         in appsettings  (service name)   auto-injects

Connection strings       Manually        YAML env vars    Auto-generated
                         maintained      manually         + injected

Inner loop speed         Fast            Slow             Fast
                         (native run)    (Docker build)   (native run)

Polyglot support         N/A             βœ… Any language   .NET-first
                                                          (containers ok)

Built-in observability   None            None             βœ… Dashboard,
                                                          traces, logs,
                                                          metrics

Debugging                Easy            Requires setup   Easy
                         (native)        (VSDBG etc.)     (native)

Production parity        Low             High             Medium
                                                          (.NET as procs)

Onboarding effort        High            Low              Low
                         (README-based)  (one command)    (one command)

Learning curve           None            Docker + YAML    C# + Aspire API

Team size fit            1–2 devs        Any              Any (.NET teams)

The Decision Framework

                    How many services do you have?
                              β”‚
                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  1 API + 1 DB           2+ services
                   β”‚                      β”‚
                   β–Ό                      β–Ό
            Manual setup          Is your stack
            (zero overhead)       all .NET?
                                          β”‚
                               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                              Yes                     No
                               β”‚                      β”‚
                               β–Ό                      β–Ό
                     .NET Aspire              Docker Compose

                     Unless:
                     - You need production
                       parity in containers
                     - You're deploying to
                       non-Azure with no
                       Aspire manifest support
                     β†’ then: Docker Compose

In practice:

Single API + DB           β†’ Manual setup
.NET API + Worker + Redis β†’ Aspire
.NET API + Node frontend  β†’ Docker Compose
Full microservices stack  β†’ Docker Compose (or Kubernetes for prod)
.NET services, fast DX    β†’ Aspire (switch to Compose for CI)

Using Both: Aspire for Dev, Compose for CI

A common pattern in mature .NET teams:

Development:  dotnet run (AppHost)
              β†’ Fast inner loop, native debugger, Aspire dashboard
              β†’ .NET code runs as processes, infra as Docker containers

CI/CD:        docker compose up
              β†’ Production-like containers for integration tests
              β†’ Every service in a container, exact deployment environment

Production:   Kubernetes (or Azure Container Apps)
              β†’ Aspire can export a deployment manifest:
                dotnet run --publisher manifest
              β†’ Generates docker-compose.yaml or Azure Container Apps YAML
                from your AppHost C# definitions
Bash
# Generate a deployment manifest from your AppHost
dotnet run --project src/AppHost --publisher manifest --output-path ./manifests

# Output:
# manifests/aspire-manifest.json  β†’ describes all resources and their relationships
# manifests/docker-compose.yaml  β†’ usable for Docker Compose deployments

Getting Started With Aspire

Bash
# Prerequisites
dotnet workload install aspire

# New solution with Aspire from scratch
dotnet new aspire-starter -n MyApp

# Structure created:
# MyApp.AppHost/       ← orchestrator, references all other projects
# MyApp.ServiceDefaults/ ← shared config: OpenTelemetry, health checks
# MyApp.ApiService/    ← sample Web API
# MyApp.Web/           ← sample Blazor frontend

# Run everything
dotnet run --project MyApp.AppHost

# Open dashboard
# http://localhost:18888
C#
// MyApp.AppHost/Program.cs β€” add Postgres + Redis in 3 lines
var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder.AddPostgres("postgres").AddDatabase("myappdb");
var redis = builder.AddRedis("cache");

var api = builder.AddProject<Projects.MyApp_ApiService>("api")
    .WithReference(postgres)
    .WithReference(redis);

builder.AddProject<Projects.MyApp_Web>("web")
    .WithReference(api);

builder.Build().Run();
C#
// MyApp.ApiService/Program.cs β€” two lines wires everything up
var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();                // health checks, OpenTelemetry, service discovery
builder.AddNpgsqlDbContext<AppDbContext>("myappdb"); // Aspire injects the connection string
builder.AddRedisDistributedCache("cache");   // Aspire injects Redis connection

var app = builder.Build();
app.MapDefaultEndpoints();                   // maps /health and /alive
// your routes here...
app.Run();

That's the entire setup. No appsettings.Development.json for Postgres or Redis. No connection string management. No observability wiring. One AppHost project coordinates everything.


Summary

Don't add orchestration overhead until you need it.

A single API with one database doesn't need Docker Compose or Aspire. Adding them to a solo project is complexity without benefit.

When you hit two or more .NET services that share infrastructure β€” that's when Aspire pays for itself in the first hour. Auto-wired connection strings, built-in distributed tracing, and a unified log stream across every service, all from dotnet run.

When your stack is polyglot, or when production parity in containers is non-negotiable, Docker Compose is the more appropriate tool β€” language-agnostic, production-faithful, and familiar to any developer who knows Docker.

The orchestration problem is real. Pick the tool that matches the scale of the problem you actually have.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.