Skip to main content

Microservices with Docker Compose

In this tutorial you will build a three-service microservices application that uses Azure Service Bus for messaging, Blob Storage for file persistence, and Key Vault for secrets — all backed by a single Topaz container in Docker Compose. No cloud subscription is needed, and a new team member can onboard with a single docker compose up.

This is the workflow shown in the Microservices use case.

What you will build

┌──────────────────────────────────────────────────────────┐
│ docker-compose.yml │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ api-gateway │ │ order-service│ │notify-service│ │
│ └──────┬──────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────┼──────────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ topaz (single image) │ │
│ │ • Service Bus │ │
│ │ • Blob Storage │ │
│ │ • Key Vault │ │
│ └────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
  • api-gateway — receives HTTP requests and publishes order events to Service Bus
  • order-service — consumes events, stores order documents in Blob Storage
  • notify-service — consumes events, reads an API key from Key Vault, sends a notification

Prerequisites

  • Docker Desktop (or Docker Engine + Compose plugin)
  • .NET 10 SDK
  • Azure CLI (for local provisioning)

Project structure

compose-demo/
cloud.json # Download from the Topaz repo (one-time)
docker-compose.yml
init/
provision.sh # Provisions Topaz resources on startup
src/
ApiGateway/
OrderService/
NotifyService/
Shared/

Step 1: Write the Docker Compose file

# docker-compose.yml
services:

# ── Topaz (single Azure emulator container) ──────────────────────
topaz:
image: thecloudtheory/topaz-host:latest
container_name: topaz.local.dev
hostname: topaz.local.dev
ports:
- "8891:8891" # Blob Storage
- "8889:8889" # Service Bus (plain AMQP)
- "5671:5671" # Service Bus (TLS AMQP)
- "8898:8898" # Key Vault
- "8899:8899" # ARM / Resource Manager + auth
command:
- --default-subscription
- 00000000-0000-0000-0000-000000000001
- --log-level
- Warning
volumes:
- topaz-data:/app/data # Persist state across restarts
healthcheck:
test: ["CMD", "curl", "-sk", "https://localhost:8899/"]
interval: 5s
timeout: 5s
retries: 10

# ── Provisioner (runs once, then exits) ──────────────────────────
provisioner:
image: mcr.microsoft.com/azure-cli:latest
depends_on:
topaz:
condition: service_healthy
volumes:
- ./init:/init
- ./cloud.json:/cloud.json
command: bash /init/provision.sh
environment:
AZURE_CORE_INSTANCE_DISCOVERY: "false"

# ── Application services ─────────────────────────────────────────
api-gateway:
build: ./src/ApiGateway
depends_on:
provisioner:
condition: service_completed_successfully
environment:
SERVICE_BUS_CONNECTION: >-
Endpoint=sb://sbns-demo.servicebus.topaz.local.dev:8889;
SharedAccessKeyName=RootManageSharedAccessKey;
SharedAccessKey=SAS_KEY_VALUE;
UseDevelopmentEmulator=true;
AZURE_CORE_INSTANCE_DISCOVERY: "false"
ports:
- "5000:8080"
extra_hosts:
- "topaz.local.dev:host-gateway"
- "sbns-demo.servicebus.topaz.local.dev:host-gateway"
- "storders.blob.storage.topaz.local.dev:host-gateway"
- "vault-demo.vault.topaz.local.dev:host-gateway"

order-service:
build: ./src/OrderService
depends_on:
provisioner:
condition: service_completed_successfully
environment:
SERVICE_BUS_CONNECTION: >-
Endpoint=sb://sbns-demo.servicebus.topaz.local.dev:8889;
SharedAccessKeyName=RootManageSharedAccessKey;
SharedAccessKey=SAS_KEY_VALUE;
UseDevelopmentEmulator=true;
BLOB_ENDPOINT: "https://storders.blob.storage.topaz.local.dev:8891"
AZURE_CORE_INSTANCE_DISCOVERY: "false"
extra_hosts:
- "topaz.local.dev:host-gateway"
- "sbns-demo.servicebus.topaz.local.dev:host-gateway"
- "storders.blob.storage.topaz.local.dev:host-gateway"

notify-service:
build: ./src/NotifyService
depends_on:
provisioner:
condition: service_completed_successfully
environment:
SERVICE_BUS_CONNECTION: >-
Endpoint=sb://sbns-demo.servicebus.topaz.local.dev:8889;
SharedAccessKeyName=RootManageSharedAccessKey;
SharedAccessKey=SAS_KEY_VALUE;
UseDevelopmentEmulator=true;
KEY_VAULT_URI: "https://vault-demo.vault.topaz.local.dev:8898"
AZURE_CORE_INSTANCE_DISCOVERY: "false"
extra_hosts:
- "topaz.local.dev:host-gateway"
- "sbns-demo.servicebus.topaz.local.dev:host-gateway"
- "vault-demo.vault.topaz.local.dev:host-gateway"

volumes:
topaz-data:
extra_hosts

