Skip to content

07 — Security and Environments

Dev Guide — Savoy Signature Hotels
PRD refs: 15_Security_and_Data_Protection.md, 02_Infrastructure_and_Environments.md


This guide covers Azure infrastructure provisioning, secrets management, security headers, RBAC, environment configuration, CI/CD pipeline, and GDPR compliance. All environments run on Azure with Cloudflare as the edge layer.


ServiceSKUPurpose
App Service (Next.js)P1v3 or P2v3SSR app, Node.js 20.9+ LTS, auto-scale
App Service (Umbraco)P1v3 or P2v3.NET 10 LTS, always-on
Azure SQLStandard S2 (50 DTU) or General PurposeContent database
Blob StorageStandard LRS, Hot tierMedia files
Application InsightsPay-as-you-goAPM for both apps
Log AnalyticsPay-as-you-goCentralized logging (KQL queries)
Key VaultStandardSecrets management
Virtual NetworkPrivate endpoints for SQL and Blob
ComponentStrategy
Next.jsAuto-scale: min 2, max 6 instances (trigger: CPU > 70%)
UmbracoSingle instance (content editing is not high-traffic)

EnvDomain PatternResource GroupDeploy Trigger
DEVsavoy-dev-*.wycreative.comrg-savoy-devPR from developdeploy/dev (CI + 1 approver)
STAGEsavoy.stage-*.wycreative.comrg-savoy-stageAuto on PR merge to staging
QAqa-*.savoysignature.comrg-savoy-qaManual promotion
PROD*.savoysignature.com / hotelnext.ptrg-savoy-prodManual promotion + approval
VariableDEVSTAGEQAPROD
UMBRACO_API_URLhttps://savoy-dev-cms.wycreative.comhttps://savoy-stage-cms.wycreative.comhttps://qa-cms.savoysignature.comhttps://cms.savoysignature.com
CLOUDFLARE_ZONE_IDZone ID (Key Vault)Zone IDZone IDZone ID
CLOUDFLARE_API_TOKENToken (Key Vault)TokenTokenToken
NODE_ENVdevelopmentstagingproductionproduction
REVALIDATE_SECRETdev-secretstage-secretqa-secretprod-secret
NEXT_PUBLIC_GA_IDG-XXXXXXXXXX
APPINSIGHTS_KEYKeyKeyKeyKey
GATE_ENABLEDtruetruetruefalse
GATE_SECRETSecret (Key Vault)SecretSecret
GATE_COOKIE_DOMAIN.wycreative.com.wycreative.com
ExposurePrefixExamples
Server-only (never in browser)No prefixUMBRACO_API_KEY, CLOUDFLARE_API_TOKEN, REVALIDATE_SECRET, Mailjet keys
Client-side (exposed to browser)NEXT_PUBLIC_NEXT_PUBLIC_RECAPTCHA_SITE_KEY, NEXT_PUBLIC_GA_ID

Rule: NEVER expose API keys, tokens, or secrets with the NEXT_PUBLIC_ prefix.


EnvironmentEngineInjection Method
Local.env.local (git-ignored)Next.js CLI / dotnet run
CI/CDGitHub Actions SecretsBuild context variables
AzureAzure Key VaultManaged Identity at runtime

Key Vault secrets include: UMBRACO_API_KEY, CLOUDFLARE_API_TOKEN, REVALIDATE_SECRET, AZURE_BLOB_CONNECTION_STRING, mailjet-api-key, mailjet-secret-key, Azure SQL connection string.

Secret NamePurposeUsed by
umbraco-api-keyDelivery API authenticationNext.js
revalidate-secretWebhook HMAC verificationNext.js
sql-connection-stringAzure SQL connectionUmbraco
blob-connection-stringAzure Blob Storage connectionUmbraco
mailjet-api-keyMailjet SMTP usernameUmbraco
mailjet-secret-keyMailjet SMTP passwordUmbraco
cloudflare-zone-idCloudflare zone ID for cache purgeNext.js
cloudflare-api-tokenCloudflare API token (Cache Purge permission)Next.js

Cache purge is disabled by default. The webhook handler checks CLOUDFLARE_PURGE_ENABLED=true before purging. Set this App Setting to true when Cloudflare proxy is active and cache purge is ready to test.

All secret names use kebab-case. Each environment (kv-savoy-dev, kv-savoy-stage, kv-savoy-prod) has its own Key Vault with its own values.


Mailjet is the SMTP relay for all email sent by the Umbraco CMS. Next.js does not send emails.

