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

Source page: https://appointme.dev/docs/deploy/production-checklist/

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

## Secrets and access

**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`                   |

## Sessions and Data Protection

**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.cs

```cs
            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](/docs/deploy/azure/)  |
| 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, headers and the edge

**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.cs

```cs
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](/docs/deploy/azure/#optional-custom-domain)                                                                                          | `CUSTOM_HOSTNAME` or the Worker                |

## Demo mode

**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               |

## Scale and releases

**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](/docs/concepts/messaging/) and [Background jobs](/docs/concepts/background-jobs/) explain how Wolverine and Hangfire are wired.

## Backups and recovery

**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`      |

## Monitoring

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

Check that telemetry arrives

The API uses the generic OTLP exporter, and the Bicep points it at `https://<location>.in.applicationinsights.azure.com/v2/track` through `OTEL_EXPORTER_OTLP_ENDPOINT`. Nothing in the code reads `APPLICATIONINSIGHTS_CONNECTION_STRING`. After a deploy, open **Application Insights → Transaction search**. If it’s empty, Microsoft’s `Azure.Monitor.OpenTelemetry.AspNetCore` exporter is the package that reads that connection string.

| 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?

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.

## Next

* [Deploy to Azure with Bicep](/docs/deploy/azure/) (Deploy)
* [Configuration](/docs/reference/configuration/) (Reference)
* [Background jobs](/docs/concepts/background-jobs/) (Concept)
