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

Source page: https://appointme.dev/docs/deploy/overview/

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

**One container serves both the API and the React SPA. Everything with state lives in a managed Azure service.**

*Figure 1. A request reaches one container, which talks to three managed services.*

1. **Browser** — your domain or `*.azurewebsites.net`

2. **Cloudflare Worker** — optional: sends the public host as `X-Original-Host`

3. **App Service container** — API + SPA on port `8080`

4. Then, in parallel:

   * **Azure SQL** — EF Core, Dapper, Wolverine and Hangfire
   * **Key Vault** — secrets, read through Key Vault references
   * **Blob Storage** — the Data Protection key ring

`src/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](/docs/deploy/production-checklist/)).

## 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](/docs/deploy/azure/) walks through the parameters, outputs and the app settings the Web App gets.

## 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](/docs/deploy/entra-external-id/)       |
| Key Vault secret values                                    | Secrets never go in a template or an output                                           | [Azure with Bicep](/docs/deploy/azure/)                    |
| The CI deployer identity and its federated credential      | Created once with `az`                                                                | [Azure with Bicep](/docs/deploy/azure/)                    |
| Keycloak                                                   | Local development only. `Authentication:Provider` is `EntraExternalId` when deployed. | [Entra External ID](/docs/deploy/entra-external-id/)       |
| Azure Service Bus                                          | The `SqlDurable` transport runs on the SQL database, so you don’t need a broker       | [Domain events & messaging](/docs/concepts/messaging/)     |
| WAF, private endpoints, VNet integration, deployment slots | A demo environment doesn’t need them                                                  | [Production checklist](/docs/deploy/production-checklist/) |

## 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](https://azure.microsoft.com/pricing/calculator/).

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

Gotcha

Linux file names are case-sensitive. If `environmentName` is `prod`, the file must be `appsettings.prod.json`, not `appsettings.Prod.json`. Otherwise the API falls back to `appsettings.json`, which selects Keycloak, and it fails at startup because `Authentication:Keycloak` is missing.

### Why does the API serve the SPA instead of a separate static host?

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

**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](/docs/deploy/azure/), step 1            | A resource group, a placeholder Web App and the deployment outputs      |
| 2. Set up sign-in          | [Entra External ID](/docs/deploy/entra-external-id/)       | A tenant, an app registration and `appsettings.Devtest.json`            |
| 3. Wire up and ship        | [Azure with Bicep](/docs/deploy/azure/), steps 3–7         | Seeded secrets, a CI identity, GitHub secrets and a first tagged deploy |
| 4. Understand the pipeline | [GitHub Actions CI/CD](/docs/deploy/ci-cd/)                | What each workflow checks, and when it deploys                          |
| 5. Harden                  | [Production checklist](/docs/deploy/production-checklist/) | A list of what to change before real customers arrive                   |

Terraform for Azure and AWS is planned for [PRO](/docs/start/free-vs-pro/#whats-in-pro).

## Next

* [Deploy to Azure with Bicep](/docs/deploy/azure/) (Deploy)
* [Entra External ID](/docs/deploy/entra-external-id/) (Deploy)
* [Configuration](/docs/reference/configuration/) (Reference)
