Skip to content

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


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.


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 Workspace
  • Each module is a standalone .bicep file in modules/
  • Modules receive only what they need via parameters
  • Modules output resource IDs and connection info for cross-references
  • main.bicep wires modules together via outputs
  • All resources tagged with environment, project: savoy, managedBy: bicep

main.bicep accepts the following parameters:

ParameterTypeDEVSTAGEPRODDescription
environmentstringdevstageprodEnvironment name (used in resource naming)
locationstringwesteuropewesteuropewesteuropeAzure region
appServicePlanSkustringB1B2P1v3App Service Plan SKU
sqlSkuNamestringBasicStandardStandardSQL Database SKU
sqlSkuDtuint52050SQL DTU capacity
enableVnetboolfalsetruetrueEnable VNet + Private Endpoints
sqlAdminLoginsecurestringSQL admin username
sqlAdminPasswordsecurestringSQL admin password
umbracoApiKeysecurestringManually generated GUID
revalidateSecretsecurestringManually generated GUID

Pattern: {resource-prefix}-savoy-{env}

ResourcePatternDEV Example
Resource Grouprg-savoy-{env}rg-savoy-dev
App Service Planplan-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 Serversql-savoy-{env}sql-savoy-dev
Azure SQL Databasesqldb-umbraco-savoy-{env}sqldb-umbraco-savoy-dev
Storage Accountstsavoy{env}stsavoydev
Key Vaultkv-savoy-{env}kv-savoy-dev
Application Insightsappi-savoy-{env}appi-savoy-dev
Log Analyticslaw-savoy-{env}law-savoy-dev

