Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 202610 min read

Production checklist

What to change before AppointMe's Azure devtest setup serves real customers. Covers secrets, Data Protection keys, forwarded headers, demo mode, Wolverine and Hangfire scaling, backups and monitoring.

Starts from
The devtest environment Bicep creates
Already on
HTTPS only, HSTS, TLS 1.2, Key Vault
Turn off
Demo:Enabled, and the jobs it left behind
Verify
That telemetry reaches Application Insights

The devtest environment is sized and configured for a public demo. This list combines the prod-hardening checklist in infra/README.md with what the code itself needs. Each row gives the item, why it matters, how to do it and where.

Replace the SQL password and the storage account key with managed identity, and lock Key Vault against purges.

Item Why How Where
Entra-only SQL authentication The AppointMeSql secret holds the server admin login and password Make the Web App’s managed identity a database user and connect with Authentication=Active Directory Managed Identity. Then drop the SQL login. infra/modules/sql.bicep, the AppointMeSql secret
Key Vault purge protection With soft delete alone, anyone who has the rights can purge a deleted secret during its 7 days Set enablePurgeProtection: true. You can’t turn it off again. infra/modules/key-vault.bicep
No shared-key storage access The DataProtectionStorage secret is an account key, which grants full access to the storage account Move Data Protection to managed-identity blob access (see the next section), then set allowSharedKeyAccess: false infra/modules/storage.bicep
Real super admins only Everyone in Authentication:SuperAdmins can open the Hangfire dashboard at /admin/jobs. Devtest lists the demo user. List operators only. The README’s longer-term item is to replace this email allow-list with a role once roles are projected into the JWT. appsettings.<Env>.json
Client secret rotation Entra client secrets expire, and both ClientSecret settings read the same one Create a new secret, az keyvault secret set it as EntraExternalIdClientSecret, then restart the Web App The app registration, Key Vault
Customer-managed keys Storage and Key Vault use Microsoft-managed keys Configure customer-managed keys if your compliance requires them storage.bicep, key-vault.bicep

The auth cookie and antiforgery tokens are encrypted with the Data Protection key ring, so the ring must survive restarts and be shared by every instance.

src/AppointMe.Api/DataProtection/DataProtectionExtensions.csView source ↗
var dataProtection = services.AddDataProtection().SetApplicationName("appointme");
var connectionString = configuration.GetConnectionString("DataProtectionStorage");
if (!string.IsNullOrWhiteSpace(connectionString))
{
dataProtection.PersistKeysToAzureBlobStorage(
connectionString,
containerName: "data-protection-keys",
blobName: "keys.xml");
}
Item Why How Where
Persist the key ring Without DataProtectionStorage, keys stay in the container’s file system, which App Service doesn’t keep (WEBSITES_ENABLE_APP_SERVICE_STORAGE is false). Every restart, and every extra instance, gets a fresh ring, which signs users out and invalidates antiforgery tokens. Seed the DataProtectionStorage secret Key Vault, see Azure with Bicep
Managed-identity blob access A connection string means an account key Use the PersistKeysToAzureBlobStorage overload that takes a blob URI and a TokenCredential, and give the Web App’s identity Storage Blob Data Contributor on the container DataProtectionExtensions.cs, role-assignments.bicep
Encrypt the ring at rest There’s no ProtectKeysWith… call, so keys.xml stores the keys unencrypted and only storage access protects them Add ProtectKeysWithAzureKeyVault from the Azure.Extensions.AspNetCore.DataProtection.Keys package DataProtectionExtensions.cs
Protect the blob storage.bicep turns on neither blob soft delete nor versioning, so a deleted keys.xml is gone for good Turn on blob soft delete for the account storage.bicep
Fix the application name SetApplicationName("appointme") scopes the ring, so changing it later invalidates every cookie Choose the name before launch and leave it alone DataProtectionExtensions.cs

