PBO Tech

← Microservices Pattern Demo

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 containerapp extension (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-insights is 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, check az postgres flexible-server create -h.
  • --public-access 0.0.0.0 lets 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_NAME becomes the cloud role name, which labels each node on the Application Map.
  • Redeploying a new image is az acr build with a new tag, then az 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.

  1. Add infra/main.bicep declaring 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.
  2. Add azure.yaml listing the three services, each with host: containerapp and its project folder.
  3. Run azd up to provision, build and deploy; azd deploy after code changes.
  4. Add a GitHub Actions workflow with azd pipeline config when 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.