ItemValue
ProviderMailjet (SMTP relay)
SMTP Hostin-v3.mailjet.com
Port587 (STARTTLS)
SenderOnly Umbraco CMS
From (dev/staging)[email protected]
From (prod)[email protected]
Configurationappsettings.jsonUmbraco.CMS.Global.Smtp
CredentialsAzure Key Vault (mailjet-api-key + mailjet-secret-key)
  1. Create a Mailjet account at app.mailjet.com
  2. Two accounts (or subaccounts) are required:
    • Non-production — for dev and staging environments
    • Production — for the live sites
  3. In each account, go to Account Settings > API Keys and note the API Key and Secret Key

Mailjet requires sender domain verification. Configure these DNS records in Cloudflare:

RecordTypeNameValueEnvironment
SPFTXT@v=spf1 include:spf.mailjet.com ~allBoth
DKIMTXTmailjet._domainkey(provided by Mailjet dashboard)Both
DMARCTXT_dmarcv=DMARC1; p=none; rua=mailto:dmarc@...Both

Domains to verify:

  • wycreative.com (non-prod sender)
  • savoysignature.com (prod sender)

Mailjet dashboard guides through this process: Account Settings > Sender Domains & Addresses > Manage > DNS Setup.

Add the Mailjet credentials to the Key Vault for each environment:

Terminal window
# DEV environment
az keyvault secret set --vault-name kv-savoy-dev --name mailjet-api-key --value "YOUR_DEV_API_KEY"
az keyvault secret set --vault-name kv-savoy-dev --name mailjet-secret-key --value "YOUR_DEV_SECRET_KEY"
# STAGING environment
az keyvault secret set --vault-name kv-savoy-stage --name mailjet-api-key --value "YOUR_DEV_API_KEY"
az keyvault secret set --vault-name kv-savoy-stage --name mailjet-secret-key --value "YOUR_DEV_SECRET_KEY"
# PRODUCTION environment
az keyvault secret set --vault-name kv-savoy-prod --name mailjet-api-key --value "YOUR_PROD_API_KEY"
az keyvault secret set --vault-name kv-savoy-prod --name mailjet-secret-key --value "YOUR_PROD_SECRET_KEY"

Dev and staging share the same non-prod Mailjet account. Production uses the separate prod account.

The Umbraco App Service already has Bicep entries that reference Key Vault (added in infra/modules/app-service-umbraco.bicep):

Umbraco__CMS__Global__Smtp__Username → @Microsoft.KeyVault(VaultName=kv-savoy-{env};SecretName=mailjet-api-key)
Umbraco__CMS__Global__Smtp__Password → @Microsoft.KeyVault(VaultName=kv-savoy-{env};SecretName=mailjet-secret-key)

The From address is set in appsettings.json (dev) and appsettings.Production.json (all Azure envs). To override per environment, add an App Setting in Azure Portal:

Umbraco__CMS__Global__Smtp__From = [email protected]

For local development (dotnet run), the SMTP credentials in appsettings.json are placeholders. To send real emails locally:

  1. Replace MAILJET_API_KEY_PLACEHOLDER and MAILJET_SECRET_KEY_PLACEHOLDER in appsettings.json with real non-prod Mailjet keys
  2. Never commit real credentials — the placeholders are safe to commit

Alternatively, create appsettings.Development.json (git-ignored) with the real credentials.

FilePurpose
Email/EmailComposer.csDI registration (auto-discovered by Umbraco)
Email/Services/IEmailService.csSend interface (SendAsync, SendFormSubmission, etc.)
Email/Services/SmtpEmailService.csImplementation using Umbraco’s IEmailSender
Email/Services/IEmailTemplateRenderer.csRazor template renderer interface
Email/Services/RazorEmailTemplateRenderer.csRazor.Templating.Core implementation
Email/Models/EmailBaseModel, FormSubmission, FormConfirmation, NewsletterWelcome
Email/Templates/Razor views: _EmailLayout, FormSubmission, FormConfirmation, NewsletterWelcome

Email templates have visual preview stories in Storybook under Email/:

  • Email/Form Submission — Default, LongContent, MinimalContent
  • Email/Form Confirmation — Default, PerHotel
  • Email/Newsletter Welcome — Default, PerHotel

Run pnpm --filter storybook dev and navigate to the Email section.

  • Spec: docs/superpowers/specs/2026-03-17-smtp-mailjet-configuration-design.md
  • Plan: docs/superpowers/plans/2026-03-17-smtp-mailjet-configuration.md

