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
SKUB2 (1 vCPU, 3.5 GB RAM)B2 / P1v3
OSLinuxLinux
Shared byBoth Umbraco + Next.jsSeparate plans per app
alwaysOn (Umbraco)truetrue
alwaysOn (Next.js)truetrue

DEV ran on B1 from initial bootstrap until 2026-05-06. Memory was sustained at 80–90% (one vCPU, 1.75 GB RAM shared by both apps), causing Umbraco backoffice slowness and dashboard cache warmup OOM-borderline. Upgraded to B2 — 2× memory, 2× CPU, +€21/mo. See § 11 for the monthly cost breakdown and change history.

alwaysOn=true is required on both apps. Cold starts otherwise add 10–30 s on Next.js after 20 min idle. Only feasible once the plan has the headroom (was off on Next.js while on B1 to leave RAM for Umbraco).

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
Savoy__Cloudflare__ZoneIdKey Vault reference → cloudflare-zone-id
Savoy__Cloudflare__ApiTokenKey Vault reference → cloudflare-api-token
Savoy__AzureMetrics__SubscriptionIdAzure subscription GUID (manual injection — see § 5.2.1)
Savoy__AzureMetrics__ResourceGrouprg-savoy-{env} (manual injection — see § 5.2.1)
Savoy__AzureMetrics__Resources__UmbracoAppapp-umbraco-savoy-{env} (manual injection — see § 5.2.1)
Savoy__AzureMetrics__Resources__NextjsAppapp-nextjs-savoy-{env} (manual injection — see § 5.2.1)
Savoy__AzureMetrics__Resources__SqlServersql-savoy-{env} (manual injection — see § 5.2.1)
Savoy__AzureMetrics__Resources__SqlDatabasesqldb-umbraco-savoy-{env} (manual injection — see § 5.2.1)
Savoy__AzureMetrics__Resources__StorageAccountstsavoy{env} (manual injection — see § 5.2.1)
Savoy__BlobStorage__Accountstsavoy{env} (manual injection — see § 5.2.2)
Savoy__KeyVault__Urihttps://kv-savoy-{env}.vault.azure.net/ (manual injection — see § 5.2.2)

ASPNETCORE_ENVIRONMENT determines which appsettings.{Environment}.json is loaded. Set Dev for the DEV App Service (loads appsettings.Dev.json with DEV-specific Cloudflare, site URLs, and Azure resource names). Set Production for PROD. The Savoy:Cloudflare:Enabled flag is true in both appsettings.Dev.json and appsettings.Production.json — cache purge is active in all deployed environments.

Environment config files:

FileLoaded whenPurpose
appsettings.jsonAlwaysBase config (Cloudflare disabled, empty credentials)
appsettings.Development.jsonASPNETCORE_ENVIRONMENT=Development (local dev)Local overrides, __DEV_OVERRIDE__ placeholders
appsettings.Dev.jsonASPNETCORE_ENVIRONMENT=Dev (DEV App Service)DEV site URLs, Cloudflare enabled, Azure resource names
appsettings.Production.jsonASPNETCORE_ENVIRONMENT=Production (PROD App Service)PROD site URLs, Cloudflare enabled

5.2.1 AzureMetrics, BlobStorage, KeyVault, Alerting — Wired via Bicep

Section titled “5.2.1 AzureMetrics, BlobStorage, KeyVault, Alerting — Wired via Bicep”

Resolved (2026-04-30, P894-T726 Phase 3): Previously these settings required manual az webapp config appsettings set injection. They are now wired via Bicep — infra/modules/app-service-umbraco.bicep provisions all 7 AzureMetrics keys (subscription/RG/resource names from Bicep functions), the 2 BlobStorage + KeyVault Uri keys (from module outputs), the 7 Alerting Better Uptime KV references, and the 1 Alerting toggle.

When you run scripts/deploy/deploy-infra.sh <env>, every one of these settings appears on the Umbraco App Service automatically. No follow-up az webapp step required. Operator action remains for the K3 secrets behind the Better Uptime URLs (populate via bootstrap-env.sh from 1Password — see § 6.1 below).

Non-secret toggles (set directly via App Settings, not Key Vault):

  • Savoy__Alerting__Enabled — master toggle (true / false)
  • Savoy__Alerting__BetterUptime__Enabled — enables Better Uptime pings
  • Savoy__Alerting__Email__Enabled — enables fallback email alerts
  • Savoy__Alerting__Email__FromAddress, Savoy__Alerting__Email__FromName
  • Savoy__Alerting__Email__Recipients__0, Savoy__Alerting__Email__Recipients__1, … (array indices)

