02. Platform foundation
Outcome
Every plane from the overview has a home Azure resource, each workload has its own least-privilege managed identity, and the whole footprint is described as code so it can be recreated and reviewed. The self-hosted MLflow app is running. This is Phase 0 of the delivery journey (docs/07); it implements docs/01.
The foundation is intentionally small — a container registry, a container-apps environment, two Postgres databases, one storage account, a Key Vault, a Log Analytics workspace, and Grafana. There is no Service Bus, Redis, Durable Functions storage, or Azure ML workspace here; those appear only if a documented upgrade (broker, Ch 09) or exception (multi-GPU, Ch 08) is triggered.
## Design — resource inventory
The entire baseline footprint (docs/01):
| Resource | Purpose |
|---|---|
| Azure Container Registry | Immutable images for Jobs, serving App, MLflow, dashboard — referenced by digest, never latest |
| Container Apps Environment | Hosts all Jobs and Apps; Log Analytics attached |
| Postgres (flexible server) | mlflow DB (registry/tracking) + results DB (run state) — one small server, two logical databases, separate logins |
| Azure Storage (Blob) | MLflow artifacts, batch outputs, immutable manifests — separate containers per concern |
| Azure Key Vault | Connection strings / unavoidable secrets — read via managed identity, never baked into images |
| Log Analytics workspace | Infra telemetry and alert rules; feeds App Insights and Grafana |
| Azure Managed Grafana | Deep operational dashboards; reads Log Analytics + Postgres |
| Microsoft Entra ID | Per-workload managed identities + dashboard sign-in (Easy Auth) |
## Design — identities and RBAC
Every workload gets its own user-assigned managed identity with the minimum roles it needs. No workload shares an identity; none gets broad Contributor.
| Identity | Assigned to | Roles (least privilege) |
|---|---|---|
id-jobs-train |
Training/eval Jobs | ACR pull; Blob read/write; Postgres mlflow + results; Key Vault get |
id-jobs-batch |
Batch inference Jobs | ACR pull; Blob read/write; Postgres results; Blob read of MLflow artifacts |
id-serving |
Serving App | ACR pull; Blob read of MLflow artifacts; Key Vault get |
id-mlflow |
MLflow App | Postgres mlflow; Blob read/write (artifacts) |
id-dashboard |
Dashboard App | ACA execution start (scoped Jobs); Postgres read of results; Log Analytics read |
id-ci (OIDC) |
GitHub Actions | ACR push; ACA Job/App definition update; no runtime data access |
Human access is separate from these machine identities: people sign in through the dashboard’s Entra Easy Auth, and who may do what is controlled by Entra security groups (ml-platform-operators, ml-platform-viewers), not by managed identities.
## Build in projects/ml-platform/
Phase 0 authors the infrastructure and the self-hosted MLflow image. The source lives in the project and is what the rest of the course builds on — this chapter just wires it together:
projects/ml-platform/
├── infra/
│ ├── main.tf # composes foundation + mlflow_app (two-pass)
│ ├── variables.tf outputs.tf
│ ├── grants.sql # Postgres principals + least-priv grants
│ ├── environments/
│ │ └── dev.tfvars
│ └── modules/
│ ├── foundation/ # RG, ACR, ACA env, Log Analytics, storage,
│ │ ├── main.tf # Key Vault, Postgres (Entra-only), Grafana
│ │ ├── identities.tf # 6 user-assigned identities + scoped RBAC
│ │ ├── variables.tf outputs.tf
│ └── mlflow_app/ # MLflow ACA App (image-pinned, 2nd pass)
│ └── main.tf variables.tf outputs.tf
├── src/mlflow_app/ # self-hosted MLflow container image
│ ├── Dockerfile # pinned MLflow + psycopg + azure-storage-blob
│ ├── requirements.txt
│ └── entrypoint.sh # fetches Entra token → mlflow server
└── deploy/
└── deploy.ps1 # two-pass deploy orchestration
The registered-model identity used everywhere downstream comes from this MLflow app, so it is part of the foundation rather than a later add-on.
## How the pieces connect
Auth is managed-identity end to end — no passwords in Terraform, no secrets in Git. Three details make that work:
- Postgres is Entra-only.
foundation/main.tfsetsauthentication { password_auth_enabled = false, active_directory_auth_enabled = true }and names the deployer as AAD admin. Workloads connect with an access token as the libpq password (seesrc/mlflow_app/entrypoint.sh, which callsDefaultAzureCredentialfor theossrdbms-aadscope). - Least privilege is declarative except per-database grants. Azure RBAC (AcrPull, Storage Blob roles, Key Vault Secrets User, the custom
job_starterandaca_deployerroles) lives infoundation/identities.tf. Postgres role membership can’t be expressed inazurerm, soinfra/grants.sqlmaps each managed identity to a Postgres principal with only the databases it needs — the one deliberate exception to pure IaC (docs/01). - The MLflow App is image-pinned, so it deploys in a second pass.
main.tfguards the module withcount = var.mlflow_image == "" ? 0 : 1.deploy.ps1applies the foundation, builds+pushes the image to ACR, runsgrants.sql, then re-applies with the pinned digest.
# from projects/ml-platform/ (pass your own Entra admin UPN)
./deploy/deploy.ps1 -TfVars infra/environments/dev.tfvars -PgAdminUpn you@example.comPrerequisite (
docs/01, open decision): writing role assignments needs User Access Administrator on the RG. The deployer has only Contributor today, so this grant is requested before the first apply.
## Golden-path position & acceptance evidence
Foundation sits before the golden path: it is the ground every step stands on. Nothing in the path can run until Phase 0 exists.
Acceptance evidence (not “terraform apply succeeded”):
terraform planis clean and every resource is created by IaC, reviewable in Git.- Each identity exists with only its listed roles (no broad
Contributor). - The MLflow app answers over HTTP and its registry is backed by the
mlflowPostgres DB and Blob artifact store — a registered test model appears in both. - Tear-down (
terraform destroyfordev) leaves no residual billable resources.
## Extensions (deferred from the MVP)
What the production contract (docs/01, docs/06) asks for that this Phase-0 MVP intentionally defers:
| Deferred | Contract | MVP substitute |
|---|---|---|
| Private endpoints / VNet integration | docs/01 |
Public access with IP firewall rules |
| Governance / Azure Policy | docs/01 |
Manual review of IaC |
| Identity bootstrap ordering (deployer → workload identities) | docs/01 |
Single deployment script, shared RG |
| Multiple environments (dev/stage/prod) | docs/06 |
One dev env, parameterized for copy |
Postgres topology split (results onto its own server) |
docs/00 open decision |
Two databases on one small server |
Next: 03 — Reproducible training & registry runs the first real workload on this foundation.