Azure runbook

One-shot the same Bench Ticket demo as Local Docker on Azure: first-party email-code login, Sessions, Support Desk create/list/reply, Health. Record store is @pegma/storage-azure-tables. Provision a Blob container for @pegma/storage-azure-blob (the demo does not store attachments). Mail is a console catcher on container logs — do not sign up for ACS, SES, SendGrid, or Resend.

Do not add Stripe, Auth0, Entra, or passkeys-required login. Entra is an IdP adapter for hosts that already authenticate with Entra; this demo uses @pegma/identity email codes.

Fixture: recipes/bench-ticket. Reuse the Node host (host.ts) with the store factory swapped to Azure Tables — same createBenchTicketComposition.

Prerequisites

1. Get the source

git clone https://github.com/pegma-dev/pegma.dev.git
cd pegma.dev
git checkout main
npm install -g corepack
corepack enable
pnpm install --frozen-lockfile

2. Default names

Resource Default name
Resource group rg-bench-ticket
Region eastus
Storage account stbenchticket plus a unique suffix (Azure names are global)
Table pegma (every Storage Core collection shares one table)
Blob container bench-ticket-blobs
Container Apps env cae-bench-ticket
Container app ca-bench-ticket
Email-code secret Container App secret email-code-hmac, injected as BENCH_TICKET_EMAIL_CODE_SECRET_BASE64
Storage account key Container App secret storage-key, injected as AZURE_STORAGE_ACCOUNT_KEY

3. Create Azure resources

az group create --name rg-bench-ticket --location eastus

az storage account create \
  --name stbenchticket$RANDOM \
  --resource-group rg-bench-ticket \
  --location eastus \
  --sku Standard_LRS

ACCOUNT=$(az storage account list -g rg-bench-ticket --query '[0].name' -o tsv)
CONN=$(az storage account show-connection-string -g rg-bench-ticket -n "$ACCOUNT" --query connectionString -o tsv)

az storage table create --name pegma --connection-string "$CONN"
az storage container create --name bench-ticket-blobs --connection-string "$CONN"

Save $CONN. Do not commit it.

4. Point the host at Azure Tables

The published demo host.ts uses createMemoryStore(). For Azure, add the published adapter and swap the factory. Exact packages (pin from catalog.json if these drifted):

pnpm add @pegma/storage-azure-tables@0.4.0 @azure/data-tables

In recipes/bench-ticket/host.ts, replace createMemoryStore() with:

import { TableClient, AzureNamedKeyCredential } from "@azure/data-tables";
import { createAzureTablesStore } from "@pegma/storage-azure-tables";

const account = process.env.AZURE_STORAGE_ACCOUNT!;
const key = process.env.AZURE_STORAGE_ACCOUNT_KEY!;
const endpoint = `https://${account}.table.core.windows.net`;
const client = new TableClient(endpoint, "pegma", new AzureNamedKeyCredential(account, key));
const store = createAzureTablesStore({ client, createTableIfMissing: false });

Set BENCH_TICKET_MAIL_CATCHER=console so codes go to stdout (container logs), not Mailpit.

Keep createBenchTicketComposition({ store, origin, … }) unchanged.

5. Deploy the container

SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")
ACCOUNT=$(az storage account list -g rg-bench-ticket --query '[0].name' -o tsv)
KEY=$(az storage account keys list -g rg-bench-ticket -n "$ACCOUNT" --query '[0].value' -o tsv)

az acr create -g rg-bench-ticket -n benchticketacr$RANDOM --sku Basic
ACR=$(az acr list -g rg-bench-ticket --query '[0].name' -o tsv)
az acr login -n "$ACR"
IMAGE="${ACR}.azurecr.io/bench-ticket:local"
docker build -f recipes/bench-ticket/Dockerfile -t "$IMAGE" .
docker push "$IMAGE"

az containerapp env create -g rg-bench-ticket -n cae-bench-ticket -l eastus

# First create without origin, then set origin from the assigned FQDN.
# HMAC and the storage account key are Container Apps secrets, not plaintext env:
# `--secrets email-code-hmac=… storage-key=…` plus `secretref:`.
# `az containerapp show` returns the secret *name* and `secretRef`, not the values.
az containerapp create \
  -g rg-bench-ticket \
  -n ca-bench-ticket \
  --environment cae-bench-ticket \
  --image "$IMAGE" \
  --ingress external \
  --target-port 8787 \
  --registry-server "${ACR}.azurecr.io" \
  --secrets "email-code-hmac=$SECRET" "storage-key=$KEY" \
  --env-vars \
    BENCH_TICKET_MAIL_CATCHER=console \
    BENCH_TICKET_EMAIL_CODE_SECRET_BASE64=secretref:email-code-hmac \
    AZURE_STORAGE_ACCOUNT="$ACCOUNT" \
    AZURE_STORAGE_ACCOUNT_KEY=secretref:storage-key \
    PORT=8787

FQDN=$(az containerapp show -g rg-bench-ticket -n ca-bench-ticket --query properties.configuration.ingress.fqdn -o tsv)
ORIGIN="https://$FQDN"
az containerapp update -g rg-bench-ticket -n ca-bench-ticket \
  --set-env-vars "BENCH_TICKET_ORIGIN=$ORIGIN"

Identity requires the live HTTPS origin. Redeploy/update env after the FQDN exists.

6. Verify

ORIGIN=https://$(az containerapp show -g rg-bench-ticket -n ca-bench-ticket --query properties.configuration.ingress.fqdn -o tsv)
curl -sS "$ORIGIN/health"

Expect HTTP 200, "ok": true, "service": "bench-ticket".

az containerapp logs show -g rg-bench-ticket -n ca-bench-ticket --follow

Then the same begin / copy 8-digit code from logs / finish / file ticket / reply sequence as Local Docker, using $ORIGIN instead of http://localhost:8787. Log line prefix: [bench-ticket mail].

Failure signs

Sign Next command
az account show fails az login
Storage account name taken Add a longer suffix; Azure storage names are global and lowercase.
TableNotFound az storage table create --name pegma --connection-string "$CONN"
Health 503 / storage fail Confirm AZURE_STORAGE_ACCOUNT and that AZURE_STORAGE_ACCOUNT_KEY is secretref:storage-key (not the key value): az containerapp show -g rg-bench-ticket -n ca-bench-ticket. Re-list the key with az storage account keys list if you must recover it.
Origin invalid / finish 400 BENCH_TICKET_ORIGIN must be https://<fqdn> with no path. az containerapp update … --set-env-vars BENCH_TICKET_ORIGIN=…
HMAC or storage key printed by az containerapp show Recreate them as --secrets email-code-hmac=… storage-key=… and secretref:. Do not put either value in --env-vars.
No code in logs az containerapp logs show … --follow before calling begin. BENCH_TICKET_MAIL_CATCHER must be console.
Building image fails on tsx Image must pnpm install before NODE_ENV=production (the checked-in Dockerfile already does).

Teardown

az group delete --name rg-bench-ticket --yes --no-wait
rm -f /tmp/bench-ticket.cookies

That removes the storage account, table, blob container, ACR, and Container App.

Store wiring (same composition)

import { createAzureTablesStore } from "@pegma/storage-azure-tables";

const store = createAzureTablesStore({
  client,
  createTableIfMissing: false,
});

Hand that Store to createBenchTicketComposition. Blob: @pegma/storage-azure-blob + createAzureBlobStore({ containerClient }) when the host grows attachments; this demo’s ticket round-trip does not need it.