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 | B2 (1 vCPU, 3.5 GB RAM) | B2 / P1v3 |
| OS | Linux | Linux |
| Shared by | Both Umbraco + Next.js | Separate plans per app |
alwaysOn (Umbraco) | true | true |
alwaysOn (Next.js) | true | true |
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=trueis 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).
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 |
Savoy__Cloudflare__ZoneId | Key Vault reference → cloudflare-zone-id |
Savoy__Cloudflare__ApiToken | Key Vault reference → cloudflare-api-token |
Savoy__AzureMetrics__SubscriptionId | Azure subscription GUID (manual injection — see § 5.2.1) |
Savoy__AzureMetrics__ResourceGroup | rg-savoy-{env} (manual injection — see § 5.2.1) |
Savoy__AzureMetrics__Resources__UmbracoApp | app-umbraco-savoy-{env} (manual injection — see § 5.2.1) |
Savoy__AzureMetrics__Resources__NextjsApp | app-nextjs-savoy-{env} (manual injection — see § 5.2.1) |
Savoy__AzureMetrics__Resources__SqlServer | sql-savoy-{env} (manual injection — see § 5.2.1) |
Savoy__AzureMetrics__Resources__SqlDatabase | sqldb-umbraco-savoy-{env} (manual injection — see § 5.2.1) |
Savoy__AzureMetrics__Resources__StorageAccount | stsavoy{env} (manual injection — see § 5.2.1) |
Savoy__BlobStorage__Account | stsavoy{env} (manual injection — see § 5.2.2) |
Savoy__KeyVault__Uri | https://kv-savoy-{env}.vault.azure.net/ (manual injection — see § 5.2.2) |
ASPNETCORE_ENVIRONMENTdetermines whichappsettings.{Environment}.jsonis loaded. SetDevfor the DEV App Service (loadsappsettings.Dev.jsonwith DEV-specific Cloudflare, site URLs, and Azure resource names). SetProductionfor PROD. TheSavoy:Cloudflare:Enabledflag istruein bothappsettings.Dev.jsonandappsettings.Production.json— cache purge is active in all deployed environments.Environment config files:
File Loaded when Purpose appsettings.jsonAlways Base config (Cloudflare disabled, empty credentials) appsettings.Development.jsonASPNETCORE_ENVIRONMENT=Development(local dev)Local overrides, __DEV_OVERRIDE__placeholdersappsettings.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 setinjection. They are now wired via Bicep —infra/modules/app-service-umbraco.bicepprovisions 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 pingsSavoy__Alerting__Email__Enabled— enables fallback email alertsSavoy__Alerting__Email__FromAddress,Savoy__Alerting__Email__FromNameSavoy__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:
| Parameter | Value | Why |
|---|---|---|
MultipleActiveResultSets | True | Without 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). |
ConnectRetryCount | 3 | Transparent 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. |
ConnectRetryInterval | 10 | Seconds between retries. Default is 10s; kept explicit so anyone reading the secret value sees the budget. |
Connection Lifetime | 300 | Proactively 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 Timeout | 30 | Initial connect timeout (default 30s, kept explicit). |
To apply these to an existing environment:
scripts/azure/set-sql-mars.sh <env> # dev | stage | prodThe 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(seeapps/cms/Savoy.Cms/Dashboard/Services/Heartbeat/Checks/SqlHealthCheck.cs) bypasses Umbraco’sIScopeProviderso 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.
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 |
Important:
NODE_ENVmust always beproductionfor deployed Next.js apps. UseAPP_ENVfor 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’sappsettings.Dev.json/appsettings.Production.jsonand injected via Azure App Settings referencing Key Vault. See Guide 04 for details.
5.4 Azure SQL Database
Section titled “5.4 Azure SQL Database”| Setting | DEV | STAGE/PROD |
|---|---|---|
| SKU | Standard S1 (20 DTU) | Standard S2 (50 DTU) |
| TDE | Enabled | Enabled |
| Firewall | Allow Azure services | VNet Private Endpoint |
| Backup | PITR 35 days (Standard tier default) | PITR 35 days + geo-replication |
| Max size | 2 GB | 250 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_percentp95 sustained >40% — in which case S2 (50 DTU) is the next step.
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, cloudflare-zone-id, cloudflare-api-token |
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). Cloudflare zone ID and API token are obtained from the Cloudflare dashboard and used by the CMS for cache purge (see Guide 04).
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 per operator)
Section titled “6.1 Prerequisites (one-time per operator)”-
Azure CLI signed into the WYcreative subscription:
Terminal window brew install azure-cli && az login -
1Password CLI signed into the
Savoy / Infravault:Terminal window brew install --cask 1password-cli && op signin -
Savoy / Infra1Password vault populated per the contract in12_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)
-
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:
# 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 Vaultbash scripts/deploy/bootstrap-env.sh stage
# Step 4 — re-deploy Bicep so App Services read the now-populated secretsbash scripts/deploy/deploy-infra.sh stage
# Step 5 — verify (KV refs resolve, heartbeats Healthy, gate works, CF ok)bash scripts/deploy/verify-env.sh stageWhat each script does:
deploy-infra.sh <env>— reads K1 (SQL admin) from 1Password, runsaz deployment group createwith the correspondinginfra/main.parameters.{env}.json. K2/K3/K4 never flow through Bicep params.bootstrap-env.sh <env>— generates K2 secrets viaopenssl rand -hex 32(idempotent — preserves existing); reads K3 from 1Password (op read); writes both tokv-savoy-{env}viaaz keyvault secret set.verify-env.sh <env>— 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.
6.3 Subsequent re-deploys
Section titled “6.3 Subsequent re-deploys”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).
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 - Verify cache purge — Confirm
ASPNETCORE_ENVIRONMENT=Devis set on the CMS App Service (loadsappsettings.Dev.jsonwithSavoy:Cloudflare:Enabled=true). VerifySavoy__Cloudflare__ZoneIdandSavoy__Cloudflare__ApiTokenApp Settings are configured. Cache purge is automatic via notification handlers — no webhook configuration needed. - 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. Environment State & Costs
Section titled “11. Environment State & Costs”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.
11.1 Current state — DEV (rg-savoy-dev)
Section titled “11.1 Current state — DEV (rg-savoy-dev)”| Resource | Name | SKU | Specs | Notes |
|---|---|---|---|---|
| App Service Plan | plan-savoy-dev | B2 | 1 vCPU, 3.5 GB RAM, Linux | Shared by Umbraco + Next.js |
| App Service (Umbraco) | app-umbraco-savoy-dev | — | DOTNETCORE|10.0, alwaysOn=true | |
| App Service (Next.js) | app-nextjs-savoy-dev | — | Linux container from ACR, alwaysOn=true | |
| Azure SQL Database | sqldb-umbraco-savoy-dev | Standard S1 | 20 DTU, 2 GB max, MARS enabled | PITR 35 days |
| Container Registry | acrsavoydev | Basic | 10 GB storage, ~3 GB used | Holds savoy-nextjs Docker image |
| Storage Account | stsavoydev | Standard_LRS | Hot tier, StorageV2 | Containers: media, form-uploads, savoy-docs-backups |
| Key Vault | kv-savoy-dev | Standard | All K1–K5 secrets | RBAC + Managed Identity |
| Log Analytics | law-savoy-dev | PerGB2018 | Retention 30 days | |
| Application Insights | appi-savoy-dev | LogAnalytics-based | Retention 90 days | Linked to law-savoy-dev |
11.2 Actual billed costs
Section titled “11.2 Actual billed costs”Last 30 days (Cost Management API, rg-savoy-dev, billed in EUR), before the 2026-05-06 upgrades:
| Resource | Cost | % of total |
|---|---|---|
plan-savoy-dev (B1 → B2 since 2026-05-06) | €20.83 | 71% |
acrsavoydev (Basic) | €4.35 | 15% |
sqldb-umbraco-savoy-dev (Basic 5 DTU → S1 20 DTU since 2026-05-06) | €4.26 | 14% |
| 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”| Resource | Pre-upgrade | Post-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)”| Metric | B1 saturation (last 7d) | B2 expected | Trigger 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 S1 | Sustained p95 >40% → S2 |
11.5 Change history
Section titled “11.5 Change history”| Date | Change | Why | Cost Δ |
|---|---|---|---|
| 2026-05-06 | SQL connection string ConnectRetryCount=3 + Connection Lifetime=300 | Transient connection break corrupted Umbraco AsyncLocal scope; new SqlHealthCheck uses direct SqlConnection so dashboard stays honest under scope corruption | €0 |
| 2026-05-06 | App Service Plan B1 → B2 | Memory >80% sustained 79% of time on B1; backoffice slowness | +€21/mo |
| 2026-05-06 | alwaysOn=true on Next.js | Eliminate cold starts (was off due to B1 RAM pressure) | €0 (within plan) |
| 2026-05-06 | SQL Basic 5 DTU → Standard S1 20 DTU | Bursty dashboard queries; PITR 7→35 days; auto-tuning | +€22/mo |
| 2026-05-04 | MARS=True on sql-connection-string | DataReader 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)”| Resource | STAGE | QA | PROD |
|---|---|---|---|
| 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 |
| Cloudflare | Free tier | Pro $20 | Pro $20 |
| Subtotal | ~€95 | ~€330 | ~€420 (excl. autoscale spikes) |
See PRD
02_Infrastructure_and_Environments.md§ 9 for the rationale and capacity assumptions.
12. Environment Comparison
Section titled “12. Environment Comparison”| Aspect | DEV | STAGE | QA | PROD |
|---|---|---|---|---|
| Resource group | rg-savoy-dev | rg-savoy-stage | rg-savoy-qa | rg-savoy-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 |
| App Service Plan | B2 (shared) | B2 (shared) | P1v3 (per app) | P1v3 (per app) |
| SQL tier | Standard S1 (20 DTU) | Standard S1 (20 DTU) | Standard S2 (50 DTU) | Standard S2 (50 DTU) + geo-replica |
| VNet | No | Yes | Yes | Yes |
| Cloudflare | Bypassed | Enabled (test cache) | Production-like | Full caching |
| SQL backup | PITR 35 days (Standard) | PITR 35 days | PITR 35 days | PITR 35 days + geo-replication |
| Storage redundancy | LRS | GRS | GRS | GRS |
| Scaling | Fixed (B2) | 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 |