5.2.2 SQL connection string — required parameters

Section titled “5.2.2 SQL connection string — required parameters”

The sql-connection-string Key Vault secret (K4 — provisioned by key-vault.bicep at deploy time) must include the following parameters:

ParameterValueWhy
MultipleActiveResultSetsTrueWithout it, Umbraco’s IScopeProvider intermittently throws "There is already an open DataReader associated with this Connection which must be closed first." whenever the dashboard runs concurrent queries (SeoAuditService, DashboardCacheWarmupService, SqlHealthCheck).
ConnectRetryCount3Transparent SqlClient retry on initial connection failures. Azure SQL has occasional transient drops, especially around scaling events on Standard tier (vCore re-allocation, plan change). Default is 1; bumping to 3 covers a ~30s outage window.
ConnectRetryInterval10Seconds between retries. Default is 10s; kept explicit so anyone reading the secret value sees the budget.
Connection Lifetime300Proactively recycles pooled connections every 5 minutes so we never reuse a connection that’s been idle long enough to be silently dropped by Azure SQL Gateway (~30 min idle timeout). Mitigates the “connection broken and recovery is not possible” SqlException that triggered an Umbraco AsyncLocal scope corruption on 2026-05-06 (the dashboard widget went red and only a process restart recovered).
Connection Timeout30Initial connect timeout (default 30s, kept explicit).

To apply these to an existing environment:

Terminal window
scripts/azure/set-sql-mars.sh <env> # dev | stage | prod

The script is idempotent (no-op if MARS is already True), reads + writes the secret value via Python + --file (avoids bash special-character mangling that previously corrupted the secret), and resets the App Setting ConnectionStrings__umbracoDbDSN to an unpinned KV reference + restarts the App Service.

MARS is the most critical of these. The retry/lifetime parameters reduce the frequency of transient-fault-induced AsyncLocal scope corruption but do not eliminate it — the underlying Umbraco bug (broken transaction reference surviving in AsyncLocal) requires a process restart to recover. The new direct-connection-based SqlHealthCheck (see apps/cms/Savoy.Cms/Dashboard/Services/Heartbeat/Checks/SqlHealthCheck.cs) bypasses Umbraco’s IScopeProvider so the dashboard reports honest SQL reachability even when the rest of the app is in a poisoned-scope state.

Why a script and not az keyvault secret set --value: the connection string contains ; separators and a generated password that may include $, &, or other shell-special characters. Passing it through --value "$VAR" round-trips through bash and silently mangles the password. We learned this the hard way (2026-05-01); the script exists so the operation is reproducible without that hazard.

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

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

Cache purge is configured on the CMS App Service, not Next.js. Cloudflare credentials (Savoy:Cloudflare:ZoneId, Savoy:Cloudflare:ApiToken) and the enable flag (Savoy:Cloudflare:Enabled) are set in Umbraco’s appsettings.Dev.json / appsettings.Production.json and injected via Azure App Settings referencing Key Vault. See Guide 04 for details.

SettingDEVSTAGE/PROD
SKUStandard S1 (20 DTU)Standard S2 (50 DTU)
TDEEnabledEnabled
FirewallAllow Azure servicesVNet Private Endpoint
BackupPITR 35 days (Standard tier default)PITR 35 days + geo-replication
Max size2 GB250 GB

DEV ran on Basic (5 DTU) from bootstrap until 2026-05-06. Even though peak DTU was only ~7%, the Basic tier is unsuitable for the development workflow: PITR retention is capped at 7 days (vs 35 on Standard), no auto-tuning of indexes, and bursty queries (dashboard fan-out across 8 sites) exhaust the 5-DTU quantum. Upgraded to Standard S1 — 4× DTU, 4× retention, +€20/mo. See § 11.

