Skip to content

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:

bicep
// Stage 1: no domain set → empty customDomains array
// Stage 2: domain set, deploy_custom_domain=false → Disabled binding (registers hostname only)
// Stage 3: domain set, deploy_custom_domain=true  → SniEnabled (issues managed TLS cert)

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_appkey_vault (SWA token stored in KV)
  • key_vaultswa_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

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