STG Deployment Runbook — Azure Container App + Auth¶
Audience: whoever actually provisions docgen's staging environment. This is the ordered, human-executed sequence — Terraform plan/apply, az ad app calls, Key Vault seeding — plus the resulting env values each side needs. It does not run anything itself; every command below is meant to be copy-pasted and reviewed by a person.
Modeled on: altaforge-ra and altaforge-sdp's existing stg/canadacentral stacks in altaforge-infrastructure. See the Terraform this runbook drives:
azure/modules/apps/docgen/backend/— the Container App moduleazure/environments/stg/canadacentral/docgen/{base,keyvault,backend,auth}/— the four stacksazure/modules/common/app-registration/— wraps the Entra app-registration resources used bydocgen/auth
Status as of writing: nothing has been applied. This is a definition + plan, not a completed deployment. Nothing in altaforge-infrastructure has been committed either — the Terraform lives on disk pending review.
0. Unknowns to resolve before step 1¶
Do not silently invent answers to these — confirm with the platform/infra owner first.
- No Dockerfile / image-build pipeline exists for altaforge-docgen yet.
docgen/pyproject.tomldefines the package (uv + hatchling-styleuv_build, Python 3.11,fastapi/uvicorndeps,docgen.server:appentrypoint on port 8769) but there is noDockerfile, nodocker-compose*.yaml, and no CI step that builds/pushes an image (checked.github/workflows/{ci,altaforge-enforce,test-frontend}.yml— none reference Docker). This must be written beforeterraform applyondocgen/backendcan succeed — the stack referencesafglobalacrshared01.azurecr.io/docgen/backend:staging-latest, which does not exist. Model the Dockerfile onaltaforge-ra'sDockerfile.backend(also confirmWORKDIRmatches what the migration job'suv run alembic upgrade headexpects —docgen/alembic.inihasscript_location = src/docgen/data/migrations, so the image's workdir must be thedocgen/package root). - Whether docgen's consumers share one Entra tenant. RA/SDP/Control-Center canadacentral apps use the
altaforgecanCIAM tenant (cfb62d3f-ffc1-4758-959f-3ead9e73182c, perdocs/work-logs/2026-07-01-vs-stg-auth-tf-audit.mdandcontrol-center/auth/terraform.tfvars). It is unconfirmed whether client-site, altaml-dealdesk, Odyssey, and Tomorrow Law all authenticate against this same tenant. If any consumer lives in a different tenant, cross-tenant client-credentials needs either a multi-tenantsign_in_audienceon docgen's API app registration or an explicit enterprise-app (service principal) provisioning of the consumer's app in docgen's tenant — a materially bigger change than what's defined here. - Whether docgen needs its own user-facing app registration at all. docgen has no login UI (
docs/solutions/README.md: "every org is a multi-tenant slice of the same DocGen engine", consumed headlessly). The delegated-user path (AZURE_APP_CLIENT_ID/JwtAuthBackend) may end up meaning "accept a token minted for the CALLING app" (i.e.,AZURE_APP_CLIENT_ID= client-site's own app id) rather than a docgen-owned registration. The Terraform defines a docgen-owned user app registration (docgen_user_appinauth/main.tf) as the simplest starting point; revisit if a consumer needs docgen to accept its own existing user tokens instead. - Whether
client-siteis a confirmed docgen consumer yet.docgen/docs/solutions/_candidates/site-resource-group.md— filed under_candidates, not a top-level solution folder likeodyssey/tomorrow-law/altaml-dealdesk. Confirm SITE's status before wiring its production client-credentials grant. - Subscription / resource names. This runbook uses the subscription id already live in
altaforge-infrastructure(ea95e48f-8bc9-40e6-8061-612a90ca50df,altaml-maz-invent) and the naming module's output foraf-stg-canadacentral-*— these are the real, already-in-use names for this repo, not invented. The Entra tenant id and the four consumer client ids below ARE placeholders (CHANGE_ME) — they must come from whoever owns those app registrations, never guessed. - Container sizing. The task that produced this runbook asked to "confirm sizing" now that docgen's progress store is DB-backed.
azure/modules/apps/docgen/backenddefaults tocpu=0.5,memory=1Gi,min_replicas=1,max_replicas=3— the same starting point RA/SDP backends use, deliberately conservative. There is no load data for docgen yet; treat these as a starting point to tune after the first real stg traffic, not a validated number.
1. Terraform — infrastructure layer¶
Run from altaforge-infrastructure/azure/environments/stg/canadacentral/docgen/. Deploy in this order (mirrors the RA/SDP "base → keyvault → backend" sequence in the root README's Stack Deployment Order):
az login
az account set --subscription ea95e48f-8bc9-40e6-8061-612a90ca50df
# 1. Resource group + managed identity
cd base && terraform init && terraform plan && terraform apply
cd ..
# 2. Pod Key Vault (+ role assignment granting the docgen identity read access)
cd keyvault && terraform init && terraform plan && terraform apply
cd ..
STOP — seed the pod Key Vault before applying backend/. backend/main.tf reads these via kv_secret_refs at runtime (not at plan time — no data "azurerm_key_vault_secret" plaintext-into-state anti-pattern):
KV=$(terraform -chdir=keyvault output -raw key_vault_name)
az keyvault secret set --vault-name "$KV" --name "app-insights-connection-string" --value "<from Layer 3 application-insights stack output>"
az keyvault secret set --vault-name "$KV" --name "docgen-db-connection-string" --value "postgresql+psycopg://<user>:<pass>@<shared-psql-host>/docgen"
az keyvault secret set --vault-name "$KV" --name "docgen-anthropic-api-key" --value "<staging Anthropic API key — direct key, not the LiteLLM gateway; see docgen CLAUDE.md LLM row>"
# Only needed if azure_m2m_scope is set in backend/terraform.tfvars (docgen calling ANOTHER altaforge-auth service m2m):
az keyvault secret set --vault-name "$KV" --name "docgen-m2m-client-secret" --value "<client secret from that OTHER service's app registration, if applicable>"
Provision the docgen database on the shared Postgres server per the standard AltaForge pattern (docs/standards/naming-and-architecture.md §5.2, CREATE DATABASE docgen; CREATE ROLE docgen_app ...) before setting docgen-db-connection-string.
# 3. Entra app registrations (see §2 below for the full auth plan before running this)
cd auth && terraform init && terraform plan && terraform apply
cd ..
terraform -chdir=auth output docgen_user_app
terraform -chdir=auth output docgen_api_app
Copy application_id from docgen_user_app and docgen_api_app, and caller_role_id/caller_role_value from docgen_api_app, into backend/terraform.tfvars (azure_app_client_id, azure_api_app_client_id) and record them for §2's consumer-side grants.
# 4. Backend Container App + migration job
cd backend && terraform init && terraform plan && terraform apply
cd ..
terraform -chdir=backend output backend_fqdn
terraform -chdir=backend output migration_job_name
Run the migration job before the first real traffic hits the new revision (same pre-deploy gate pattern as RA — see the CI/CD Architecture Guide referenced in modules/common/migration-job/main.tf):
RG=$(terraform -chdir=base output -raw resource_group_name)
JOB=$(terraform -chdir=backend output -raw migration_job_name)
az containerapp job start --name "$JOB" --resource-group "$RG"
2. Azure AD app-registration plan¶
What gets created¶
| Registration | Purpose | Terraform | Consumed by |
|---|---|---|---|
stg-canadacentral-docgen |
User-facing app — delegated user tokens | auth/main.tf → module.docgen_user_app |
docgen's JwtAuthBackend (AZURE_APP_CLIENT_ID) |
stg-canadacentral-docgen-api |
API/resource app — m2m client-credentials audience, exposes app role docgen.api.caller |
auth/main.tf → module.docgen_api_app |
docgen's ClientCredentialsAuthBackend (AZURE_API_APP_CLIENT_ID), and every consumer's ServiceTokenProvider (mode=m2m, SERVICE_TOKEN_M2M_SCOPE=api://<docgen_api_app client id>/.default) |
This is a new pattern for this repo — every existing app (control-center, VerticalScope trio, altaml/dealdesk) uses one shared registration for both delegated and (dormant) service auth. docgen genuinely needs the split because it has two distinct real callers (a signed-in human via a consumer app, and a consumer's own backend calling headlessly) validated by two different backend classes (JwtAuthBackend vs ClientCredentialsAuthBackend, routed by CompositeAuthBackend on the aud claim — see vendor/altaforge-libraries/altaforge/src/altaforge/auth/backends.py). There is no existing shared tenant/app pattern for m2m in this repo to reuse; this is the first one.
Per-consumer grant (repeat for each of the 4 apps)¶
For client-site, altaml-dealdesk, client-odyssey, client-tomorrowlaw, each needs its own existing (or new) app registration to be granted the docgen.api.caller application role on stg-canadacentral-docgen-api, so its ServiceTokenProvider (mode=m2m) can obtain a token docgen's ClientCredentialsAuthBackend accepts.
DOCGEN_API_APP_ID="<docgen_api_app.application_id from auth/ output>"
DOCGEN_API_SP_ID="<docgen_api_app.service_principal_object_id from auth/ output>"
ROLE_ID="<docgen_api_app.caller_role_id from auth/ output>"
# For each consumer app CONSUMER_SP_ID (its enterprise application / service
# principal object id — NOT its application/client id):
CONSUMER_SP_ID="<consumer's service principal object id>"
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/${CONSUMER_SP_ID}/appRoleAssignments" \
--body "{\"principalId\": \"${CONSUMER_SP_ID}\", \"resourceId\": \"${DOCGEN_API_SP_ID}\", \"appRoleId\": \"${ROLE_ID}\"}"
This is the actual enforcement point — Entra will not issue a docgen-api-audience token to a service principal that has no app-role assignment on it (service_principal_app_role_assignment_required = true on docgen_api_app). Adding api_access (a requiredResourceAccess entry) on each consumer's own app registration is optional/documentary — it makes the grant visible in the consumer app's "API permissions" portal blade, but the role assignment above is what Entra actually checks.
If any of these 4 consumer apps already has its own Terraform-managed app registration in altaforge-infrastructure (none currently reference docgen — checked), prefer adding an azuread_app_role_assignment resource there over a one-off az rest call, so the grant is reproducible.
Env vars — which side gets which id¶
| Env var | Value | Set on |
|---|---|---|
AZURE_TENANT_ID |
the Entra tenant id (§0 unknown #2) | docgen backend |
AZURE_APP_CLIENT_ID |
docgen_user_app.application_id |
docgen backend |
AZURE_API_APP_CLIENT_ID |
docgen_api_app.application_id |
docgen backend |
SERVICE_TOKEN_MODE |
m2m |
each consumer app (client-site, dealdesk, odyssey, tomorrowlaw) |
SERVICE_TOKEN_M2M_TENANT_ID |
same tenant id as above | each consumer app |
SERVICE_TOKEN_M2M_CLIENT_ID |
the CONSUMER's own app (client) id | each consumer app |
SERVICE_TOKEN_M2M_CLIENT_SECRET |
a client secret minted on the CONSUMER's own app registration | each consumer app (secret store, never committed) |
SERVICE_TOKEN_M2M_SCOPE |
api://<docgen_api_app.application_id>/.default |
each consumer app |
(SERVICE_TOKEN_* names are defined in vendor/altaforge-libraries/altaforge/src/altaforge/auth/client/config.py.)
3. Resulting values to feed each side¶
docgen's apps/web/.env.local (local) → Key Vault + Container App env (stg)¶
Already covered by §1's az keyvault secret set + backend/terraform.tfvars edits above. Summary of the full auth-relevant env set docgen's backend needs in stg:
DOCGEN_ENV=staging
DATABASE_URL=<from docgen-db-connection-string KV secret>
ANTHROPIC_API_KEY=<from docgen-anthropic-api-key KV secret>
AZURE_TENANT_ID=<tenant id>
AZURE_APP_CLIENT_ID=<docgen_user_app.application_id>
AZURE_API_APP_CLIENT_ID=<docgen_api_app.application_id>
DISABLE_AUTH must NOT be set in stg — docgen removed its /auth/dev-login bypass (docgen#363) and now validates real JWTs only.
Each consumer app's runtime env (client-site, altaml-dealdesk, client-odyssey, client-tomorrowlaw)¶
SERVICE_TOKEN_MODE=m2m
SERVICE_TOKEN_M2M_TENANT_ID=<tenant id>
SERVICE_TOKEN_M2M_CLIENT_ID=<that consumer's own app/client id>
SERVICE_TOKEN_M2M_CLIENT_SECRET=<that consumer's own client secret — Key Vault / GitHub Secret, never committed>
SERVICE_TOKEN_M2M_SCOPE=api://<docgen_api_app.application_id>/.default
For client-site specifically (the repo this task originated from): this maps to backend/.env → whatever secret store client-site's own deploy uses (see client-site/CLAUDE.md's Quick Start — backend/.env locally; production wiring for client-site's own Azure deployment is out of scope for this runbook and not yet defined in altaforge-infrastructure as of this writing).