Reassess only if dtu_consumption_percent p95 sustained >40% — in which case S2 (50 DTU) is the next step.

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, cloudflare-zone-id, cloudflare-api-token

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). Cloudflare zone ID and API token are obtained from the Cloudflare dashboard and used by the CMS for cache purge (see Guide 04).

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

  1. Azure CLI signed into the WYcreative subscription:

    Terminal window
    brew install azure-cli && az login
  2. 1Password CLI signed into the Savoy / Infra vault:

    Terminal window
    brew install --cask 1password-cli && op signin
  3. Savoy / Infra 1Password vault populated per the contract in 12_SECRETS_POLICY.md § 5:

    • savoy-sql-admin-{env} (K1 — generated, 32-char random)
    • savoy-mailjet, savoy-cloudflare-api, savoy-better-uptime (K3 — issued by providers)
  4. Azure DevOps Service Connection scoped to the target resource group: ADO → Project Settings → Service Connections → Azure Resource Manager → rg-savoy-{env}

6.2 Provision a brand-new environment (DEV / STAGE / PROD)

Section titled “6.2 Provision a brand-new environment (DEV / STAGE / PROD)”

Three scripted steps, no interactive prompts, no secrets in shell history:

Terminal window
# Step 1 — create the resource group (one-time, manual)
az group create --name rg-savoy-stage --location westeurope
# Step 2 — first Bicep deploy (creates Key Vault empty of K2/K3 secrets;
# App Services may report KV reference resolution errors at this point —
# expected, the next step fixes it)
bash scripts/deploy/deploy-infra.sh stage
# Step 3 — bootstrap K2 (openssl rand) + K3 (1Password) into Key Vault
bash scripts/deploy/bootstrap-env.sh stage
# Step 4 — re-deploy Bicep so App Services read the now-populated secrets
bash scripts/deploy/deploy-infra.sh stage
# Step 5 — verify (KV refs resolve, heartbeats Healthy, gate works, CF ok)
bash scripts/deploy/verify-env.sh stage

What each script does:

  • deploy-infra.sh &lt;env&gt; — reads K1 (SQL admin) from 1Password, runs az deployment group create with the corresponding infra/main.parameters.{env}.json. K2/K3/K4 never flow through Bicep params.
  • bootstrap-env.sh &lt;env&gt; — generates K2 secrets via openssl rand -hex 32 (idempotent — preserves existing); reads K3 from 1Password (op read); writes both to kv-savoy-{env} via az keyvault secret set.
  • verify-env.sh &lt;env&gt; — green/red checklist: KV secrets populated, App Service KV refs resolve, CMS heartbeat reports Healthy, dev gate enforced + bypass works, Cloudflare API token resolves the zone.

After the initial bootstrap, operators just run deploy-infra.sh for code/infra changes and verify-env.sh to confirm. K2 secrets are preserved across re-deploys (Bicep does not rewrite KV secrets it didn’t create); K3 secrets re-sync from 1Password only when bootstrap-env.sh is rerun (which is also the rotation flow — rotate at provider → update 1Password → re-run script).


.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. Verify cache purge — Confirm ASPNETCORE_ENVIRONMENT=Dev is set on the CMS App Service (loads appsettings.Dev.json with Savoy:Cloudflare:Enabled=true). Verify Savoy__Cloudflare__ZoneId and Savoy__Cloudflare__ApiToken App Settings are configured. Cache purge is automatic via notification handlers — no webhook configuration needed.
  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

This is the operational source of truth. When you change a SKU, scale a tier, or notice costs drift, update this section. The Bicep params (infra/main.parameters.{env}.json) and this section MUST stay aligned — otherwise re-deploys will silently downgrade resources.

ResourceNameSKUSpecsNotes
App Service Planplan-savoy-devB21 vCPU, 3.5 GB RAM, LinuxShared by Umbraco + Next.js
App Service (Umbraco)app-umbraco-savoy-devDOTNETCORE|10.0, alwaysOn=true
App Service (Next.js)app-nextjs-savoy-devLinux container from ACR, alwaysOn=true
Azure SQL Databasesqldb-umbraco-savoy-devStandard S120 DTU, 2 GB max, MARS enabledPITR 35 days
Container RegistryacrsavoydevBasic10 GB storage, ~3 GB usedHolds savoy-nextjs Docker image
Storage AccountstsavoydevStandard_LRSHot tier, StorageV2Containers: media, form-uploads, savoy-docs-backups
Key Vaultkv-savoy-devStandardAll K1–K5 secretsRBAC + Managed Identity
Log Analyticslaw-savoy-devPerGB2018Retention 30 days
Application Insightsappi-savoy-devLogAnalytics-basedRetention 90 daysLinked to law-savoy-dev

