Bicep Structure¶
The infra/ directory contains Bicep templates for deploying the portfolio to Azure. All code follows snake_case naming and CAF naming conventions.
File Overview¶
infra/
├── main.bicep # Per-environment orchestrator (subscription scope)
├── shared.bicep # Shared infra: DNS zone + domain (subscription scope)
├── modules/
│ ├── container_apps.bicep # Container Apps Environment, apps (frontend, backend, UIGen, CMS), managed cert
│ ├── container_registry.bicep # Azure Container Registry
│ ├── key_vault.bicep # Azure Key Vault
│ ├── monitoring.bicep # Log Analytics + Application Insights
│ ├── static_web_app.bicep # Static Web App (MkDocs docs, Entra auth settings)
│ ├── swa_auth.bicep # SWA Entra ID auth (RBAC + appsettings, dev only)
│ ├── dns_zone.bicep # DNS zone, TXT/CNAME records
│ ├── app_service_domain.bicep # Domain purchase via Azure App Service Domain
│ ├── app_configuration.bicep # Azure App Configuration store (shared RG, Free tier)
│ ├── app_configuration_rbac.bicep # RBAC: App Config Data Reader for backend MI
│ └── postgres.bicep # PostgreSQL Flexible Server (AAD-only auth)
├── parameters.dev.bicepparam # Dev environment parameters
├── parameters.prod.bicepparam # Prod environment parameters
├── parameters.shared.bicepparam # Shared infrastructure parameters
└── scripts/
├── setup-oidc.sh # OIDC + email secrets setup
└── teardown-oidc.sh # OIDC cleanup
infra/main.json, infra/shared.json, and infra/parameters.*.json are Bicep build artifacts — gitignored.
Parameters¶
main.bicep (per-environment)¶
| Parameter | Description | Default |
|---|---|---|
environment |
Environment name (dev/prod) |
dev |
location |
Azure region | westeurope |
admin_email |
Admin email for notifications | — |
custom_domain |
Custom domain hostname to bind | '' |
uigen_custom_domain |
Custom domain for UIGen (e.g. admin.sven-relijveld.com). Leave empty to skip. |
'' |
deploy_uigen_custom_domain |
Bind managed certificate for UIGen custom domain | false |
deploy_containers |
Deploy Container App images | true |
deploy_custom_domain |
Issue managed cert and bind SniEnabled |
false |
swa_custom_domain |
Custom domain for Static Web App | '' |
deploy_swa_custom_domain |
Bind SWA custom domain (cname-delegation) | false |
image_tag |
Image tag to deploy (e.g. latest, pr-42, sha-abc1234) |
latest |
uigen_image_tag |
Image tag for UIGen. Defaults to image_tag. |
'' |
email_address |
Gmail address for SMTP. Injected from GitHub Secrets; leave empty for demo mode. | '' |
email_password |
Gmail app password for SMTP. Injected from GitHub Secrets; leave empty for demo mode. | '' |
enable_frontend_auth |
Enable Entra ID SSO (EasyAuth) on the frontend Container App | false |
enable_uigen_auth |
Enable Entra ID SSO (EasyAuth) and Key Vault secret injection on the UIGen Container App | false |
entra_app_client_id |
Entra app registration client ID | '' |
entra_app_client_secret |
Entra app client secret (injected at deploy time) | '' |
deploy_postgres |
Deploy Azure PostgreSQL Flexible Server | false |
postgres_location |
Azure region for PostgreSQL Flexible Server (may differ from location) |
location |
oidc_sp_object_id |
Object ID of the OIDC service principal (for Cost Management Reader assignment) | '' |
app_config_endpoint |
App Configuration store HTTPS endpoint | '' |
app_config_resource_id |
App Configuration resource ID (for RBAC role assignment) | '' |
feature_cv_generation |
Fallback value for FEATURE_CV_GENERATION when App Config endpoint is not set |
false |
deploy_cms |
Deploy the CMS Container App | false |
cms_custom_domain |
Custom domain hostname for the CMS (e.g. cms.sven-relijveld.com). Leave empty to skip. |
'' |
deploy_cms_custom_domain |
Bind managed certificate for CMS custom domain | false |
mslearn_client_id |
Entra app registration client ID for Microsoft Learn OAuth credential sync | '' |
tags |
Tags to apply to all resources | see source |
shared.bicep (once, environment-agnostic)¶
| Parameter | Description | Default |
|---|---|---|
domain_name |
Root domain (e.g. sven-relijveld.com) |
— |
purchase_domain |
Purchase domain via App Service Domain | false |
contact_* |
Non-sensitive registrant fields (stored in file) | — |
contact_phone/address/city/* |
Injected from GitHub Secrets at runtime | — |
ca_env_verification_id |
Prod Container Apps env verification ID (discovered at runtime) | '' |
frontend_fqdn |
Prod frontend Container App FQDN (discovered) | '' |
dev_ca_env_verification_id |
Dev Container Apps env verification ID (discovered) | '' |
dev_frontend_fqdn |
Dev frontend Container App FQDN (discovered) | '' |
prod_swa_hostname |
Prod SWA default hostname (discovered) | '' |
dev_swa_hostname |
Dev SWA default hostname (discovered) | '' |
Outputs¶
After deployment main.bicep emits:
| Output | Description |
|---|---|
resource_group_name |
Resource Group name |
key_vault_name |
Key Vault name |
container_registry_name |
Container Registry name |
container_registry_login_server |
ACR login server URL |
frontend_url |
Frontend Container App URL |
backend_url |
Backend Container App URL |
frontend_app_name |
Frontend Container App resource name |
backend_app_name |
Backend Container App resource name |
container_app_env_name |
Container Apps Environment resource name |
app_insights_instrumentation_key |
Application Insights instrumentation key |
deploy_containers |
Whether containers were deployed in this run |
uigen_url |
UIGen Container App URL |
uigen_app_name |
UIGen Container App resource name |
postgres_server_name |
PostgreSQL Flexible Server name (empty if deploy_postgres=false) |
postgres_server_rg |
Resource group containing the PostgreSQL server |
Module Design Notes¶
container_apps.bicep¶
Managed identities and RBAC role assignments are unconditional — they deploy regardless of deploy_containers. This ensures roles propagate in Azure AD before the next workflow run creates the Container Apps.
minReplicas is environment-aware: dev uses 0 (scale-to-zero on idle, HTTP trigger wakes on first request ~30–60 s cold start), prod uses 1 (always warm).
UIGen's EasyAuth configuration excludes /auth/token and /auth/callback from the authentication gate so the OAuth PKCE flow can complete. Without this exclusion, EasyAuth intercepts the token exchange and the UIGen login loop never resolves.
Custom domain binding uses a 3-way ternary:
Both parameters.prod.bicepparam and parameters.dev.bicepparam are at Stage 3. deploy-infrastructure.yml always overrides deploy_custom_domain=false for the env-only deploy step to prevent cert issuance before the Container App exists.
The CMS Container App is deployed conditionally (if (deploy_cms)). It uses the same 3-way custom-domain ternary as the frontend — cms_custom_domain + deploy_cms_custom_domain control the binding stage. When deploy_cms=false the CMS app and its associated storage account are not created.
The cert-sync ACA job (Microsoft.App/jobs@2023-05-01) deploys alongside the Container Apps. It uses a Schedule trigger (0 6 * * 1 — every Monday 06:00 UTC) and reuses the backend image with command python -m api.jobs.sync_certifications. It shares the backend managed identity for Key Vault and ACR access. The mslearn-refresh-token Key Vault secret is injected as MSLEARN_REFRESH_TOKEN; MSLEARN_CLIENT_ID is a plain env var. When mslearn_client_id is empty the MS Learn env vars are not injected and the sync skips gracefully.
container_registry.bicep¶
ACR naming removes hyphens (Azure requirement): dna${env}portfolio${location}acr. Admin user is disabled; the Container App's managed identity uses AcrPull for image pulls.
key_vault.bicep¶
Names are limited to 24 characters and must be globally unique. The module builds a base name then appends a 6-character suffix from uniqueString(resourceGroup().id), truncating if needed. RBAC authorization mode is used (enableRbacAuthorization: true).
Stores: email-address, email-password, entra-app-client-secret (dev only), static-web-app-api-token, and mslearn-refresh-token (MS Learn OAuth refresh token for credential sync). The SWA token is not passed through Bicep — deploy-infrastructure.yml fetches it via az staticwebapp secrets list after the SWA is deployed and writes it directly to Key Vault.
postgres.bicep¶
Deployed conditionally (if (deploy_postgres)). Uses AAD-only authentication — no password is set. The backend managed identity is assigned as the PostgreSQL AAD administrator. The database is named portfolio. PostgreSQL is deployed to uksouth (migrated from northeurope in May 2026 to Standard_B1ms Burstable tier, saving ~€23/mo; West Europe quota is restricted for Flexible Server).
The DATABASE_URL environment variable injected into the backend container uses the format:
postgresql+asyncpg://<mi-principal-name>@<fqdn>/portfolio?ssl=require
app_configuration.bicep / app_configuration_rbac.bicep¶
app_configuration.bicep creates the Azure App Configuration store in the shared resource group (dna-shared-portfolio-westeurope-rg, Free tier). The cv-generation feature flag is defined here with per-environment labels (dev/prod).
app_configuration_rbac.bicep assigns both App Configuration Data Reader and App Configuration Data Owner roles to the backend managed identity. Data Owner is required so the backend MI can write flag values via PATCH /api/feature-flags; Data Reader is retained for read access. This is deployed via main.bicep (per-environment) using the app_config_resource_id output from the shared infra deployment.
swa_auth.bicep¶
Deployed conditionally (if (enable_frontend_auth && !empty(entra_app_client_id))) after both static_web_app and key_vault modules to avoid a circular dependency:
static_web_app→key_vault(SWA token stored in KV)key_vault→swa_auth(KV reference used in SWA appsettings)
Grants the SWA system-assigned managed identity Key Vault Secrets User on the Key Vault, then sets appsettings with the Entra client ID and secret directly (Static Web Apps does not support the @Microsoft.KeyVault() reference syntax used by App Service).
Code Standards¶
- snake_case for all variables, parameters, and output names
- CAF naming for all Azure resources (see Resource Overview)
- Modules are self-contained — each module outputs everything the parent needs
Next Steps¶
- Security — managed identities, Key Vault, ACR admin, email credentials flow
- Resource Overview — naming convention and resource map
- Pipeline Overview — how CI/CD uses these templates
Page history
| Field | Value |
|---|---|
| Last updated | 2026-07-03 |
Changelog
| Date | PRs | Summary |
|---|---|---|
| 2026-03-17 | #77 | Added swa_auth.bicep module, enable_auth/entra params to main.bicep, scale-to-zero note for dev Container Apps. Removed swa_api_token param from key_vault.bicep (token now written imperatively by deploy-infrastructure.yml). |
| 2026-04-21 | #181, #183, #243, #247 | Add postgres.bicep, app_configuration.bicep, app_configuration_rbac.bicep modules to file overview; add deploy_postgres, app_config_endpoint, app_config_resource_id parameters; add postgres outputs; add module design notes for postgres and app_configuration |
| 2026-05-19 | #438, #455 | Update container_apps.bicep note: UIGen EasyAuth excludes /auth/token and /auth/callback; update app_configuration_rbac note: MI needs Data Owner for feature-flag writes; update postgres.bicep location note to uksouth |
| 2026-05-30 | #463, #469 | Split enable_auth parameter into enable_frontend_auth (frontend EasyAuth) and enable_uigen_auth (UIGen EasyAuth + KV secret); prod has enable_uigen_auth=true, enable_frontend_auth=false. |
| 2026-06-02 | #486, #497, #512, #516 | Add CMS Bicep params (deploy_cms, cms_custom_domain, deploy_cms_custom_domain) and CMS Container App to container_apps.bicep description |
| 2026-07-03 | #606 | Add mslearn_client_id parameter; document cert-sync ACA job in container_apps.bicep notes; add mslearn-refresh-token to key_vault.bicep secrets list |