HTTPS and HSTS are on already. Forwarded headers are trusted from any caller and the CSP only reports, so tighten both, then put a firewall in front.

src/AppointMe.Api/Program.csView source ↗
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
// The Cloudflare Worker fronting devtest rewrites Host to the azurewebsites
// origin and carries the public hostname here instead.
options.ForwardedHostHeaderName = "X-Original-Host";
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
Item Why How Where
HTTPS only Already set: httpsOnly: true, minimum TLS 1.2, FTPS off Nothing to do app-service.bicep
HSTS Already sent outside Development: 365 days, with IncludeSubDomains Before launch, make sure every subdomain of your host serves HTTPS, because IncludeSubDomains covers them all Program.cs
Forwarded headers KnownProxies and KnownIPNetworks are cleared, so the API accepts X-Forwarded-For, X-Forwarded-Proto and X-Original-Host from any caller. Anyone who reaches the azurewebsites.net origin directly can choose the host the API builds its OIDC redirect from. With the Worker, restrict the Web App to Cloudflare’s IP ranges using access restrictions. Without it, drop XForwardedHost and the ForwardedHostHeaderName line. Program.cs, App Service access restrictions
Enforce the CSP The API sends Content-Security-Policy-Report-Only, so browsers report violations but block nothing Watch the browser console for violation reports, then rename the header to Content-Security-Policy SecurityHeaders/SecurityHeadersMiddleware.cs
WAF Nothing filters traffic before App Service Put Azure Front Door or Application Gateway with WAF in front New infrastructure
Private endpoints and VNet integration SQL, Storage, Key Vault and ACR all have publicNetworkAccess: 'Enabled' Add private endpoints for them, plus Service Bus if you use it, and VNet-integrate the Web App New infrastructure
SQL firewall The AllowAllAzureServices rule (0.0.0.0) admits connections from any Azure service, including other customers’ Remove it once private endpoints are in place sql.bicep
Custom domain and managed TLS *.azurewebsites.net isn’t your brand See the custom domain options CUSTOM_HOSTNAME or the Worker

Set Demo:Enabled to false, then delete the two recurring jobs it registered. Turning the flag off doesn’t remove them.

Item Why How Where
Turn off Demo:Enabled While it’s on, DemoSeedingSaga fills every newly registered company, real ones included, with 150 customers and about 450 appointments. /api/v1/login/demo signs anyone in as the demo user. Set "Enabled": false, the default in appsettings.json appsettings.<Env>.json. Devtest has true.
Delete the top-up jobs When the flag is on, the registrars add crm:top-up-demo-customers and booking:top-up-demo-appointments, which run daily at 03:00 UTC. When it’s off, they register nothing, but they don’t remove what’s already in Hangfire storage, and the jobs don’t check the flag. Delete both under Recurring Jobs at /admin/jobs The Hangfire dashboard
Remove the demo account Demo:User keeps a password in a committed file, and devtest also lists the demo email in SuperAdmins Delete the Demo:User block and the SuperAdmins entry, then delete the user from your tenant appsettings.<Env>.json, Entra

Every instance runs the whole app: HTTP, Wolverine’s durability agent, a Hangfire server, and EF migrations at startup. Get off F1 first, then plan for overlapping versions.

