This guide deploys Children's Story Studio to Azure Container Apps as a single custom container, using the Azure Developer CLI (azd) and the Bicep templates in infra/. One command — azd up — provisions every resource and pushes the image. No local Docker daemon required.
Which branch should I deploy? Either works:
main— the minimal workshop starter (fewer features, smaller infra footprint, easiest to read)all-features— the fully-built reference application (everything from every guide enabled)
- What gets deployed
- Prerequisites
- Sample config files in this repo
- One-time setup
- Deploy
- Subsequent deploys
- Optional: Microsoft Entra sign-in (Easy Auth)
- Configuration reference
- Tear down
- Troubleshooting
infra/main.bicep is subscription-scoped — it creates the resource group itself, so the entire stack is one azd up away.
Subscription
└── Resource group: rg-<env-name> (created by Bicep)
├── Virtual network (10.20.0.0/22) — CAE-infra + private-endpoints subnets
├── Azure Container Registry (Basic) — stores the app image
├── Storage Account — persisted demo stories (Blob, private endpoint)
├── Log Analytics + Application Insights — diagnostics + OTEL ingestion
├── User-assigned managed identity — RBAC principal for the Container App
├── Container Apps Environment — runs in the VNet
└── Container App: <env>-app — pulls image from ACR, mounted to MI
Two pre-existing resources are reused (the deploy does not create them — it grants the app's managed identity runtime roles on each):
| Resource | Roles granted to the Container App's managed identity |
|---|---|
| Foundry account | Azure AI User, Cognitive Services OpenAI User |
| Speech account | Cognitive Services Speech User |
| Storage account | Storage Blob Data Contributor (on the new account) |
| Container Registry | AcrPull (on the new ACR) |
The container's Dockerfile builds the React SPA in a Node stage and copies it into the Python runtime image, so FastAPI serves both the SPA (/) and the API (/api/*) from one origin — no CORS to configure and Server-Sent Events work natively.
| Tool | Required for | Notes |
|---|---|---|
| Azure subscription | All deploys | You need permission to create resources, assign RBAC roles, and (if using Entra Easy Auth) create app registrations. |
| Azure CLI | All deploys | az login is used for both the deploy and the Entra app-registration script. |
Azure Developer CLI (azd) |
All deploys | macOS: brew install azure-dev. Windows: winget install microsoft.azd. |
jq |
Entra Easy Auth script only | macOS: brew install jq. |
| Existing Azure AI Foundry account + project | All deploys | With a chat-model deployment (e.g. gpt-5.2 / gpt-5.4) and an image-model deployment (e.g. gpt-image-1.5). See Prerequisites & Environment Setup for provisioning steps. |
| Existing Azure AI Speech account | TTS feature only | Or share the same multi-service AIServices account as Foundry by setting AZURE_SPEECH_RESOURCE_ID to the Foundry resource ID. |
| Docker | Not required | azure.yaml declares remoteBuild: true, so Azure Container Registry builds the image remotely on linux/amd64. This sidesteps Mac M1 cross-platform headaches. |
The repo ships three template files — each maps to a different stage of the workflow. Knowing which goes where prevents a lot of "why is my variable being ignored" pain.
| File | Used by | What it's for |
|---|---|---|
backend/.env.example |
Local dev only (uvicorn running locally) |
Copy to backend/.env and fill in. The Container App never reads this file — runtime env vars are injected by Bicep. |
infra/scripts/azd-env.example.sh |
Deploy only (azd up) |
Copy to infra/scripts/azd-env.local.sh (gitignored), edit the placeholders, and bash it once. It runs all the azd env set commands in one go so you don't have to remember them. |
infra/main.parameters.json |
Bicep (read by azd up) |
Don't edit this. It maps Bicep parameters to azd-env values like ${AZURE_STORAGE_ACCOUNT_NAME} — the variables you set via the script above. |
azure.yaml |
azd (read by every azd command) |
The azd project definition. Already configured for Container Apps with remote-build. |
What about the
.azure/folder? You'll see it appear in the repo root after your firstazd env new. It contains per-environment config (.azure/<env>/.env,.azure/<env>/config.json) thatazdgenerates and owns — never hand-edit it. It's intentionally gitignored (.azure/.gitignorecontains*) because it ends up holding subscription IDs, resource IDs, and — if you enable Entra Easy Auth — yourENTRA_CLIENT_SECRET. Three things write to it:
azd env new <name>creates the subdirectory.azd env set KEY value(what the sample script above runs) populates it with your input.azd upappends Bicep outputs (SERVICE_APP_FQDN,AZURE_RESOURCE_GROUP, etc.) so subsequent commands and the postprovision hook can read them.If you ever need to inspect what's set:
azd env get-values. If you need to start over:rm -rf .azure/<env>and re-runazd env new.
# From the repo root
azd auth login
az login # also needed — the Bicep deploy uses az too
# 1. Create a fresh azd environment (any short name; becomes part of resource names)
azd env new childrenstory
azd env select childrenstory
# 2. Populate every azd env var the deploy needs.
# - Copy the sample script and edit the placeholders (subscription IDs,
# Foundry account names, region, etc.).
cp infra/scripts/azd-env.example.sh infra/scripts/azd-env.local.sh
$EDITOR infra/scripts/azd-env.local.sh
bash infra/scripts/azd-env.local.shWhat the script writes. All values land in
.azure/childrenstory/.env(gitignored). To inspect what's actually set, runazd env get-values. To change one value later, runazd env set KEY valueand the nextazd up/azd provisionpicks it up.
azd upazd up runs end-to-end:
- Provision —
infra/main.bicep(~3-7 min on first run): VNet, ACR, Log Analytics, App Insights, Storage, managed identity, Container Apps Environment, and the Container App itself (usingmcr.microsoft.com/k8se/quickstartas a placeholder image since the ACR is empty). - Deploy — ACR Tasks builds the image remotely on
linux/amd64from theDockerfile, pushes it to the new registry, then updates the Container App revision to point at it. - Postprovision hook — prints the public URL:
https://<env>-app.<unique>.<region>.azurecontainerapps.io/.
When the command finishes, the URL above will load the SPA. The app self-seeds the demo-stories blob container on first boot via seed_demo_stories_if_empty(), so canned demo stories are available immediately.
Different commands for different change types:
| Change | Command | What runs |
|---|---|---|
| App code only (Python or React) | azd deploy |
ACR remote build + Container App revision update. ~2 min. |
| Bicep / infra change | azd provision |
Re-runs Bicep (idempotent — no-op if nothing changed). |
| Both | azd up |
Provision then deploy. |
| Just one azd env var (e.g. switch model) | azd env set KEY value && azd provision |
Bicep applies the new container env var. |
Off by default — the app is publicly reachable. To require Entra sign-in (single-tenant) on every request:
# 1. After the first `azd up` you have an FQDN. Get it:
FQDN=$(azd env get-value SERVICE_APP_FQDN)
# 2. Run the helper to create (or reuse) an Entra app registration with the
# correct redirect URI. Idempotent. Mints a client secret only on first run
# (or with --rotate-secret).
bash infra/scripts/create_entra_app_reg.sh --fqdn "$FQDN"
# 3. Copy the four ENTRA_* values it prints and feed them to azd:
azd env set ENABLE_ENTRA_AUTH true
azd env set ENTRA_CLIENT_ID <from script output>
azd env set ENTRA_CLIENT_SECRET <from script output> # one-time — save it now
azd env set ENTRA_TENANT_ID <from script output>
# 4. Re-provision so the Container App's auth config is updated.
azd provisionThe script requires the Application.ReadWrite.All Graph permission on your Entra account (Application Administrator or Cloud Application Administrator role). After provisioning, /api/health stays anonymous (200) but every other route returns 401 → 302 to the Entra sign-in page on a browser navigation.
To rotate the client secret later: bash infra/scripts/create_entra_app_reg.sh --fqdn "$FQDN" --rotate-secret then re-set ENTRA_CLIENT_SECRET and re-provision.
Every value is settable via azd env set <KEY> <value>. Bold = required (no default). Defaults shown match infra/main.parameters.json.
| Variable | Description |
|---|---|
AZURE_LOCATION |
Region for new resources (e.g. eastus2). |
AZURE_STORAGE_ACCOUNT_NAME |
New Storage Account name; globally unique, 3-24 lowercase alphanumeric. |
FOUNDRY_PROJECT_ENDPOINT |
Short account-level Foundry endpoint, e.g. https://<account>.services.ai.azure.com/. |
AZURE_FOUNDRY_RESOURCE_ID |
ARM resource ID of the existing Foundry account. The deploy grants the app's MI Azure AI User + Cognitive Services OpenAI User on this. |
| Variable | Default | Description |
|---|---|---|
FOUNDRY_MODEL_DEPLOYMENT_NAME |
gpt-5.2 |
Chat-model deployment name in your Foundry project. |
FOUNDRY_IMAGE_MODEL_DEPLOYMENT_NAME |
gpt-image-1.5 |
Image-model deployment name. |
AZURE_SPEECH_RESOURCE_ID |
"" |
ARM ID of the Speech account. Empty → defaults to AZURE_FOUNDRY_RESOURCE_ID. |
AZURE_SPEECH_REGION |
eastus2 |
Region of your Speech / multi-service account. |
AZURE_SPEECH_ENDPOINT |
(derived) | Override the Speech TTS endpoint. Leave empty to derive from region. |
RESOURCE_GROUP_NAME |
rg-<env> |
Override the auto-generated resource group name. |
| Variable | Default | Description |
|---|---|---|
CONTAINER_CPU |
1.0 |
CPU cores per replica. |
CONTAINER_MEMORY |
2Gi |
Memory per replica. |
MIN_REPLICAS |
1 |
Minimum running replicas (set to 0 to allow scale-to-zero). |
MAX_REPLICAS |
3 |
Maximum replicas. |
| Variable | Default | Description |
|---|---|---|
VNET_NAME |
(derived) | Override VNet name. |
VNET_ADDRESS_PREFIX |
10.20.0.0/22 |
VNet CIDR. Must be /22 or larger to fit the Container Apps Environment subnet. |
CAE_SUBNET_ADDRESS_PREFIX |
10.20.0.0/23 |
Subnet for Container Apps Environment infrastructure. |
PRIVATE_ENDPOINTS_SUBNET_ADDRESS_PREFIX |
10.20.2.0/26 |
Subnet for the storage private endpoint. |
| Variable | Default | Description |
|---|---|---|
ENABLE_ENTRA_AUTH |
false |
Master switch. When true, all four ENTRA_* values must also be set. |
ENTRA_CLIENT_ID |
"" |
App registration client (application) ID. |
ENTRA_CLIENT_SECRET |
"" |
App registration client secret. Treat as a secret. |
ENTRA_TENANT_ID |
(deploy tenant) | Tenant ID for the OIDC issuer. |
azd down --purge --forceDeletes the resource group and purge-empties the soft-deleted services (Log Analytics, App Insights, ACR, KeyVault if any). The pre-existing Foundry and Speech accounts are NOT touched — they live in different resource groups.
Easy-Auth gotcha:
azd downdoes not delete the Entra app registration created bycreate_entra_app_reg.sh. Delete it manually in the Entra portal if you want a clean slate.
-
azd uperrors withFailed to deploy service "app": no Container App with name … found— The firstazd upis provisioning + deploying together. If provision fails partway, the Container App may not exist yet. Check the Bicep error in the azd output, fix the input that caused it, and re-runazd up. -
MANIFEST_UNKNOWN/ image pull failures on first revision — Expected briefly. The Container App is created with a public placeholder image and only switches to the real one afterazd deploycompletes. If you see this in logs afterazd upfinished cleanly, runazd deployagain to retry the image push. -
DefaultAzureCredentialfailures from the running container — Confirm the role assignments landed on the Foundry / Speech accounts. The user-assigned managed identity name is inazd env get-value AZURE_MANAGED_IDENTITY_PRINCIPAL_ID:MI=$(azd env get-value AZURE_MANAGED_IDENTITY_PRINCIPAL_ID) az role assignment list --assignee "$MI" --all -o table
-
Pydantic Invalid JSON: EOF while parsing a stringfrom the orchestrator/architect — Reasoning models (gpt-5.x) consume most of the response budget on internal reasoning tokens. The agents in this repo pass an explicitmax_tokensto leave room for the JSON output; if you've cloned an older fork, bumpmax_tokenson the structured-outputagent.run()calls to 16k+ for the orchestrator and 24k+ for the architect. -
SSE story-stream drops after a few seconds in production — Container Apps supports streaming over HTTP/1.1. If your client is behind a corporate proxy that buffers responses, switch the proxy off or increase its read timeout. The SPA uses fetch streaming (not EventSource), so any HTTP/1.1+ origin works.
-
Easy Auth: 500 from
/.auth/login/aad/callback— Almost always either (a) the redirect URI in the Entra app registration doesn't matchhttps://<fqdn>/.auth/login/aad/callbackexactly (re-runcreate_entra_app_reg.sh --fqdn $FQDNto re-merge), or (b) the client secret inENTRA_CLIENT_SECRETis stale (rotate with--rotate-secretand re-set +azd provision). -
Health check —
GET https://<fqdn>/api/healthshould return200 OKeven when Easy Auth is on. If it doesn't, check the Container App revision logs:az containerapp logs show \ --name "$(azd env get-value SERVICE_APP_NAME)" \ --resource-group "$(azd env get-value AZURE_RESOURCE_GROUP)" \ --tail 50 --type console