SettingDEVSTAGE/PROD
SKUB1B2 / P1v3
OSLinuxLinux
Shared byBoth Umbraco + Next.jsSeparate plans per app
SettingValue
Runtime.NET 10 LTS (`DOTNETCORE
Always-onEnabled
Managed IdentitySystem-Assigned (for Key Vault access)
ConnectionStrings__umbracoDbDSNKey Vault reference → sql-connection-string
ConnectionStrings__umbracoDbDSN_ProviderNameMicrosoft.Data.SqlClient
Umbraco__CMS__DeliveryApi__ApiKeyKey Vault reference → umbraco-api-key
Savoy__AllowedOrigins__0https://app-nextjs-savoy-{env}.azurewebsites.net
ASPNETCORE_ENVIRONMENTProduction
APPLICATIONINSIGHTS_CONNECTION_STRINGOutput from App Insights module
Umbraco__CMS__Global__Smtp__UsernameKey Vault reference → mailjet-api-key
Umbraco__CMS__Global__Smtp__PasswordKey Vault reference → mailjet-secret-key
SettingValue
RuntimeNode.js 20 LTS
Startup commandnode server.js
Managed IdentitySystem-Assigned (for Key Vault access)
UMBRACO_API_URLhttps://app-umbraco-savoy-{env}.azurewebsites.net
UMBRACO_API_KEYKey Vault reference → umbraco-api-key
UMBRACO_MEDIA_URLhttps://app-umbraco-savoy-{env}.azurewebsites.net
REVALIDATE_SECRETKey Vault reference → revalidate-secret
NODE_ENVproduction (required by Next.js runtime)
APP_ENVdev / stage / prod (for feature flags)
APPLICATIONINSIGHTS_CONNECTION_STRINGOutput from App Insights module
CLOUDFLARE_PURGE_ENABLEDfalse (set to true to activate cache purge + warmup)
CLOUDFLARE_ZONE_IDKey Vault reference → cloudflare-zone-id
CLOUDFLARE_API_TOKENKey Vault reference → cloudflare-api-token

Important: NODE_ENV must always be production for deployed Next.js apps. Use APP_ENV for environment-specific logic.

Cache purge is disabled by default. Set CLOUDFLARE_PURGE_ENABLED=true in App Settings to activate Cloudflare cache purge on content publish. Requires CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN to be configured in Key Vault.

SettingDEVSTAGE/PROD
SKUBasic (5 DTU)Standard S2 (50 DTU)
TDEEnabledEnabled
FirewallAllow Azure servicesVNet Private Endpoint
BackupDefault (PITR 7 days)PITR 35 days + geo-replication
SettingDEVSTAGE/PROD
RedundancyLRSGRS
Access tierHotHot
Containersmedia (blob public read), form-uploads (private)Same
SettingValue
SKUStandard
Access policiesGET secrets → both App Service Managed Identities
Secretsumbraco-api-key, revalidate-secret, sql-connection-string, blob-connection-string, mailjet-api-key, mailjet-secret-key

Secrets umbraco-api-key and revalidate-secret are 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).

SettingDEVSTAGE/PROD
Retention30 days90 days
Linked toBoth App ServicesBoth App Services
SamplingAdaptiveAdaptive

  1. Create Resource Group:

    Terminal window
    az group create --name rg-savoy-dev --location westeurope
  2. Generate secrets:

    Terminal window
    # Generate GUIDs for API key and revalidate secret
    uuidgen # → use as umbracoApiKey
    uuidgen # → use as revalidateSecret
  3. Create Azure DevOps Service Connection: Azure DevOps → Project Settings → Service Connections → Azure Resource Manager → scope to rg-savoy-dev

Terminal window
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>

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.

Terminal window
# Create resource group
az group create --name rg-savoy-stage --location westeurope
# Deploy with STAGE parameters
az 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>

.azure/pipelines/deploy-dev.yml

  • 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.

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
StageJobCommands / Actions
CIlint-typecheckpnpm install --frozen-lockfile && pnpm lint && pnpm typecheck
unit-testspnpm test
build-nextjsDocker build (apps/web/Dockerfile) + push to ACR acrsavoydev.azurecr.io/savoy-nextjs
build-umbracodotnet publish -c Release → publish artifact
build-storybookpnpm --filter storybook build + wrangler pages deploy to Cloudflare Pages (savoy-storybook-dev)
CDdeploy-umbracoAzure App Service deploy (.NET zip) — Umbraco migrations run on startup
deploy-nextjsRestart App Service to pull latest Docker image from ACR
smoke-testcurl health endpoints on both apps to verify response

Add [skip:*] flags to the PR title to skip builds/deploys (read via Azure DevOps API during pipeline initialization):

FlagEffect
[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]

Umbraco deploys before Next.js because:

  1. Schema migrations may add Element Types or properties that the frontend expects
  2. The Content Delivery API must be available before Next.js starts fetching

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=8080 must be set on the App Service
  • .azure/pipelines/validate-branch-name.yml — validates PR branch naming convention
  • .azure/pipelines/validate-main-source.yml — ensures PRs to main come only from develop

8. Post-Deploy Configuration (manual, one-time)

Section titled “8. Post-Deploy Configuration (manual, one-time)”

After the first successful deployment:

  1. Umbraco initial setup — Access https://savoy-dev-cms.wycreative.com (redirects to /umbraco) and complete the install wizard
  2. Configure webhook — In Umbraco backoffice, create a webhook that POSTs to https://savoy-dev-signature.wycreative.com/api/webhooks/umbraco using the REVALIDATE_SECRET for HMAC signing
  3. Seed content — Run the setup scripts to populate the CMS (see Section 8.1)
  4. Verify API — Confirm Content Delivery API responds at https://savoy-dev-cms.wycreative.com/umbraco/delivery/api/v2/content
  5. Auth gate — Verify GATE_ENABLED=true, GATE_SECRET, and GATE_COOKIE_DOMAIN are set on both App Services (Next.js + Umbraco)

All CMS setup scripts accept --env=&lt;name&gt; to target remote environments. Default is local.

Terminal window
# 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 folders
node 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 site
node 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 site
node apps/cms/scripts/seed-m08-rooms.mjs --env=dev savoy-palace
Environment--env valueURL
Locallocal (default)https://localhost:44385
DEVdevhttps://app-umbraco-savoy-dev.azurewebsites.net
STAGEstagehttps://app-umbraco-savoy-stage.azurewebsites.net
PRODprodhttps://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.1 Primary URLs (Cloudflare proxied — requires auth gate login)

Section titled “9.1 Primary URLs (Cloudflare proxied — requires auth gate login)”
AppURL
Umbraco CMShttps://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
Storybookhttps://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)”
AppURL
Umbraco CMShttps://app-umbraco-savoy-dev.azurewebsites.net
Next.jshttps://app-nextjs-savoy-dev.azurewebsites.net

Direct Azure URLs bypass the auth gate and Cloudflare. Use only for emergency debugging.


MeasureDEVSTAGE/PROD
SecretsKey Vault + Managed IdentityKey Vault + Managed Identity
SQL accessFirewall: Allow Azure servicesVNet Private Endpoint
Blob accessmedia = public read, form-uploads = privateSame + CDN
HTTPSCloudflare edge + Azure originCloudflare Full (Strict)
Auth GateUmbraco backoffice login required (__dev_gate JWT cookie)Cloudflare Zero Trust / IP whitelist
BackofficeVia Cloudflare proxy (savoy-dev-cms.wycreative.com)Cloudflare Zero Trust / IP whitelist
WAFCloudflare (proxied)Cloudflare managed rules
App InsightsEnabledEnabled

ResourceDEV (EUR/month)STAGEPROD
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

AspectDEVSTAGEQAPROD
Domainsavoy-dev-*.wycreative.comsavoy-stage-*.wycreative.comqa-*.savoysignature.com*.savoysignature.com
Deploy triggerPR from developdeploy/dev (CI + 1 approver)Auto on merge to stagingManual promotionManual + approval
VNetNoYesYesYes
CloudflareBypassedEnabled (test cache)Production-likeFull caching
SQL backup7-day PITR35-day PITR35-day PITR35-day PITR + geo-replication
StorageLRSGRSGRSGRS
ScalingFixed (B1)Fixed (B2)Auto-scaleAuto-scale (min 2, max 6)

ProblemCheck
App Service not startingApp Service logs → Log Stream; check APPLICATIONINSIGHTS_CONNECTION_STRING is set
Key Vault access deniedVerify Managed Identity is enabled and access policy grants GET
SQL connection failedCheck firewall allows Azure services; verify connection string in Key Vault
Umbraco migrations failedApp Service logs → SavoyMigrationPlan errors; check SQL permissions
Next.js 500 errorsVerify UMBRACO_API_URL resolves; check Umbraco is running and API key matches
CORS errorsCheck Savoy__AllowedOrigins__0 matches the Next.js app URL exactly