LayerConfiguration
WAFManaged Ruleset (Strict) — blocks SQLi, XSS, CMS exploits
Bot ManagementSuper Bot Fight Mode enabled
Rate Limiting20 req/min per IP on /api/forms/* and /api/search
DDoSUnmetered L3/L4/L7 mitigation
SSL/TLSFull (Strict), edge + origin certificates
Min TLS Version1.2
SettingValue
Always HTTPSOn
HTTP/2On
HTTP/3 (QUIC)On
BrotliOn
Auto MinifyHTML, CSS, JS

6.3 DEV Subdomain Configuration (wycreative.com)

Section titled “6.3 DEV Subdomain Configuration (wycreative.com)”

All internal development and staging environments use subdomains under wycreative.com (Cloudflare zone already active).

ServicePatternExample
Next.js (per hotel)savoy.dev-{hotel-short}.wycreative.comsavoy.dev-signature.wycreative.com
Umbraco CMSsavoy.dev-cms.wycreative.com

Complete DEV subdomain list (8 hotel sites + CMS):

SubdomainSite KeyAzure Target
savoy.dev-signature.wycreative.comsavoy-signatureapp-nextjs-savoy-dev.azurewebsites.net
savoy.dev-palace.wycreative.comsavoy-palaceapp-nextjs-savoy-dev.azurewebsites.net
savoy.dev-royal.wycreative.comroyal-savoyapp-nextjs-savoy-dev.azurewebsites.net
savoy.dev-saccharum.wycreative.comsaccharumapp-nextjs-savoy-dev.azurewebsites.net
savoy.dev-reserve.wycreative.comthe-reserveapp-nextjs-savoy-dev.azurewebsites.net
savoy.dev-calheta.wycreative.comcalheta-beachapp-nextjs-savoy-dev.azurewebsites.net
savoy.dev-gardens.wycreative.comgardensapp-nextjs-savoy-dev.azurewebsites.net
savoy.dev-next.wycreative.comhotel-nextapp-nextjs-savoy-dev.azurewebsites.net
savoy.dev-cms.wycreative.comapp-umbraco-savoy-dev.azurewebsites.net

All 8 hotel sites point to the same Next.js App Service — the site-resolver middleware uses the hostname to determine which hotel to serve.

Step-by-Step: Adding DNS Records in Cloudflare

Section titled “Step-by-Step: Adding DNS Records in Cloudflare”

Prerequisites:

  • Access to Cloudflare dashboard for wycreative.com zone
  • Azure App Service already deployed (app-nextjs-savoy-dev.azurewebsites.net and app-umbraco-savoy-dev.azurewebsites.net)

1. Add CNAME records

In Cloudflare dashboard: DNS > Records > Add record

For each hotel site (repeat 8 times):

TypeNameTargetProxy statusTTL
CNAMEsavoy-dev-signatureapp-nextjs-savoy-dev.azurewebsites.netProxied (orange cloud)Auto
CNAMEsavoy-dev-palaceapp-nextjs-savoy-dev.azurewebsites.netProxied (orange cloud)Auto
CNAMEsavoy-dev-royalapp-nextjs-savoy-dev.azurewebsites.netProxied (orange cloud)Auto
CNAMEsavoy-dev-saccharumapp-nextjs-savoy-dev.azurewebsites.netProxied (orange cloud)Auto
CNAMEsavoy-dev-reserveapp-nextjs-savoy-dev.azurewebsites.netProxied (orange cloud)Auto
CNAMEsavoy-dev-calhetaapp-nextjs-savoy-dev.azurewebsites.netProxied (orange cloud)Auto
CNAMEsavoy-dev-gardensapp-nextjs-savoy-dev.azurewebsites.netProxied (orange cloud)Auto
CNAMEsavoy-dev-nextapp-nextjs-savoy-dev.azurewebsites.netProxied (orange cloud)Auto

For the CMS:

TypeNameTargetProxy statusTTL
CNAMEsavoy-dev-cmsapp-umbraco-savoy-dev.azurewebsites.netProxied (orange cloud)Auto

For Storybook (Cloudflare Pages):

TypeNameTargetProxy statusTTL
CNAMEsavoy-dev-storybooksavoy-storybook-dev.pages.devProxied (orange cloud)Auto

DEV uses Cloudflare proxy (orange cloud) — all subdomains are proxied for consistent security (auth gate), performance monitoring, and SSL. The Dev Auth Gate requires Umbraco backoffice login before accessing any DEV site. Direct Azure URLs (*.azurewebsites.net) bypass the auth gate and should only be used for emergency debugging.

2. Configure Azure Custom Domains

After adding DNS records, bind the custom domains in Azure Portal:

For the Next.js App Service (app-nextjs-savoy-dev):

  1. Go to App Service > Custom domains > Add custom domain
  2. Add each hostname: savoy.dev-signature.wycreative.com, savoy.dev-palace.wycreative.com, etc.
  3. Azure validates the CNAME record automatically
  4. Enable App Service Managed Certificate (free) for each domain — provides SSL automatically
  5. Repeat for all 8 hotel subdomains

For the Umbraco App Service (app-umbraco-savoy-dev):

  1. Same process: Custom domains > Add custom domain
  2. Add savoy.dev-cms.wycreative.com
  3. Enable App Service Managed Certificate

Note: Azure App Service Managed Certificates work with DNS-only CNAME records. No Cloudflare origin certificate needed for DEV.

3. Verify

Terminal window
# Test DNS resolution
nslookup savoy.dev-signature.wycreative.com
# Expected: CNAME → app-nextjs-savoy-dev.azurewebsites.net → Azure IP
# Test HTTPS
curl -I https://savoy.dev-signature.wycreative.com
# Expected: 200 OK with X-Powered-By or similar header
# Test CMS
curl -I https://savoy.dev-cms.wycreative.com/umbraco
# Expected: 302 redirect to login

Staging follows the same pattern with stage instead of dev, but with Cloudflare proxy enabled (orange cloud) to match production behavior:

SubdomainTargetProxy
savoy.stage-signature.wycreative.comapp-nextjs-savoy-stage.azurewebsites.netProxied (orange cloud)
savoy.stage-cms.wycreative.comapp-umbraco-savoy-stage.azurewebsites.netProxied (orange cloud)

Staging App Services do not exist yet — create them when provisioning the staging environment (see Section 16).


  • /umbraco path is NOT publicly accessible
  • Locked via Cloudflare Zero Trust or Azure IP Restrictions
  • Only internal IPs and VPN users can access the backoffice
SettingValue
ProviderAzure AD (Microsoft Entra ID) via OpenID Connect
Local accountsDisabled (except emergency admin)
MFAEnforced at Azure AD level

Editors are scoped to specific hotel nodes:

RoleAccess
Savoy Palace EditorOnly Savoy Palace siteRoot and its children
Royal Savoy EditorOnly Royal Savoy siteRoot and its children
Group AdminAll 8 sites + Shared Content
DeveloperFull backoffice access

Configuration: Create User Groups in Umbraco with “Start Node” set to the corresponding siteRoot node.

  • Dependabot enabled for NuGet CVE scanning
  • CI/CD pipeline fails on high-severity vulnerabilities during dotnet restore

Configure in next.config.ts:

const securityHeaders = [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.google-analytics.com https://www.googletagmanager.com https://*.recaptcha.net",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://savoymedia.blob.core.windows.net https://res.cloudinary.com",
"font-src 'self' data:",
"connect-src 'self' https://api.navarino.co https://*.google-analytics.com",
].join('; '),
},
];

MechanismImplementation
HMAC SHA-256Verify X-Webhook-Signature header using REVALIDATE_SECRET
IP AllowlistRestrict to Umbraco Azure App Service outbound IP
import { createHmac } from 'crypto';
function verifyWebhookSignature(body: string, signature: string): boolean {
const expected = createHmac('sha256', process.env.REVALIDATE_SECRET!)
.update(body)
.digest('hex');
return signature === expected;
}

ScopeMethod
At rest (SQL)Azure SQL TDE with Microsoft-managed keys
At rest (Blob)Azure Storage TDE
In transitTLS 1.3 (fallback 1.2)
DataRetentionDeletion
Form submissions90 daysScheduled task auto-delete
App Insights logs30 daysStripped of request bodies
Cloudflare logs30 daysAutomatic
Azure SQL backups35 daysPITR (Point-in-Time Restore)
CI/CD logs90 daysAutomatic
SettingValue
CMP ProviderCookiebot.com
Script injectionlayout.tsx <head>
Blocking behaviorAutomatic cookie blocking (GTM, Navarino, etc. blocked before consent)
Essential cookies (exempt)Locale routing, CSRF, Cloudflare tokens
MonitoringDPO receives monthly scan reports; unclassified cookies trigger alert

PR: 1 approval

PR: 2 approvals + CI pass

PR: 2 approvals + QA sign-off + CI pass

feature/SAVOY-123-hero-module

fix/SAVOY-456-nav-bug

develop (DEV)

staging (STAGE/QA)

main (PROD)

Branch protection:

BranchRules
develop1 approval required
staging2 approvals + CI pass
main2 approvals + QA sign-off + CI pass

Feature branch naming: feature/SAVOY-{taskId}-{short-description} or fix/SAVOY-{taskId}-{short-description}


[1] Lint + Type Check

pnpm lint / pnpm typecheck

[2] Unit Tests

pnpm test --coverage

[3] Build

pnpm build (Next.js)

pnpm build:storybook (Storybook)

[4] AI QA Agent (openClaw)

Performance (Lighthouse CI)

Accessibility (axe-core + Playwright)

Visual Regression (Chromatic/Percy)

UI/UX Validation (Playwright E2E)

[5] Deploy + Purge + Smoke Tests

Terminal window
pnpm install --frozen-lockfile
pnpm lint
pnpm typecheck
pnpm test --coverage
pnpm test:e2e
pnpm build
pnpm build:storybook

Runs on a local Mac runner. Steps:

  1. PR Pickup
  2. Performance Tests (Lighthouse CI: LCP < 2.5s, FID < 100ms, CLS < 0.1)
  3. Accessibility Audit (axe-core + Playwright)
  4. Visual Regression (Chromatic/Percy)
  5. UI/UX Validation (Playwright E2E across viewports)
  6. Posts comments to PR + Zoho Project

MetricThresholdAlert Level
Response Time P95> 2sWarning
Response Time P95> 5sCritical
Error Rate 5xx> 1%Critical
Cache Hit Rate< 80%Warning
CPU Usage> 85% for 5minWarning
Memory Usage> 90%Critical
SQL DTU Usage> 80%Warning

Logging retention: Next.js + Umbraco: 90 days (App Insights). Cloudflare: 30 days. Azure SQL: 30 days. CI/CD: 90 days.


ComponentStrategyRTORPO
Azure SQLGeo-replication1h5min
Blob StorageGRS (Geo-Redundant Storage)1h0
App ServicesRe-deploy from CI/CD to secondary region2h
SQL BackupsPITR, 35-day retention

TierRange
Minimum (DEV-like)~EUR 625/mo
Production (full stack)~EUR 1,450/mo

16. Step-by-Step: Provisioning a New Environment

Section titled “16. Step-by-Step: Provisioning a New Environment”
  1. Create Resource Grouprg-savoy-{env}
  2. Create Azure SQL — Standard S2, enable TDE, configure geo-replication for PROD
  3. Create Storage Account — Standard LRS Hot tier, create media and form-uploads containers
  4. Create Key Vault — Add all secrets (API keys, connection strings, tokens, Mailjet keys — see Section 5.4)
  5. Create App Service (Umbraco) — .NET 10 LTS, always-on, configure Managed Identity for Key Vault
  6. Create App Service (Next.js) — Node.js 20.9+, configure auto-scale rules
  7. Configure VNet — Private endpoints for SQL and Blob
  8. Create Application Insights — Link to both App Services
  9. Configure Cloudflare — DNS records, SSL, WAF rules, page rules, Zero Trust for /umbraco
  10. Deploy — Run CI/CD pipeline targeting the new environment
  11. Verify — Smoke tests, check monitoring alerts, validate RBAC

PitfallSolution
Secrets committed to gitUse .env.local (git-ignored) locally, Key Vault in Azure
NEXT_PUBLIC_ prefix on sensitive keysOnly analytics/reCAPTCHA site keys get NEXT_PUBLIC_ prefix
Backoffice publicly accessibleLock /umbraco via Cloudflare Zero Trust or Azure IP restrictions
Missing MFA for CMS usersEnforce MFA at Azure AD level
Local CMS accounts activeDisable local accounts, use Azure AD SSO only
Form data retained indefinitelyConfigure 90-day auto-delete scheduled task
Missing security headersConfigure all headers in next.config.ts (see Section 7)
Dependabot alerts ignoredCI/CD must fail on high-severity NuGet vulnerabilities
Mailjet credentials committed to gitUse MAILJET_API_KEY_PLACEHOLDER in appsettings.json, real keys only in Key Vault
Mailjet sender domain not verifiedEmails will be rejected — verify SPF + DKIM in Cloudflare DNS (see Section 5.3)