Deployment overview
AppointMe deploys to Azure App Service as one .NET 10 container backed by Azure SQL, Key Vault and Blob Storage. Bicep provisions it, and a GitHub Actions run on a v* tag ships it.
- Runs on
- Azure App Service, one Linux container
- Provisioned by
- Bicep templates in
infra/ - Shipped by
- GitHub Actions, on a
v*tag - Default sizing
- Free F1 plan, Basic SQL
What runs where
Section titled “What runs where”One container serves both the API and the React SPA. Everything with state lives in a managed Azure service.
*.azurewebsites.netX-Original-Host8080src/AppointMe.Api/Dockerfile builds the image in three stages. It builds the React bundle and copies it into wwwroot, pre-generates the Wolverine handler code, and runs the API on port 8080 as a non-root user. EF Core migrations run when the container starts, before the API serves traffic.
Users sign in through Microsoft Entra External ID. Keycloak is for local development only. App Service pulls the image from Azure Container Registry with its managed identity. The Bicep also creates Application Insights and points the OpenTelemetry exporter at it, but check that data arrives before you rely on it (see the Production checklist).
What the Bicep creates
Section titled “What the Bicep creates”infra/main.bicep deploys these modules into one resource group. Names carry the environment name, and the globally unique ones add a 6-character hash of the resource group ID.
| Module | Resource | Devtest setting |
|---|---|---|
app-insights.bicep |
Application Insights | Workspace-based, linked to the Log Analytics workspace |
app-service-plan.bicep |
Linux App Service plan | F1 (Free) |
app-service.bicep |
Web App for Containers | System-assigned identity, HTTPS only, TLS 1.2, Key Vault references for secrets |
container-registry.bicep |
Container Registry | Basic, admin user off, no anonymous pull |
custom-domain.bicep + custom-domain-ssl-binding.bicep |
Hostname binding + free managed certificate | Only when CUSTOM_HOSTNAME is set. Needs B1 or higher. |
key-vault.bicep |
Key Vault | RBAC mode, standard tier, 7-day soft delete, no purge protection |
log-analytics.bicep |
Log Analytics workspace | PerGB2018, 30-day retention, 0.5 GB/day ingestion cap |
role-assignments.bicep |
Two role assignments for the Web App’s identity | AcrPull on the registry, Key Vault Secrets User on the vault |
sql.bicep |
SQL server + appointme database |
Basic, TLS 1.2, an AllowAllAzureServices firewall rule |
storage.bicep |
Storage account + private data-protection-keys container |
Standard_LRS, no public blob access |
Deploy to Azure with Bicep walks through the parameters, outputs and the app settings the Web App gets.
What’s deliberately not in the Bicep
Section titled “What’s deliberately not in the Bicep”You set up identity, secrets and the CI login once by hand. Network hardening is left for production.
| Not in the Bicep | Why | Covered in |
|---|---|---|
| Entra External ID tenant and app registration | You can only create the tenant in the portal | Entra External ID |
| Key Vault secret values | Secrets never go in a template or an output | Azure with Bicep |
| The CI deployer identity and its federated credential | Created once with az |
Azure with Bicep |
| Keycloak | Local development only. Authentication:Provider is EntraExternalId when deployed. |
Entra External ID |
| Azure Service Bus | The SqlDurable transport runs on the SQL database, so you don’t need a broker |
Domain events & messaging |
| WAF, private endpoints, VNet integration, deployment slots | A demo environment doesn’t need them | Production checklist |
The free F1 plan versus B1 and up
Section titled “The free F1 plan versus B1 and up”The F1 App Service plan is free, but it unloads the app when idle and caps its daily CPU. Move to B1 when that starts to hurt.
| F1 (the default) | B1 and up | |
|---|---|---|
| Idle | Unloads after about 20 minutes idle. The next request cold-starts the container, including EF migrations, in 30–90 s. | Stays loaded if you turn on Always On, which the Bicep doesn’t set |
| CPU | A daily quota. Once it’s used up, the app returns HTTP 403 until midnight UTC. | No daily quota |
| Background work | Hangfire jobs and Wolverine’s durability agent run only while the app is loaded | Run continuously with Always On |
| Custom domain | No hostname bindings, so the Cloudflare Worker fronts the domain | CUSTOM_HOSTNAME binds it with a free managed certificate |
| Deployment slots | No | S1 or higher |
main.bicep doesn’t pass a SKU, so each module’s default applies. To change the plan, edit the sku default in infra/modules/app-service-plan.bicep. The same goes for SQL (databaseSku in sql.bicep, Basic: 5 DTU, 2 GB) and the registry (sku in container-registry.bicep).
The rest is paid at its smallest tier: SQL Basic, ACR Basic, Standard_LRS storage, Key Vault operations and Log Analytics ingestion, which is capped at 0.5 GB a day. Price your region with the Azure pricing calculator.
Environments
Section titled “Environments”The repo ships one deployed environment, devtest. The environment name sets the resource names and picks the appsettings file that loads.
| Environment | ASPNETCORE_ENVIRONMENT |
Config file | Runs | Sign-in |
|---|---|---|---|---|
| Local | Development |
appsettings.Development.json |
Aspire or dotnet run |
Keycloak |
| devtest | Devtest |
appsettings.Devtest.json |
Azure, deployed by devtest.yml |
Entra External ID |
| Codegen | Codegen |
appsettings.Codegen.json |
Build time only: Wolverine codegen and the OpenAPI document |
Placeholder values |
Bicep maps environmentName: 'devtest' to Devtest. Any other name is passed through unchanged, so staging loads appsettings.staging.json. The CI pipeline only knows devtest. A second environment needs its own .bicepparam file, a GitHub environment, a federated credential and a deploy job.
Why does the API serve the SPA instead of a separate static host?1 min · optional
The API has no CORS policy. It signs browsers in with a cookie (appointme.auth), checks antiforgery tokens, and sends a report-only CSP with connect-src 'self'. All three expect the SPA and the API on one origin.
Locally the Vite dev server proxies /api, /signin-oidc and /admin to the API to get the same effect. In the container the API serves the built bundle from wwwroot and falls back to index.html, so there’s one image to build and one host to secure.
Deploy in this order
Section titled “Deploy in this order”Each step uses outputs from the one before it. Expect about an hour the first time, longer if you’re waiting on DNS.
| Step | Page | You end up with |
|---|---|---|
| 1. Provision | Azure with Bicep, step 1 | A resource group, a placeholder Web App and the deployment outputs |
| 2. Set up sign-in | Entra External ID | A tenant, an app registration and appsettings.Devtest.json |
| 3. Wire up and ship | Azure with Bicep, steps 3–7 | Seeded secrets, a CI identity, GitHub secrets and a first tagged deploy |
| 4. Understand the pipeline | GitHub Actions CI/CD | What each workflow checks, and when it deploys |
| 5. Harden | Production checklist | A list of what to change before real customers arrive |
Terraform for Azure and AWS is planned for PRO.