Last 30 days (Cost Management API, rg-savoy-dev, billed in EUR), before the 2026-05-06 upgrades:

ResourceCost% of total
plan-savoy-dev (B1 → B2 since 2026-05-06)€20.8371%
acrsavoydev (Basic)€4.3515%
sqldb-umbraco-savoy-dev (Basic 5 DTU → S1 20 DTU since 2026-05-06)€4.2614%
Key Vault, Storage, App Insights, Log Analytics<€0.10<1%
Total billed (last 30d)€29.44

90-day trend: Mar €17.35 · Apr €31.03 · May (partial 6 days) €3.58 → projection ~€18.

11.3 Projected costs after 2026-05-06 upgrades

Section titled “11.3 Projected costs after 2026-05-06 upgrades”
ResourcePre-upgradePost-upgradeΔ
plan-savoy-dev~€12.41 (B1)~€42 (B2)+€21
sqldb-umbraco-savoy-dev~€4.26 (Basic 5 DTU)~€26 (S1 20 DTU)+€22
ACR + Storage + KV + AI/LAW~€4.50~€4.50
Projected total~€21~€72+€51/mo

Numbers are list price from the Azure portal “Service and compute tier” cost summary, in EUR (USD figures × current FX). Real billed amounts will be visible after one full billing cycle (early June 2026).

11.4 Capacity headroom (post-upgrade targets)

Section titled “11.4 Capacity headroom (post-upgrade targets)”
MetricB1 saturation (last 7d)B2 expectedTrigger to upgrade
Memory %84% avg, 91% peak<60%Sustained >75% → P0v3 / split plans
CPU %69% avg, 100% peak<50%Sustained >70% → P0v3
SQL DTU % (peak)7% (Basic 5 DTU)<5% on S1Sustained p95 >40% → S2
DateChangeWhyCost Δ
2026-05-06SQL connection string ConnectRetryCount=3 + Connection Lifetime=300Transient connection break corrupted Umbraco AsyncLocal scope; new SqlHealthCheck uses direct SqlConnection so dashboard stays honest under scope corruption€0
2026-05-06App Service Plan B1 → B2Memory >80% sustained 79% of time on B1; backoffice slowness+€21/mo
2026-05-06alwaysOn=true on Next.jsEliminate cold starts (was off due to B1 RAM pressure)€0 (within plan)
2026-05-06SQL Basic 5 DTU → Standard S1 20 DTUBursty dashboard queries; PITR 7→35 days; auto-tuning+€22/mo
2026-05-04MARS=True on sql-connection-stringDataReader contention on concurrent dashboard queries€0

11.6 Cost estimates — STAGE / QA / PROD (target SKUs, not yet provisioned)

Section titled “11.6 Cost estimates — STAGE / QA / PROD (target SKUs, not yet provisioned)”
ResourceSTAGEQAPROD
App Service Plan~€42 (B2 shared)~€110 (P1v3 Umbraco) + ~€110 (P1v3 Next.js)~€110 (P1v3 Umbraco) + ~€110 (P1v3 Next.js) auto-scale 2–6
Azure SQL~€26 (S1 20 DTU)~€56 (S2 50 DTU)~€56 (S2 50 DTU) + geo-replication
Storage~€2 (GRS)~€5 (GRS)~€10 (GRS, larger media)
Key Vault~€1~€1~€1
App Insights + LAW~€10-15~€20-30~€40-60
VNet + Private Endpoints~€15~€15~€15
CloudflareFree tierPro $20Pro $20
Subtotal~€95~€330~€420 (excl. autoscale spikes)

See PRD 02_Infrastructure_and_Environments.md § 9 for the rationale and capacity assumptions.


AspectDEVSTAGEQAPROD
Resource grouprg-savoy-devrg-savoy-stagerg-savoy-qarg-savoy-prod
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
App Service PlanB2 (shared)B2 (shared)P1v3 (per app)P1v3 (per app)
SQL tierStandard S1 (20 DTU)Standard S1 (20 DTU)Standard S2 (50 DTU)Standard S2 (50 DTU) + geo-replica
VNetNoYesYesYes
CloudflareBypassedEnabled (test cache)Production-likeFull caching
SQL backupPITR 35 days (Standard)PITR 35 daysPITR 35 daysPITR 35 days + geo-replication
Storage redundancyLRSGRSGRSGRS
ScalingFixed (B2)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