Item Why How Where
Leave F1 F1 unloads when idle, has a daily CPU quota and allows no hostname bindings or slots B1 or higher. S1 or P1v3 for deployment slots and zone redundancy. app-service-plan.bicep
Always On The Bicep doesn’t set alwaysOn, so Hangfire’s recurring jobs and Wolverine’s durability agent stop whenever the app idles Add alwaysOn: true to siteConfig (B1 or higher) app-service.bicep
Wolverine transport SqlDurable keeps messages in durable local queues backed by the wolverine_* tables in the app database (created in dbo at this commit), so they survive restarts without a broker To move to a broker, set Wolverine:Transport to AzureServiceBus, seed an AppointMeMessaging secret, and add a ConnectionStrings__AppointMeMessaging Key Vault reference. Queues are created at startup (AutoProvision()), and the SQL outbox stays on. appsettings.<Env>.json, app-service.bicep
Hangfire servers Each instance starts a Hangfire server on the hangfire schema and re-registers the recurring jobs at startup with AddOrUpdate Nothing to change. The dashboard’s Servers page lists one server per instance. Hangfire/HangfireExtensions.cs
Startup migrations Every instance runs MigrateAsync for each module’s DbContext before it serves traffic. During a rolling restart or slot swap, the old version runs against the new schema. Keep migrations backward compatible: add in one release, remove in a later one AppointMe.Shared/Database/Migrations
Deployment slots CI deploys in place (az webapp restart), so every release has downtime: a cold start plus migrations Deploy to a staging slot, warm it up, then swap. This needs S1 or higher and a change to devtest.yml. devtest.yml, app-service.bicep
Required reviewers Any v* tag deploys straight away Add required reviewers to the GitHub environment Settings → Environments

Domain events & messaging and Background jobs explain how Wolverine and Hangfire are wired.

The Bicep sets no backup policy, so Azure SQL’s defaults apply. Nothing in the Bicep backs up the key ring.

Item Why How Where
SQL backup retention Azure SQL’s default point-in-time restore applies: 7 days for a new database Set the short-term retention you need, and add long-term retention for weekly, monthly or yearly copies sql.bicep, or the portal
Defender for SQL The Bicep doesn’t turn it on Turn on Microsoft Defender for SQL The SQL server
Zone redundancy zoneRedundant: false, Standard_LRS storage, one region Choose zone-redundant tiers sql.bicep, storage.bicep, the plan
Multi-region Everything lives in one resource group in one region Front Door, a paired-region App Service and SQL geo-replication New infrastructure
Restore drill An untested backup isn’t a backup Restore to a new database, then point a slot or a test environment at it The portal or az sql db restore

Application Insights and Log Analytics are provisioned. The API exports OpenTelemetry traces and metrics, but no logs, so confirm what arrives before you rely on it.

Item Why How Where
Know what’s exported Traces: ASP.NET Core, HttpClient, SqlClient (with exceptions) and the Wolverine source. Metrics: ASP.NET Core, HttpClient and Wolverine* meters. No logs. Add a log exporter if you want logs next to traces OpenTelemetry/OpenTelemetryExtensions.cs
Service version The resource is hard-coded as AppointMe.Api, version 1.0.0 Feed in the release version if you want to tell releases apart OpenTelemetryExtensions.cs
Container logs The Bicep adds no diagnostic settings, so console output never reaches Log Analytics Turn on App Service logs, or add a diagnostic setting that sends to the workspace app-service.bicep
Ingestion cap 0.5 GB a day. Once it’s hit, ingestion stops until the next day. Raise dailyQuotaGb, or set it to '-1' to remove the cap log-analytics.bicep
Retention 30 days Raise retentionInDays log-analytics.bicep
A real health check There’s no health endpoint. App Service probes / (healthCheckPath), which returns the SPA’s index.html without touching the database. Add an endpoint that checks SQL, and point healthCheckPath at it Program.cs, app-service.bicep
Alerts The Bicep defines none Alert on availability, the 5xx rate and the ingestion cap Azure Monitor
Why isn't this hardening in the template already?1 min · optional

Most of it costs money every hour: Front Door with WAF, private endpoints, S1 or Premium plans for slots and zones, and a SQL tier that’s zone-redundant. The template ships the smallest setup that runs the full app, so evaluating it costs little, and you add what your customers need.

The code-level items are different. Demo mode and the forwarded-headers trust exist for the public demo behind the Cloudflare Worker. The CSP is report-only while the policy is tuned against the SPA, as the comment in SecurityHeadersMiddleware says. Change all three on day one.