How to run event-driven microservices on Azure
What you end with
This guide moves a small event-driven microservices system from Docker Compose onto Azure managed services. The system has an API gateway, services that each own their own database, and a pub/sub broker between them, all traced with OpenTelemetry. The running example is an orders service that publishes order_created and an inventory service that consumes it; substitute your own services and events.
At the end, an event published while its consumer is down is no longer lost: it waits in Service Bus and is processed when the consumer comes back. Each request gives one connected trace across the broker in Application Insights.
It assumes a Compose stack like this: nginx as gateway, Python/FastAPI services, SQLite or another per-service store, Redis pub/sub, and Jaeger for traces - the same shape as the Microservices Pattern Demo itself. Other languages work the same way; only the code in Step 3 changes.
| Compose stack | Azure |
|---|---|
| Docker Compose, self-hosted | Azure Container Apps |
| Image builds on the host | Azure Container Registry |
| nginx gateway | nginx as a Container App; API Management as an option |
| SQLite / per-service DB | Azure Database for PostgreSQL Flexible Server, one database per service |
| Redis pub/sub | Azure Service Bus topic + subscription |
| OpenTelemetry → Jaeger | Azure Monitor OpenTelemetry Distro → Application Insights |
| Credentials in env vars | Managed identities |
You need:
- An Azure subscription where you can create resource groups and assign roles (Owner, or Contributor plus User Access Administrator)
- Azure CLI 2.60 or later with the
containerappextension (az extension add -n containerapp --upgrade) - Your services’ source, each with a Dockerfile; local Docker isn’t needed, because images are built in Azure Container Registry
- Optional: Azure Developer CLI (
azd) for Step 6
Commands use bash. Resource names below are examples; Service Bus, Postgres and registry names must be globally unique, so add a suffix.
At a glance
flowchart LR
client["Browser"]
gw["gateway (Container App, public)"]
orders["orders-service (Container App, internal)"]
inventory["inventory-service (Container App, internal)"]
acr[("Azure Container Registry")]
sb[("Service Bus topic: order-created")]
pgOrders[("PostgreSQL: orders")]
pgInventory[("PostgreSQL: inventory")]
appi[("Application Insights")]
client --> gw
gw -->|"/orders"| orders
gw -->|"/inventory"| inventory
orders -->|reads/writes| pgOrders
inventory -->|reads/writes| pgInventory
orders -->|"publish, traceparent in properties"| sb
sb -->|deliver| inventory
orders -.->|spans| appi
inventory -.->|spans| appi
gw -.->|pulls images| acr
orders -.->|pulls images| acr
inventory -.->|pulls images| acr
classDef container fill:#1168bd,stroke:#0b4884,color:#fff
classDef managed fill:#999999,stroke:#6b6b6b,color:#fff
class gw,orders,inventory container
class acr,sb,pgOrders,pgInventory,appi managed
Only the gateway is public. Each service reaches only its own database, and orders-service never calls inventory-service: the Service Bus topic is their only coupling.
Step 1: Provision the platform
Six resources replace the Compose file’s infrastructure: a registry, a Service Bus namespace, a Postgres server, Log Analytics with Application Insights, and a Container Apps environment.
SUFFIX=$RANDOM; LOC=uksouth; RG=rg-msdemo-azure
ACR=acrmsdemo$SUFFIX; SB=sb-msdemo-$SUFFIX; PG=pg-msdemo-$SUFFIX
LAW=law-msdemo; APPI=appi-msdemo; ENV=cae-msdemo
az group create -n $RG -l $LOC
az acr create -g $RG -n $ACR --sku Basic
# Broker: topics need Standard tier
az servicebus namespace create -g $RG -n $SB -l $LOC --sku Standard
az servicebus topic create -g $RG --namespace-name $SB -n order-created
az servicebus topic subscription create -g $RG --namespace-name $SB \
--topic-name order-created -n inventory --max-delivery-count 10
# Telemetry
az monitor log-analytics workspace create -g $RG -n $LAW -l $LOC
LAW_ID=$(az monitor log-analytics workspace show -g $RG -n $LAW --query id -o tsv)
az monitor app-insights component create -g $RG -a $APPI -l $LOC --workspace $LAW_ID
# Container Apps environment
LAW_CID=$(az monitor log-analytics workspace show -g $RG -n $LAW --query customerId -o tsv)
LAW_KEY=$(az monitor log-analytics workspace get-shared-keys -g $RG -n $LAW --query primarySharedKey -o tsv)
az containerapp env create -g $RG -n $ENV -l $LOC \
--logs-workspace-id $LAW_CID --logs-workspace-key $LAW_KEY
# Postgres: Entra auth only, reachable from Azure services
az postgres flexible-server create -g $RG -n $PG -l $LOC \
--tier Burstable --sku-name Standard_B1ms --version 16 --storage-size 32 \
--microsoft-entra-auth Enabled --password-auth Disabled --public-access 0.0.0.0
az postgres flexible-server db create -g $RG -s $PG -d orders
az postgres flexible-server db create -g $RG -s $PG -d inventory
Notes:
app-insightsis a CLI extension; the CLI offers to install it the first time.- Older CLI versions spell the Postgres flag
--active-directory-auth. If a flag is rejected, checkaz postgres flexible-server create -h. --public-access 0.0.0.0lets Azure services in but nothing else. Private networking (a VNet-integrated environment) is out of scope for this guide.
Step 2: Identities and access
Each service gets its own user-assigned managed identity with only the rights it needs. That is how the variant enforces database-per-service and the topic boundary, with no secrets anywhere.
| Identity | Pull images | Service Bus | Postgres |
|---|---|---|---|
| id-orders | AcrPull | Data Sender on topic order-created |
owns database orders |
| id-inventory | AcrPull | Data Receiver on subscription inventory |
owns database inventory |
| id-gateway | AcrPull | none | none |
for s in orders inventory gateway; do az identity create -g $RG -n id-$s; done
pid() { az identity show -g $RG -n id-$1 --query principalId -o tsv; }
role() { az role assignment create --assignee-object-id $1 \
--assignee-principal-type ServicePrincipal --role "$2" --scope $3; }
ACR_ID=$(az acr show -n $ACR --query id -o tsv)
TOPIC_ID=$(az servicebus topic show -g $RG --namespace-name $SB -n order-created --query id -o tsv)
SUB_ID=$(az servicebus topic subscription show -g $RG --namespace-name $SB \
--topic-name order-created -n inventory --query id -o tsv)
for s in orders inventory gateway; do role $(pid $s) AcrPull $ACR_ID; done
role $(pid orders) "Azure Service Bus Data Sender" $TOPIC_ID
role $(pid inventory) "Azure Service Bus Data Receiver" $SUB_ID
For Postgres, make yourself the Entra admin, open the firewall to your IP, and create a role per identity:
az postgres flexible-server microsoft-entra-admin create -g $RG -s $PG \
--display-name "$(az ad signed-in-user show --query userPrincipalName -o tsv)" \
--object-id "$(az ad signed-in-user show --query id -o tsv)" --type User
az postgres flexible-server firewall-rule create -g $RG -n $PG \
--rule-name me --start-ip-address <your-ip> --end-ip-address <your-ip>
export PGPASSWORD=$(az account get-access-token --resource-type oss-rdbms --query accessToken -o tsv)
psql "host=$PG.postgres.database.azure.com dbname=postgres user=<your-upn> sslmode=require"
SELECT * FROM pgaadauth_create_principal('id-orders', false, false);
SELECT * FROM pgaadauth_create_principal('id-inventory', false, false);
REVOKE CONNECT ON DATABASE orders, inventory FROM PUBLIC;
GRANT CONNECT ON DATABASE orders TO "id-orders";
GRANT CONNECT ON DATABASE inventory TO "id-inventory";
\c orders
GRANT ALL ON SCHEMA public TO "id-orders";
\c inventory
GRANT ALL ON SCHEMA public TO "id-inventory";
After this, id-orders can’t even connect to inventory. Older CLI versions name the admin command ad-admin create. Role assignments can take a few minutes to take effect.
Step 3: Code changes
Five changes, all inside the existing services; routes and the UI stay the same. Table and column names below are examples, so adapt them to your schema.
| Area | Compose stack | Azure variant |
|---|---|---|
| Broker client | redis publish / subscribe |
azure-servicebus via a shared eventbus.py |
| Trace context | trace_context field in the payload |
traceparent in message application properties |
| Database | SQLite file | PostgreSQL via psycopg, Entra token as password |
| Telemetry | OTLP exporter → Jaeger | azure-monitor-opentelemetry distro |
| Gateway | nginx with a /jaeger/ route |
same nginx image, /jaeger/ removed |
Add to both services’ requirements: azure-servicebus, azure-identity, azure-monitor-opentelemetry, psycopg[binary]. Drop redis and the OTLP and Redis instrumentation packages.
3a. Shared publish/consume helper
Keep trace propagation in one place. Services call publish and consume and never touch trace context themselves, so a new event type is traced for free.
# eventbus.py, shared by both services
import json, logging, os
from azure.identity import DefaultAzureCredential
from azure.servicebus import ServiceBusClient, ServiceBusMessage
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind
_tracer = trace.get_tracer(__name__)
_client = ServiceBusClient(os.environ["SERVICEBUS_FQDN"], DefaultAzureCredential())
_s = lambda x: x.decode() if isinstance(x, bytes) else str(x)
def publish(topic: str, event: dict, message_id: str) -> None:
props: dict[str, str] = {}
propagate.inject(props) # writes traceparent / tracestate
msg = ServiceBusMessage(json.dumps(event), message_id=message_id,
content_type="application/json",
application_properties=props)
with _client.get_topic_sender(topic) as sender:
sender.send_messages(msg)
def consume(topic: str, subscription: str, span_name: str, handler) -> None:
"""Blocking receive loop; run it in a background thread."""
with _client.get_subscription_receiver(topic, subscription) as receiver:
for msg in receiver:
props = {_s(k): _s(v) for k, v in (msg.application_properties or {}).items()}
ctx = propagate.extract(props)
with _tracer.start_as_current_span(span_name, context=ctx, kind=SpanKind.CONSUMER):
try:
handler(json.loads(str(msg)))
receiver.complete_message(msg) # only after the DB commit
except Exception:
logging.exception("handler failed; message will be redelivered")
receiver.abandon_message(msg) # dead-lettered after 10 tries
In orders-service, after the order row is committed:
publish("order-created", {"order_id": order.id, "item": order.item, "qty": order.qty},
message_id=str(order.id))
3b. Idempotent consumer in inventory-service
Delivery is now at-least-once, so the same order can arrive twice. Record each processed order id in the same transaction as the stock change.
def handle_order_created(event: dict) -> None:
with db() as conn, conn.transaction():
cur = conn.execute(
"INSERT INTO processed_orders (order_id) VALUES (%s) ON CONFLICT DO NOTHING",
(event["order_id"],))
if cur.rowcount == 0:
return # already applied
conn.execute("UPDATE stock SET qty = GREATEST(qty - %s, 0) WHERE item = %s",
(event["qty"], event["item"]))
# FastAPI lifespan / startup
threading.Thread(target=consume, daemon=True,
args=("order-created", "inventory", "process_order_created",
handle_order_created)).start()
3c. PostgreSQL with an Entra token
The managed identity’s access token is the password. Tokens last about an hour, so get one per connection.
import os, psycopg
from azure.identity import DefaultAzureCredential
_cred = DefaultAzureCredential() # uses AZURE_CLIENT_ID for the user-assigned identity
def db():
token = _cred.get_token("https://ossrdbms-aad.database.windows.net/.default").token
return psycopg.connect(host=os.environ["PGHOST"], dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"], password=token, sslmode="require")
Porting from SQLite: change ? placeholders to %s, and INTEGER PRIMARY KEY AUTOINCREMENT to GENERATED ALWAYS AS IDENTITY. Keep the CREATE TABLE IF NOT EXISTS at startup, and add processed_orders (order_id PRIMARY KEY) to inventory. Any reset endpoint becomes a TRUNCATE plus re-seeding stock, because a redeploy no longer wipes state.
3d. Telemetry
Replace the tracer-provider and OTLP exporter setup with one call, made before the FastAPI app is created:
from azure.monitor.opentelemetry import configure_azure_monitor
configure_azure_monitor() # reads APPLICATIONINSIGHTS_CONNECTION_STRING; instruments FastAPI, psycopg, Azure SDK
The distro also traces Service Bus calls through the Azure SDK. Those spans join the same trace, and the explicit traceparent still makes the consumer span a child of the request.
3e. Gateway
Keep the nginx image, with its config and static UI copied in at build time rather than bind-mounted. Point /orders and /inventory at http://orders-service and http://inventory-service; inside a Container Apps environment an app is reachable by its name. Delete the /jaeger/ location. In the UI, show each order’s trace id instead of a Jaeger link; you can search it in Application Insights’ Transaction search.
Step 4: Build and deploy the three container apps
Images are built inside the registry, so nothing depends on your local Docker or a host path. The two services get internal ingress, and only the gateway is public, just as in Compose.
# Build (adjust the folder paths to the repo layout)
for a in orders-service inventory-service gateway; do
az acr build -r $ACR -t $a:v1 ./$a
done
APPI_CS=$(az monitor app-insights component show -g $RG -a $APPI --query connectionString -o tsv)
idres() { az identity show -g $RG -n id-$1 --query id -o tsv; }
cid() { az identity show -g $RG -n id-$1 --query clientId -o tsv; }
svc() { # $1 = short name, $2 = database
az containerapp create -g $RG -n $1-service --environment $ENV \
--image $ACR.azurecr.io/$1-service:v1 \
--user-assigned $(idres $1) \
--registry-server $ACR.azurecr.io --registry-identity $(idres $1) \
--ingress internal --target-port 8000 --min-replicas 1 --max-replicas 3 \
--env-vars AZURE_CLIENT_ID=$(cid $1) OTEL_SERVICE_NAME=$1-service \
SERVICEBUS_FQDN=$SB.servicebus.windows.net \
PGHOST=$PG.postgres.database.azure.com PGDATABASE=$2 PGUSER=id-$1 \
APPLICATIONINSIGHTS_CONNECTION_STRING="$APPI_CS"
}
svc orders orders
svc inventory inventory
# Gateway last, so its upstream names resolve
az containerapp create -g $RG -n gateway --environment $ENV \
--image $ACR.azurecr.io/gateway:v1 \
--user-assigned $(idres gateway) \
--registry-server $ACR.azurecr.io --registry-identity $(idres gateway) \
--ingress external --target-port 80 --min-replicas 0
az containerapp show -g $RG -n gateway --query properties.configuration.ingress.fqdn -o tsv
Notes:
- Use the port your services listen on in place of
8000. - inventory-service runs its consumer as a background loop, so it needs
--min-replicas 1. Scaling it from zero would need a KEDA Service Bus scale rule instead. OTEL_SERVICE_NAMEbecomes the cloud role name, which labels each node on the Application Map.- Redeploying a new image is
az acr buildwith a new tag, thenaz containerapp update -n <app> --image <new tag>.
Step 5: Verify
Three checks: the flow works, traces connect across the broker, and no events are lost while the consumer is down. The third is the one plain Redis pub/sub fails, because it has no persistence.
1. The flow works. Open the gateway’s URL and place an order, or:
GW=https://$(az containerapp show -g $RG -n gateway --query properties.configuration.ingress.fqdn -o tsv)
curl -sS -X POST $GW/orders -H 'Content-Type: application/json' -d '{"item":"widget","qty":1}'
curl -sS $GW/inventory
2. Traces connect. In Application Insights, open Logs and run the query below. Each order should show one operation id spanning both cloud roles. The Application Map should show gateway → orders-service → Service Bus → inventory-service, plus Postgres.
union requests, dependencies
| where timestamp > ago(30m)
| summarize roles = make_set(cloud_RoleName), spans = count() by operation_Id
| where array_length(roles) > 1
Telemetry can take a couple of minutes to appear.
3. No lost events. Stop the consumer, place orders, then bring it back:
REV=$(az containerapp revision list -g $RG -n inventory-service --query "[0].name" -o tsv)
az containerapp revision deactivate -g $RG -n inventory-service --revision $REV
for i in 1 2 3; do
curl -sS -X POST $GW/orders -H 'Content-Type: application/json' -d '{"item":"gadget","qty":1}'
done
az servicebus topic subscription show -g $RG --namespace-name $SB --topic-name order-created \
-n inventory --query countDetails.activeMessageCount # expect 3
az containerapp revision activate -g $RG -n inventory-service --revision $REV
# within seconds: count back to 0, gadget stock down by 3
The orders succeed while inventory is down; that is the decoupling. Once it comes back, all three events are applied; that is the durability the Redis version lacks. To see the dead-letter queue, make the handler raise for one item. After 10 deliveries the message moves to inventory/$DeadLetterQueue, visible in the portal’s Service Bus Explorer.
Step 6: Make it repeatable, then tear down
The CLI steps prove the design; Bicep with azd makes it one command, the Azure equivalent of docker compose up --build.
- Add
infra/main.bicepdeclaring the resources from Steps 1-2. Bicep can’t create the Postgres roles, so run the SQL from Step 2 as a post-provision hook. - Add
azure.yamllisting the three services, each withhost: containerappand its project folder. - Run
azd upto provision, build and deploy;azd deployafter code changes. - Add a GitHub Actions workflow with
azd pipeline configwhen you want CI.
| Bicep resource type | From step |
|---|---|
Microsoft.ContainerRegistry/registries |
1 |
Microsoft.ServiceBus/namespaces (+ /topics, /topics/subscriptions) |
1 |
Microsoft.DBforPostgreSQL/flexibleServers (+ /databases, /administrators) |
1, 2 |
Microsoft.OperationalInsights/workspaces, Microsoft.Insights/components |
1 |
Microsoft.App/managedEnvironments, Microsoft.App/containerApps |
1, 4 |
Microsoft.ManagedIdentity/userAssignedIdentities, Microsoft.Authorization/roleAssignments |
2 |
Optional next steps, once the baseline works:
- API Management in front of the gateway, with Entra ID auth, which closes the “no auth on the gateway” limitation
- A reservation saga, replacing the stock clamp - done in Part 2 on Azure, with Circuit Breaker and Retry around the new synchronous call it needs
- An outbox table in orders-service, if an order without its event matters
Tear down everything with one command; nothing lives outside the resource group:
az group delete -n $RG --yes --no-wait # or: azd down --purge
Costs accrue while the group exists. Service Bus Standard and the Postgres server bill even when idle, so delete the group between sessions or stop the server with az postgres flexible-server stop.
Unfamiliar with a term on this page? See the glossary for plain-English definitions, including the Azure-specific ones.