09 — Infrastructure as Code (Bicep)
Dev Guide — Savoy Signature Hotels
PRD refs:02_Infrastructure_and_Environments.md,07_SECURITY_AND_ENVIRONMENTS.md
Design spec:docs/superpowers/specs/2026-03-14-dev-infrastructure-design.md
1. Overview
Section titled “1. Overview”All Azure infrastructure is provisioned via Bicep templates stored in infra/. Each environment (DEV, STAGE, QA, PROD) uses the same templates with different parameter files. This guide covers the template structure, resource configuration, deployment commands, and CI/CD pipeline.
2. Template Structure
Section titled “2. Template Structure”infra/ main.bicep ← Orchestrator — calls all modules main.parameters.dev.json ← Parameters for DEV main.parameters.stage.json ← Parameters for STAGE (future) main.parameters.prod.json ← Parameters for PROD (future) modules/ sql-server.bicep ← Azure SQL Server + Database storage-account.bicep ← Blob Storage + containers (media, form-uploads) key-vault.bicep ← Key Vault + access policies (grants GET to App Service Managed Identities) app-service-plan.bicep ← Shared App Service Plan app-service-umbraco.bicep ← App Service .NET 10 (Umbraco CMS) app-service-nextjs.bicep ← App Service Node.js 20 (Next.js) app-insights.bicep ← Application Insights + Log Analytics Workspace2.1 Module Design Principles
Section titled “2.1 Module Design Principles”- Each module is a standalone
.bicepfile inmodules/ - Modules receive only what they need via parameters
- Modules output resource IDs and connection info for cross-references
main.bicepwires modules together via outputs- All resources tagged with
environment,project: savoy,managedBy: bicep
3. Orchestrator Parameters
Section titled “3. Orchestrator Parameters”main.bicep accepts the following parameters:
| Parameter | Type | DEV | STAGE | PROD | Description |
|---|---|---|---|---|---|
environment | string | dev | stage | prod | Environment name (used in resource naming) |
location | string | westeurope | westeurope | westeurope | Azure region |
appServicePlanSku | string | B1 | B2 | P1v3 | App Service Plan SKU |
sqlSkuName | string | Basic | Standard | Standard | SQL Database SKU |
sqlSkuDtu | int | 5 | 20 | 50 | SQL DTU capacity |
enableVnet | bool | false | true | true | Enable VNet + Private Endpoints |
sqlAdminLogin | securestring | — | — | — | SQL admin username |
sqlAdminPassword | securestring | — | — | — | SQL admin password |
umbracoApiKey | securestring | — | — | — | Manually generated GUID |
revalidateSecret | securestring | — | — | — | Manually generated GUID |
4. Naming Convention
Section titled “4. Naming Convention”Pattern: {resource-prefix}-savoy-{env}
| Resource | Pattern | DEV Example |
|---|---|---|
| Resource Group | rg-savoy-{env} | rg-savoy-dev |
| App Service Plan | plan-savoy-{env} | plan-savoy-dev |
| App Service (Umbraco) | app-umbraco-savoy-{env} | app-umbraco-savoy-dev |
| App Service (Next.js) | app-nextjs-savoy-{env} | app-nextjs-savoy-dev |
| Azure SQL Server | sql-savoy-{env} | sql-savoy-dev |
| Azure SQL Database | sqldb-umbraco-savoy-{env} | sqldb-umbraco-savoy-dev |
| Storage Account | stsavoy{env} | stsavoydev |
| Key Vault | kv-savoy-{env} | kv-savoy-dev |
| Application Insights | appi-savoy-{env} | appi-savoy-dev |
| Log Analytics | law-savoy-{env} | law-savoy-dev |
5. Resource Configuration
Section titled “5. Resource Configuration”5.1 App Service Plan
Section titled “5.1 App Service Plan”| Setting | DEV | STAGE/PROD |
|---|---|---|
| SKU | B1 | B2 / P1v3 |
| OS | Linux | Linux |
| Shared by | Both Umbraco + Next.js | Separate plans per app |
5.2 App Service — Umbraco (.NET 10)
Section titled “5.2 App Service — Umbraco (.NET 10)”| Setting | Value |
|---|---|
| Runtime | .NET 10 LTS (`DOTNETCORE |
| Always-on | Enabled |
| Managed Identity | System-Assigned (for Key Vault access) |
ConnectionStrings__umbracoDbDSN | Key Vault reference → sql-connection-string |
ConnectionStrings__umbracoDbDSN_ProviderName | Microsoft.Data.SqlClient |
Umbraco__CMS__DeliveryApi__ApiKey | Key Vault reference → umbraco-api-key |
Savoy__AllowedOrigins__0 | https://app-nextjs-savoy-{env}.azurewebsites.net |
ASPNETCORE_ENVIRONMENT | Production |
APPLICATIONINSIGHTS_CONNECTION_STRING | Output from App Insights module |
Umbraco__CMS__Global__Smtp__Username | Key Vault reference → mailjet-api-key |
Umbraco__CMS__Global__Smtp__Password | Key Vault reference → mailjet-secret-key |
5.3 App Service — Next.js (Node.js 20)
Section titled “5.3 App Service — Next.js (Node.js 20)”| Setting | Value |
|---|---|
| Runtime | Node.js 20 LTS |
| Startup command | node server.js |
| Managed Identity | System-Assigned (for Key Vault access) |
UMBRACO_API_URL | https://app-umbraco-savoy-{env}.azurewebsites.net |
UMBRACO_API_KEY | Key Vault reference → umbraco-api-key |
UMBRACO_MEDIA_URL | https://app-umbraco-savoy-{env}.azurewebsites.net |
REVALIDATE_SECRET | Key Vault reference → revalidate-secret |
NODE_ENV | production (required by Next.js runtime) |
APP_ENV | dev / stage / prod (for feature flags) |
APPLICATIONINSIGHTS_CONNECTION_STRING | Output from App Insights module |
CLOUDFLARE_PURGE_ENABLED | false (set to true to activate cache purge + warmup) |
CLOUDFLARE_ZONE_ID | Key Vault reference → cloudflare-zone-id |
CLOUDFLARE_API_TOKEN | Key Vault reference → cloudflare-api-token |
Important:
NODE_ENVmust always beproductionfor deployed Next.js apps. UseAPP_ENVfor environment-specific logic.Cache purge is disabled by default. Set
CLOUDFLARE_PURGE_ENABLED=truein App Settings to activate Cloudflare cache purge on content publish. RequiresCLOUDFLARE_ZONE_IDandCLOUDFLARE_API_TOKENto be configured in Key Vault.
5.4 Azure SQL Database
Section titled “5.4 Azure SQL Database”| Setting | DEV | STAGE/PROD |
|---|---|---|
| SKU | Basic (5 DTU) | Standard S2 (50 DTU) |
| TDE | Enabled | Enabled |
| Firewall | Allow Azure services | VNet Private Endpoint |
| Backup | Default (PITR 7 days) | PITR 35 days + geo-replication |
5.5 Storage Account
Section titled “5.5 Storage Account”| Setting | DEV | STAGE/PROD |
|---|---|---|
| Redundancy | LRS | GRS |
| Access tier | Hot | Hot |
| Containers | media (blob public read), form-uploads (private) | Same |
5.6 Key Vault
Section titled “5.6 Key Vault”| Setting | Value |
|---|---|
| SKU | Standard |
| Access policies | GET secrets → both App Service Managed Identities |
| Secrets | umbraco-api-key, revalidate-secret, sql-connection-string, blob-connection-string, mailjet-api-key, mailjet-secret-key |
Secrets
umbraco-api-keyandrevalidate-secretare manually generated GUIDs passed as secure parameters on first deploy. Connection strings are constructed by Bicep from SQL and Storage module outputs. Mailjet API keys are obtained from the Mailjet dashboard (see Guide 07, Section 5).
5.7 Application Insights + Log Analytics
Section titled “5.7 Application Insights + Log Analytics”| Setting | DEV | STAGE/PROD |
|---|---|---|
| Retention | 30 days | 90 days |
| Linked to | Both App Services | Both App Services |
| Sampling | Adaptive | Adaptive |
6. Deployment
Section titled “6. Deployment”6.1 Prerequisites (one-time, manual)
Section titled “6.1 Prerequisites (one-time, manual)”-
Create Resource Group:
Terminal window az group create --name rg-savoy-dev --location westeurope -
Generate secrets:
Terminal window # Generate GUIDs for API key and revalidate secretuuidgen # → use as umbracoApiKeyuuidgen # → use as revalidateSecret -
Create Azure DevOps Service Connection: Azure DevOps → Project Settings → Service Connections → Azure Resource Manager → scope to
rg-savoy-dev
6.2 Initial Deploy (CLI)
Section titled “6.2 Initial Deploy (CLI)”az deployment group create \ --resource-group rg-savoy-dev \ --template-file infra/main.bicep \ --parameters @infra/main.parameters.dev.json \ --parameters sqlAdminLogin=<admin-user> \ sqlAdminPassword=<admin-password> \ umbracoApiKey=<generated-guid> \ revalidateSecret=<generated-guid>6.3 Subsequent Deploys
Section titled “6.3 Subsequent Deploys”Handled by the CI/CD pipeline (see Section 7). Infrastructure changes are applied by re-running the Bicep deployment from CLI — the pipeline deploys application code only.
6.4 Creating Additional Environments
Section titled “6.4 Creating Additional Environments”# Create resource groupaz group create --name rg-savoy-stage --location westeurope
# Deploy with STAGE parametersaz deployment group create \ --resource-group rg-savoy-stage \ --template-file infra/main.bicep \ --parameters @infra/main.parameters.stage.json \ --parameters sqlAdminLogin=<admin-user> \ sqlAdminPassword=<admin-password> \ umbracoApiKey=<generated-guid> \ revalidateSecret=<generated-guid>7. CI/CD Pipeline
Section titled “7. CI/CD Pipeline”7.1 Pipeline File
Section titled “7.1 Pipeline File”.azure/pipelines/deploy-dev.yml
7.2 Triggers
Section titled “7.2 Triggers”- CI (validation): Runs on PRs targeting
deploy/dev(validation gate before merge) - CD (deploy): Runs on push to
deploy/dev(after PR merge)
Feature PRs to develop do not trigger CI — they merge freely. CI only runs when deploying.
7.3 Stages
Section titled “7.3 Stages”PR from develop → deploy/dev → [CI] Lint + TypeCheck + Unit Tests + Build (Next.js + Umbraco + Storybook) → [1 approver required] → [Merge] → [CD] Deploy Umbraco → Deploy Next.js → Smoke Test| Stage | Job | Commands / Actions |
|---|---|---|
| CI | lint-typecheck | pnpm install --frozen-lockfile && pnpm lint && pnpm typecheck |
unit-tests | pnpm test | |
build-nextjs | Docker build (apps/web/Dockerfile) + push to ACR acrsavoydev.azurecr.io/savoy-nextjs | |
build-umbraco | dotnet publish -c Release → publish artifact | |
build-storybook | pnpm --filter storybook build + wrangler pages deploy to Cloudflare Pages (savoy-storybook-dev) | |
| CD | deploy-umbraco | Azure App Service deploy (.NET zip) — Umbraco migrations run on startup |
deploy-nextjs | Restart App Service to pull latest Docker image from ACR | |
smoke-test | curl health endpoints on both apps to verify response |
7.4 Skip Flags
Section titled “7.4 Skip Flags”Add [skip:*] flags to the PR title to skip builds/deploys (read via Azure DevOps API during pipeline initialization):
| Flag | Effect |
|---|---|
[skip:umbraco] | Skip Umbraco build + deploy |
[skip:nextjs] | Skip Next.js Docker build + deploy |
[skip:storybook] | Skip Storybook build |
[skip:deploy] | Skip entire CD stage |
Example: fix(ui): button color [skip:umbraco] [skip:storybook]
7.4 Deploy Order
Section titled “7.4 Deploy Order”Umbraco deploys before Next.js because:
- Schema migrations may add Element Types or properties that the frontend expects
- The Content Delivery API must be available before Next.js starts fetching
7.5 Next.js Docker Deployment
Section titled “7.5 Next.js Docker Deployment”Next.js is deployed as a Docker container, NOT as a zip deploy. This is mandatory because Azure Oryx runtime interferes with pnpm standalone output.
Dockerfile: apps/web/Dockerfile (multi-stage: deps → build → runner on node:20-alpine)
Important rules:
- Build context is the repo root (not
apps/web/) - Pipeline builds on
ubuntu-latest(amd64) — never build locally for deployment (Apple Silicon is arm64) - ACR pull uses Managed Identity with AcrPull role
- App Service configured as Docker container (
linuxFxVersion: DOCKER|acrsavoydev.azurecr.io/savoy-nextjs:latest) WEBSITES_PORT=8080must be set on the App Service
7.6 Existing Pipelines (unchanged)
Section titled “7.6 Existing Pipelines (unchanged)”.azure/pipelines/validate-branch-name.yml— validates PR branch naming convention.azure/pipelines/validate-main-source.yml— ensures PRs tomaincome only fromdevelop
8. Post-Deploy Configuration (manual, one-time)
Section titled “8. Post-Deploy Configuration (manual, one-time)”After the first successful deployment:
- Umbraco initial setup — Access
https://savoy-dev-cms.wycreative.com(redirects to/umbraco) and complete the install wizard - Configure webhook — In Umbraco backoffice, create a webhook that POSTs to
https://savoy-dev-signature.wycreative.com/api/webhooks/umbracousing theREVALIDATE_SECRETfor HMAC signing - Seed content — Run the setup scripts to populate the CMS (see Section 8.1)
- Verify API — Confirm Content Delivery API responds at
https://savoy-dev-cms.wycreative.com/umbraco/delivery/api/v2/content - Auth gate — Verify
GATE_ENABLED=true,GATE_SECRET, andGATE_COOKIE_DOMAINare set on both App Services (Next.js + Umbraco)
8.1 Seeding Content with Setup Scripts
Section titled “8.1 Seeding Content with Setup Scripts”All CMS setup scripts accept --env=<name> to target remote environments. Default is local.
# 1. Create MCP API user (Playwright login → creates API user + client credentials)node apps/cms/scripts/setup-mcp-user.mjs --env=dev
# 2. Create content tree (8 hotel sites + page structure)node apps/cms/scripts/setup-content-tree.mjs --env=dev
# 3. Create media library foldersnode apps/cms/scripts/setup-media-folders.mjs --env=dev
# 4. Populate with demo modules (all sites)node apps/cms/scripts/setup-demo-content.mjs --env=dev
# 4b. Or a single sitenode apps/cms/scripts/setup-demo-content.mjs --env=dev savoy-palace
# 5. Seed M08 rooms (6 room pages per site with images + itemCards module block)node apps/cms/scripts/seed-m08-rooms.mjs --env=dev
# 5b. Or a single sitenode apps/cms/scripts/seed-m08-rooms.mjs --env=dev savoy-palace| Environment | --env value | URL |
|---|---|---|
| Local | local (default) | https://localhost:44385 |
| DEV | dev | https://app-umbraco-savoy-dev.azurewebsites.net |
| STAGE | stage | https://app-umbraco-savoy-stage.azurewebsites.net |
| PROD | prod | https://cms.savoysignature.com |
Prerequisites: Playwright installed (pnpm install from monorepo root). Step 1 must run before steps 2-4.
Configuration: Environment URLs and credentials are defined in apps/cms/scripts/_env.mjs. All scripts are idempotent — safe to re-run.
9. DNS and Domains
Section titled “9. DNS and Domains”9.1 Primary URLs (Cloudflare proxied — requires auth gate login)
Section titled “9.1 Primary URLs (Cloudflare proxied — requires auth gate login)”| App | URL |
|---|---|
| Umbraco CMS | https://savoy-dev-cms.wycreative.com (redirects to /umbraco) |
| Next.js (Signature) | https://savoy-dev-signature.wycreative.com |
| Next.js (other sites) | https://savoy-dev-{site-key}.wycreative.com |
| Storybook | https://savoy-dev-storybook.wycreative.com (Cloudflare Pages) |
All DEV domains use CNAME records proxied through Cloudflare (savoy-dev-*.wycreative.com). SSL is managed by Cloudflare at the edge.
9.2 Direct Azure URLs (backup — bypasses auth gate)
Section titled “9.2 Direct Azure URLs (backup — bypasses auth gate)”| App | URL |
|---|---|
| Umbraco CMS | https://app-umbraco-savoy-dev.azurewebsites.net |
| Next.js | https://app-nextjs-savoy-dev.azurewebsites.net |
Direct Azure URLs bypass the auth gate and Cloudflare. Use only for emergency debugging.
10. Security
Section titled “10. Security”| Measure | DEV | STAGE/PROD |
|---|---|---|
| Secrets | Key Vault + Managed Identity | Key Vault + Managed Identity |
| SQL access | Firewall: Allow Azure services | VNet Private Endpoint |
| Blob access | media = public read, form-uploads = private | Same + CDN |
| HTTPS | Cloudflare edge + Azure origin | Cloudflare Full (Strict) |
| Auth Gate | Umbraco backoffice login required (__dev_gate JWT cookie) | Cloudflare Zero Trust / IP whitelist |
| Backoffice | Via Cloudflare proxy (savoy-dev-cms.wycreative.com) | Cloudflare Zero Trust / IP whitelist |
| WAF | Cloudflare (proxied) | Cloudflare managed rules |
| App Insights | Enabled | Enabled |
11. Cost Estimates
Section titled “11. Cost Estimates”| Resource | DEV (EUR/month) | STAGE | PROD |
|---|---|---|---|
| App Service Plan | ~€12 (B1) | ~€25 (B2) | ~€100 (P1v3) |
| Azure SQL | ~€5 (Basic) | ~€30 (S1) | ~€75 (S2) |
| Storage Account | ~€1 (LRS) | ~€2 (GRS) | ~€5 (GRS) |
| Key Vault | ~€1 | ~€1 | ~€1 |
| Application Insights | ~€5-10 | ~€10-20 | ~€30-50 |
| Log Analytics | ~€2-5 | ~€5-10 | ~€10-20 |
| VNet / Private Endpoints | — | ~€15 | ~€15 |
| Total | ~€26-34 | ~€88-103 | ~€236-266 |
12. Environment Comparison
Section titled “12. Environment Comparison”| Aspect | DEV | STAGE | QA | PROD |
|---|---|---|---|---|
| Domain | savoy-dev-*.wycreative.com | savoy-stage-*.wycreative.com | qa-*.savoysignature.com | *.savoysignature.com |
| Deploy trigger | PR from develop → deploy/dev (CI + 1 approver) | Auto on merge to staging | Manual promotion | Manual + approval |
| VNet | No | Yes | Yes | Yes |
| Cloudflare | Bypassed | Enabled (test cache) | Production-like | Full caching |
| SQL backup | 7-day PITR | 35-day PITR | 35-day PITR | 35-day PITR + geo-replication |
| Storage | LRS | GRS | GRS | GRS |
| Scaling | Fixed (B1) | Fixed (B2) | Auto-scale | Auto-scale (min 2, max 6) |
13. Troubleshooting
Section titled “13. Troubleshooting”| Problem | Check |
|---|---|
| App Service not starting | App Service logs → Log Stream; check APPLICATIONINSIGHTS_CONNECTION_STRING is set |
| Key Vault access denied | Verify Managed Identity is enabled and access policy grants GET |
| SQL connection failed | Check firewall allows Azure services; verify connection string in Key Vault |
| Umbraco migrations failed | App Service logs → SavoyMigrationPlan errors; check SQL permissions |
| Next.js 500 errors | Verify UMBRACO_API_URL resolves; check Umbraco is running and API key matches |
| CORS errors | Check Savoy__AllowedOrigins__0 matches the Next.js app URL exactly |