Manual deployment
Use manual deployment when you need to control the image build, registry, Container Apps sizing, storage, networking, or release process. The product ships as one image: FastAPI serves both /api and the built SPA, while MCP servers run in-process. No separate frontend, database, or Redis container is required.
Prefer a guided path? Use one-click installation.
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 — PostgreSQL for a shared deployment, or SQLite (
DATABASE_URL=sqlite+aiosqlite:///./.data/app.db) with./.dataon an Azure Files volume so it survives revisions. - Redis — not on the request path; omit it.
- MCP servers — spawned in-process over stdio. The production image runs its pinned, build-time Azure MCP installation through
azmcp; the EntraID FastMCP server uses its isolated image virtual environment. The image includes Node 22 and Azure CLI. - Azure Resource Graph extension — production bakes
resource-graphinto/opt/az-extensionsand setsAZURE_EXTENSION_DIRto that stable path, so temporary service-principal CLI sessions do not download an extension into each throwawayAZURE_CONFIG_DIR. - Dependencies — pinned in the backend requirements file and installed before
pip install --no-deps .so every runtime import resolves.
API prefix
Every backend endpoint is served under /api (for example /api/me, /api/chats, /api/admin/...). Only /healthz and /readyz live at the root. The frontend reads its base from VITE_API_BASE, defaulting to http://localhost:35001/api for local development. This keeps API routes from colliding with the SPA’s client-side routes such as /inventory, /admin, and /policy.
Azure access modes
| Mode | How |
|---|---|
Your az login (default, local) | ~/.azure is mounted into the backend and your own RBAC applies |
| Service principal | set AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET |
| Managed identity (Container Apps) | assign an identity to the Container App; no secret is needed |
The Azure MCP server starts with --read-only (MCP_READ_ONLY=true). Write-capable tools are classified, approval-gated, and audited.
Prerequisites
- Azure CLI authenticated to the intended subscription.
- Permission to create a resource group, Azure Container Registry, Container Apps resources, identities, role assignments, storage, and the chosen database.
- Dockerfile build context from a trusted release or reviewed source checkout.
- A production secret-management plan.
Deployment workflow
- Choose persistence. Use PostgreSQL for a shared production database, or place SQLite’s
.datadirectory on Azure Files. Never rely on an ephemeral container filesystem. - Choose the identity. Prefer a Container App managed identity. If using a service principal, store its secret or certificate as a Container App secret.
- Build and tag the image. Build from the repository root so the frontend and backend are included. Prefer an immutable release tag over relying only on
latest. - Create the Container Apps environment and application. Expose port 8000 through HTTPS ingress.
- Set production configuration. Important settings include the database URL, secure-cookie behavior, bootstrap administrator values, public URL, connection identity, and optional model configuration.
- Attach persistent storage before allowing production traffic when SQLite is selected.
- Verify health. Check
/healthzfor liveness and/readyzfor readiness, then load the SPA through the public URL. - Grant Reader to the application identity at the intended Azure scope.
- Complete First-run setup.
Key production environment variables
| Variable | Purpose |
|---|---|
SEED_ADMIN_USERNAME / SEED_ADMIN_PASSWORD | Bootstrap administrator created on first run. Store the password as a Container App secret; the user is forced to change it at first sign-in. |
DATABASE_URL | sqlite+aiosqlite:///./.data/app.db on Azure Files, or a PostgreSQL URL (?ssl=require for Azure Database for PostgreSQL). |
COOKIE_SECURE | true behind 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 Container Apps. |
AZURE_EXTENSION_DIR | Stable Azure CLI extension location. The production image and Bicep deployment use /opt/az-extensions, which already contains resource-graph. |
LLM_API_KEY | Optional. A model provider key can be configured in Settings instead. |
Keep every secret in a Container App secret or an approved secret store. Never bake one into the image or a template parameter file.
Deploy from scratch
$RG = "rg-azsupagent"
$LOC = "southcentralus"
$ACR = "azsupagent$((Get-Random -Maximum 99999))" # globally unique
$APP = "azsupagent"
az account set --subscription "<subscription-id>"
# 1) Registry (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)"
Prefer a versioned tag or digest over reusing latest. See upgrades and uninstall.
Cost and scaling
- Lowest-cost posture: Basic ACR plus a Consumption Container App with
--min-replicas 0(no compute charge while idle) at 0.5 vCPU / 1 GiB. The first request after idle pays a cold start, but the Azure MCP package and Resource Graph extension are already in the image and are not fetched at runtime. - Use a single replica while depending on SQLite or in-container state. Set
--min-replicas 1to avoid cold starts, at higher cost.
Production guardrails
- Set
COOKIE_SECURE=truebehind HTTPS. - Keep the bootstrap password in a platform secret and change it at first sign-in.
- Protect database credentials and the application’s secrets-encryption key.
- Use one replica when depending on SQLite or in-memory coordination. A shared database alone does not make every in-memory workflow horizontally scalable.
- Keep Azure MCP read-only unless a reviewed workflow requires writes; product write paths remain permission- and approval-gated.
- Keep the root image’s
AZURE_EXTENSION_DIR=/opt/az-extensionssetting. The image bakes theresource-graphAzure CLI extension there so temporary service-principal sessions do not install it dynamically. - Restrict ingress and outbound traffic deliberately if private networking is required.
Validate the result
- The Container App revision is healthy and serving the expected immutable image.
/healthzand/readyzsucceed.- Refreshing a client-side route such as
/workloadsreturns the SPA, not a 404. - Database data survives a revision restart.
- The managed identity or service principal can list only the intended Azure scopes.
- No credentials appear in revision logs or environment-variable exports.
Troubleshooting
| Symptom | What to check |
|---|---|
| Container exits at startup | Application logs, dependency installation, database URL, and mounted paths |
| SPA loads but API calls fail | /api routing, frontend build-time API base, ingress, and CORS/public URL settings |
| Data disappears after a revision | Azure Files mount or PostgreSQL connection; SQLite must not live only in the image filesystem |
| Deep links return 404 | Requests must reach FastAPI’s SPA fallback, not a static host without rewrite rules |
| Azure tools cannot authenticate | Managed-identity assignment or service-principal variables and RBAC scope |
az graph is missing intermittently | Confirm the current image contains /opt/az-extensions and the revision sets AZURE_EXTENSION_DIR to it; rebuild rather than dynamically installing into each temporary CLI profile |
| First request is slow | Scale-to-zero cold start and initial MCP process startup; production packages are already image-baked |
Build-time gotchas
az acr build’s log streamer can crash locally on Windows withUnicodeEncodeError: '\u2713'(colorama on cp1252). The build still runs server-side; pollaz acr task list-runs -r <acr> --top 1 -o table, or stream logs to a UTF-8 console without piping or redirecting.az containerapp up --source .can fail with'NoneType' object has no attribute 'linux'. Use the explicitacr create→acr build→containerapp createsequence above instead.- The Dockerfile copies
backend/beforepip installbecausesetuptools packages=["app"]validates the package directory at build time. - The container imports the whole API at startup, so a missing dependency crashes uvicorn immediately. Keep
backend/requirements.txtcomplete. - If
az graphis reported missing in a service-principal session, verify the revision was built from the root Dockerfile and still setsAZURE_EXTENSION_DIR=/opt/az-extensions. Rebuild the image rather than working around it with repeated dynamic installs into temporary config directories.