Manual Deployment Guide (Azure Container Apps)
Prefer the guided path? See One-click installation. in the README. This guide is for deploying from the CLI with full control.
The whole app — FastAPI API + the built React SPA + the in-process MCP servers — ships as one image and runs as a single Container App. No separate frontend, Postgres, or Redis containers are required.
How it fits in one container
- SPA: built by the repository’s multi-stage Dockerfile (
VITE_API_BASE=/api) and copied intoapp/static. FastAPI serves/assets/*and falls back toindex.htmlfor any non-/apipath, so deep links and refresh work. - Database: SQLite (
DATABASE_URL=sqlite+aiosqlite:///./.data/app.db). Put./.dataon an Azure Files volume to persist across revisions. Postgres is still supported by pointingDATABASE_URLat it. - Redis: not on the request path; omit it.
- MCP servers: spawned in-process over stdio (
npx @azure/mcp, EntraID FastMCP) — the image already includes Node 20 + Azure CLI. - Dependencies: pinned in the backend requirements file (frozen from a working environment, Windows-only packages removed) and installed before
pip install --no-deps .so every runtime import resolves.
API prefix
Every backend endpoint is served under /api (e.g. /api/me, /api/chats, /api/admin/...). Only /healthz and /readyz live at the root. The frontend reads its base from VITE_API_BASE (default http://localhost:8000/api for local dev). This keeps API routes from colliding with the SPA’s client-side routes (/inventory, /admin, /policy, …) so the single-container build can serve the app at every non-/api path.
Azure access modes
| Mode | How |
|---|---|
Your az login (default, local) | ~/.azure is mounted into the backend; uses your RBAC |
| Service principal | set AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET |
| Managed identity (ACA) | assign an identity to the Container App; no secret needed |
The MCP server starts with --read-only (MCP_READ_ONLY=true). Write-capable tools are classified, approval-gated, and audited.
Key env vars (production)
| Variable | Purpose |
|---|---|
SEED_ADMIN_USERNAME / SEED_ADMIN_PASSWORD | Bootstrap admin created on first run (store the password as a Container App secret; the user is forced to change it on first login) |
DATABASE_URL | sqlite+aiosqlite:///./.data/app.db (Azure Files) or a Postgres URL (?ssl=require for Azure PostgreSQL) |
COOKIE_SECURE | true (HTTPS ingress) |
AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET | Service-principal identity for MCP (or use a managed identity) — there is no ~/.azure mount in ACA |
LLM_API_KEY (or configure in Settings) | LLM provider key |
Deploy from scratch (PowerShell)
$RG = "rg-azsupagent"
$LOC = "southcentralus"
$ACR = "azsupagent$((Get-Random -Maximum 99999))" # globally-unique
$APP = "azsupagent"
az account set --subscription "<subscription-id>"
# 1) Registry (cheapest Basic SKU) + cloud build of the single image
az acr create -n $ACR -g $RG --sku Basic --admin-enabled true -l $LOC
az acr build -r $ACR -t "${APP}:latest" -f Dockerfile .
# 2) Container Apps environment (Consumption)
az containerapp env create -n "$APP-env" -g $RG -l $LOC
# 3) The app: external ingress on 8000, scale-to-zero, admin password as a secret
$server = "$ACR.azurecr.io"
$pw = az acr credential show -n $ACR --query "passwords[0].value" -o tsv
az containerapp create -n $APP -g $RG `
--environment "$APP-env" `
--image "$server/${APP}:latest" `
--registry-server $server --registry-username $ACR --registry-password $pw `
--target-port 8000 --ingress external `
--min-replicas 0 --max-replicas 1 --cpu 0.5 --memory 1.0Gi `
--secrets "admin-password=<your-password>" `
--env-vars SEED_ADMIN_USERNAME=admin "SEED_ADMIN_PASSWORD=secretref:admin-password" `
"DATABASE_URL=sqlite+aiosqlite:///./.data/app.db" COOKIE_SECURE=true
Redeploy a new build
az acr build -r $ACR -t "${APP}:latest" -f Dockerfile .
# 'latest' is reused, so force a fresh revision:
az containerapp update -n $APP -g $RG `
--image "$ACR.azurecr.io/${APP}:latest" --revision-suffix "r$(Get-Random -Maximum 9999)"
Cost & scaling notes
- Cheapest posture: Basic ACR + Consumption Container App,
min-replicas 0(scale-to-zero → no compute charge when idle),0.5 vCPU / 1 GiB. The first request after idle pays a cold-start (plus a one-timenpx @azure/mcpfetch). - Single replica only while using SQLite or in-container state (it’s stateful). Set
--min-replicas 1to avoid cold starts (costs more).
Gotchas learned (Windows + ACR)
az acr buildlog streamer crashes locally on Windows withUnicodeEncodeError: '\u2713'(colorama → cp1252). The build still runs server-side. Pollaz acr task list-runs -r <acr> --top 1 -o tablefor status, or stream logs straight to a UTF-8 console (don’t pipe/redirect).az containerapp up --source .can fail with'NoneType' object has no attribute 'linux'; use the explicitacr create→acr build→containerapp createflow above instead.- The Dockerfile copies
backend/beforepip installbecausesetuptools packages=["app"]validates the package dir at build time. - The container imports the whole API at startup, so a missing dependency crashes uvicorn immediately — keep
backend/requirements.txtcomplete.
Notes & limitations (current build)
- Multi-session chat, SSE streaming, persistence, and end-to-end read-only Azure MCP tool calling are complete.
- Write actions are classified and gated; execution flows through the approval workflow.
- Single-replica Postgres/SQLite (stateful). For Container Apps, back state with Azure Files volumes; everything else is stateless and scales horizontally.