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.
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 lostOrchestration 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:
// 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"
}
}// 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
# 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: bridgeWhat 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 careWhat 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 β 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 wiringApproach 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
// 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.// 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 developersService Discovery
// 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 runtimeThe 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
# 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 discoveryWhat ServiceDefaults Wires Up Automatically
// 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 adapterSide-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 ComposeIn 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# 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 deploymentsGetting Started With Aspire
# 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// 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();// 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.