The extra_hosts entries map Topaz hostnames to host-gateway (which resolves to the Docker host's IP, i.e. 127.0.0.1 on Linux or the Docker bridge on macOS/Windows). This gives each container the same DNS view as the host machine.

cloud.json

Place the Topaz cloud.json in your project root — download it once:

curl -fsSL https://raw.githubusercontent.com/TheCloudTheory/Topaz/refs/heads/main/cloud.json -o cloud.json

The provisioner container mounts it and uses it to register the Topaz cloud with the Azure CLI — the same file used in every other Topaz integration.

Step 2: Write the provisioner script

The provisioner runs once after Topaz is healthy, creates all required resources, and exits. Application services wait for it to complete before starting.

#!/usr/bin/env bash
# init/provision.sh
set -e

SUBSCRIPTION=00000000-0000-0000-0000-000000000001

# Register the Topaz cloud using the canonical cloud.json
az cloud register -n Topaz --cloud-config @/cloud.json 2>/dev/null || true

az cloud set -n Topaz
az login --allow-no-subscriptions
az account set --subscription "$SUBSCRIPTION"

# Resource group
az group create --name rg-demo --location westeurope

# Service Bus
az servicebus namespace create \
--name sbns-demo \
--resource-group rg-demo \
--location westeurope \
--sku Standard

az servicebus topic create \
--name orders \
--namespace-name sbns-demo \
--resource-group rg-demo

az servicebus topic subscription create \
--name order-service \
--topic-name orders \
--namespace-name sbns-demo \
--resource-group rg-demo

az servicebus topic subscription create \
--name notify-service \
--topic-name orders \
--namespace-name sbns-demo \
--resource-group rg-demo

# Blob Storage
az storage account create \
--name storders \
--resource-group rg-demo \
--location westeurope \
--sku Standard_LRS

STORAGE_KEY=$(az storage account keys list \
--account-name storders \
--resource-group rg-demo \
--query "[0].value" --output tsv)

az storage container create \
--name order-documents \
--account-name storders \
--account-key "$STORAGE_KEY" \
--blob-endpoint "https://storders.blob.storage.topaz.local.dev:8891"

# Key Vault + secret
az keyvault create \
--name vault-demo \
--resource-group rg-demo \
--location westeurope

az keyvault secret set \
--vault-name vault-demo \
--name notification-api-key \
--value "local-api-key-abc123"

echo "Provisioning complete."

Step 3: Connect application services to Topaz

Each service reads its connection details from environment variables injected by Docker Compose. The SDK code itself is identical to what you would use against real Azure.

// Publishes to the "orders" topic on Service Bus
builder.Services.AddSingleton(sp =>
{
var connectionString = builder.Configuration["SERVICE_BUS_CONNECTION"]!;
return new ServiceBusClient(connectionString);
});

app.MapPost("/orders", async (ServiceBusClient bus, OrderRequest req) =>
{
var sender = bus.CreateSender("orders");
await sender.SendMessageAsync(
new ServiceBusMessage(JsonSerializer.Serialize(req))
{
ContentType = "application/json",
Subject = "OrderPlaced",
});
return Results.Accepted();
});

Step 4: Start the stack

docker compose up --build

Docker Compose will:

  1. Pull and start the Topaz container
  2. Wait for Topaz to pass its health check
  3. Run the provisioner (creates resource group, Service Bus, storage, Key Vault)
  4. Start api-gateway, order-service, and notify-service

Test the end-to-end flow:

# Post an order through the gateway
curl -X POST http://localhost:5000/orders \
-H "Content-Type: application/json" \
-d '{"orderId": "ORD-001", "customerId": "ACME", "amount": 49.99}'

The order flows: API Gateway → Service Bus topic → both subscribers → Blob Storage + notification.

Step 5: Persist state across restarts

The topaz-data volume defined in the Compose file persists all resource state across docker compose down / up cycles. Resources created during the first provisioner run are still available on subsequent starts.

To reset to a clean state:

docker compose down -v # -v removes the named volume
docker compose up

Step 6: Switching to real Azure

The only changes needed to point at real Azure:

  1. Replace SERVICE_BUS_CONNECTION with the connection string from the Azure portal
  2. Replace BLOB_ENDPOINT with https://<account>.blob.core.windows.net
  3. Replace KEY_VAULT_URI with https://<vault>.vault.azure.net
  4. Replace AzureLocalCredential with DefaultAzureCredential (or a managed identity credential)
  5. Remove extra_hosts and AZURE_CORE_INSTANCE_DISCOVERY

All SDK logic — senders, receivers, blob clients, secret clients — stays unchanged.

Common issues

SymptomFix
Services start before resources existProvisioner has not finished — check service_completed_successfully condition on depends_on
SSL certificate problem in provisionerMount the Topaz certificate into the Azure CLI container and update-ca-certificates
host-gateway not resolvedLinux: use host-gateway; macOS/Windows Docker Desktop: use host.docker.internal instead
Messages not routed to subscriptionsSubscriptions were created after messages were published — provisioner runs before services start, so this should not occur if the dependency chain is correct

What you've built

A complete local microservices stack where:

  • Azure Service Bus fans out order events to multiple consumers
  • Azure Blob Storage persists order documents
  • Azure Key Vault supplies API keys at runtime
  • A single docker compose up stands everything up from scratch
  • A new engineer can onboard without a cloud subscription or shared credentials
Star on GitHub