# Deploy to Azure with Bicep

> Provision AppointMe on Azure with Bicep, seed Key Vault, give GitHub Actions an OIDC identity with two scoped roles, and ship the .NET 10 container to App Service by pushing a v* tag.

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

* **You'll build:** A running `devtest` environment on Azure
* **Time:** About an hour
* **Touches:** `infra/`, Key Vault, GitHub repo settings
* **Before you start:** `az login`, a subscription, your GitHub repo

## Provision and deploy

**Seven steps take you from an empty subscription to a tagged release running on App Service. Each step uses outputs from the one before it.**

The names below are the repo’s own. In a project generated with `dotnet new appointme`, your project name replaces `appointme` and `AppointMe` in them. You need the `az` CLI signed in to your subscription and admin rights on your GitHub repo. The `gh` CLI is optional.

1. **Create the resource group and provision.** `main.bicepparam` reads the SQL admin password from an environment variable, so nothing secret is committed. Run `az deployment group what-if` with the same arguments first if you want a preview.

   ```bash
   az group create --name rg-appointme-devtest --location westeurope
   export SQL_ADMIN_PASSWORD='<a-strong-password>'


   az deployment group create \
     --resource-group rg-appointme-devtest \
     --template-file infra/main.bicep \
     --parameters infra/main.bicepparam \
     --query 'properties.outputs'
   ```

   Check: the command prints the [outputs](#parameters-outputs-and-app-settings). The Web App starts on a public hello-world placeholder image, which step 7 replaces with yours.

2. **Set up Entra External ID.** Create the tenant and the app registration, and fill in `appsettings.Devtest.json`. Use the `appServiceUrl` host for the redirect URI. [Entra External ID](/docs/deploy/entra-external-id/) has the full steps. You come back with a client secret value.

3. **Seed Key Vault.** The Web App reads three secrets through Key Vault references. None of them is a deployment output, so you set them once, by hand.

   | Secret                        | Feeds the app setting                                                             | Value                                                                                                                                                       |
   | ----------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
   | `AppointMeSql`                | `ConnectionStrings__AppointMeSql`                                                 | `Server=tcp:<sqlServerFqdn>,1433;Initial Catalog=appointme;User ID=appointme_admin;Password=<password>;Encrypt=True;TrustServerCertificate=False;`          |
   | `DataProtectionStorage`       | `ConnectionStrings__DataProtectionStorage`                                        | The output of `az storage account show-connection-string --resource-group rg-appointme-devtest --name <storageAccountName> --query connectionString -o tsv` |
   | `EntraExternalIdClientSecret` | `Authentication__EntraExternalId__ClientSecret` and `EntraIdentity__ClientSecret` | The client secret from step 2                                                                                                                               |

   ```bash
   KV=$(az deployment group show -g rg-appointme-devtest -n main --query 'properties.outputs.keyVaultName.value' -o tsv)


   az keyvault secret set --vault-name "$KV" --name AppointMeSql --value "..."
   az keyvault secret set --vault-name "$KV" --name DataProtectionStorage --value "..."
   az keyvault secret set --vault-name "$KV" --name EntraExternalIdClientSecret --value "..."


   # Restart so the Web App picks up the Key Vault references
   az webapp restart --name <web-app-name> --resource-group rg-appointme-devtest
   ```

   There’s no output for the Web App name. It’s the `appServiceUrl` host without `.azurewebsites.net`, or run `az webapp list -g rg-appointme-devtest --query "[].name" -o tsv`. Check: in the portal, the Web App’s **Environment variables** page shows each Key Vault reference as resolved.

4. **Create the CI deployer identity.** GitHub Actions signs in as a user-assigned managed identity with two roles, each scoped to a single resource.

   ```bash
   az identity create \
     --name id-appointme-devtest-ci \
     --resource-group rg-appointme-devtest


   PRINCIPAL_ID=$(az identity show -n id-appointme-devtest-ci -g rg-appointme-devtest --query principalId -o tsv)


   az role assignment create --assignee $PRINCIPAL_ID --role Contributor --scope $(az acr show -n <acr-name> -g rg-appointme-devtest --query id -o tsv)


   az role assignment create --assignee $PRINCIPAL_ID --role "Website Contributor" --scope $(az webapp show -n <web-app-name> -g rg-appointme-devtest --query id -o tsv)
   ```

   | Role                  | Scope                  | Why                                                                                                                                                                    |
   | --------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
   | `Contributor`         | The container registry | `az acr build` runs a server-side ACR Task, which needs control-plane access to read the registry and schedule the run. `AcrPush` is data-plane only and isn’t enough. |
   | `Website Contributor` | The Web App            | `az webapp config container set` and `az webapp restart`                                                                                                               |

   If you switch the pipeline to `docker build` and `docker push`, `AcrPush` on the registry is enough.

5. **Add the federated credential.** Every Azure call runs in the `deploy-devtest` job, which targets the `devtest` GitHub environment. One environment-scoped credential covers every release tag.

   ```bash
   az identity federated-credential create \
     --identity-name id-appointme-devtest-ci \
     --resource-group rg-appointme-devtest \
     --name github-env-devtest \
     --issuer https://token.actions.githubusercontent.com \
     --subject "repo:<owner>/<repo>:environment:devtest" \
     --audiences api://AzureADTokenExchange
   ```

   The audience must be `api://AzureADTokenExchange`, which is what `azure/login` requests by default.

6. **Wire up GitHub.** First enable Actions under **Settings → Actions**. Fresh organization repos may have it off, which leaves runs stuck in `queued`. Then add these under **Settings → Secrets and variables → Actions**:

   | Name                    | Kind                | Value                                                                                                                                               |
   | ----------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
   | `ACR_NAME`              | Secret              | The `containerRegistryName` output, without `.azurecr.io`                                                                                           |
   | `APP_PUBLIC_URL`        | Variable (optional) | The public URL shown on the deployment, for example `https://app.example.com`. GitHub rejects a secret-derived environment URL, so it’s a variable. |
   | `APP_SERVICE_NAME`      | Secret              | The Web App name, not its URL                                                                                                                       |
   | `AZURE_CLIENT_ID`       | Secret              | `az identity show -n id-appointme-devtest-ci -g rg-appointme-devtest --query clientId -o tsv`                                                       |
   | `AZURE_RESOURCE_GROUP`  | Secret              | `rg-appointme-devtest`                                                                                                                              |
   | `AZURE_SUBSCRIPTION_ID` | Secret              | Your subscription ID                                                                                                                                |
   | `AZURE_TENANT_ID`       | Secret              | The tenant that holds your subscription: `az account show --query tenantId -o tsv`                                                                  |

   ```bash
   gh secret set AZURE_RESOURCE_GROUP --body "rg-appointme-devtest"
   gh variable set APP_PUBLIC_URL --body "https://app.example.com"
   ```

   Then create the `devtest` environment under **Settings → Environments**. Set **Deployment branches and tags** to *Selected branches and tags*, with the tag pattern `v*` and the branch `main`. That rule is what stops any other ref, such as a pull request that edits the workflow, from getting the Azure credential. You can also add required reviewers there to approve each release.

7. **Tag to deploy.** A pushed `v*` tag runs the full `devtest` workflow: build and test, then build the image in ACR, point the Web App at it and restart it.

   ```bash
   git tag v1.2.0
   git push origin v1.2.0
   ```

   To redeploy a release, run the workflow manually from the Actions tab against its tag. To deploy `main` without a release, run it manually against `main`, and the footer shows `main-<short sha>`. A manual run against any other branch only builds and tests. [GitHub Actions CI/CD](/docs/deploy/ci-cd/) has the details.

### Why one environment-scoped credential instead of one per tag?

A federated credential matches the `sub` claim in GitHub’s token exactly. A job that targets an environment gets the subject `repo:<owner>/<repo>:environment:devtest`, which is the same for every tag. A branch- or tag-scoped subject would need a new credential for every release.

The environment’s deployment rule does the gatekeeping instead: only `v*` tags and `main` can run a job in `devtest`, so only they can obtain the token Azure trusts.

## Verify it works

**A green run, the release version in the app’s footer, and a sign-in that lands on your External ID tenant.**

| Check                                      | Expected                                                                       |
| ------------------------------------------ | ------------------------------------------------------------------------------ |
| The Actions run                            | Both jobs, `Build and test` and `Build image and deploy to devtest`, are green |
| `https://<web-app-name>.azurewebsites.net` | The AppointMe SPA. Allow a couple of minutes for the first container start.    |
| The footer                                 | The tag you pushed, such as `v1.2.0`                                           |
| Sign in                                    | Redirects to `<tenant>.ciamlogin.com`, then back to `Frontend:BaseUrl`         |

To watch the container start, turn on container logging and tail it. Each module’s `DbContext` logs `Applying migrations for <Context>...` before the API serves traffic.

```bash
az webapp log config --name <web-app-name> --resource-group rg-appointme-devtest --docker-container-logging filesystem
az webapp log tail --name <web-app-name> --resource-group rg-appointme-devtest
```

## Parameters, outputs and app settings

**`main.bicepparam` sets five of the six parameters, three of them from environment variables. The deployment returns nine outputs, and the Web App gets eleven app settings.**

| Parameter               | Set by `main.bicepparam`                                                                       | Notes                                                                 |
| ----------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `customHostname`        | `CUSTOM_HOSTNAME` env var, default empty                                                       | Empty skips the custom-domain modules. Needs a B1+ plan.              |
| `environmentName`       | `'devtest'`                                                                                    | 3–10 characters. Used in resource names and `ASPNETCORE_ENVIRONMENT`. |
| `initialContainerImage` | `INITIAL_CONTAINER_IMAGE` env var, default `mcr.microsoft.com/azuredocs/aci-helloworld:latest` | Only for the first deploy. CI replaces the image afterwards.          |
| `location`              | Not set                                                                                        | Defaults to the resource group’s region                               |
| `sqlAdminLogin`         | `'appointme_admin'`                                                                            |                                                                       |
| `sqlAdminPassword`      | `SQL_ADMIN_PASSWORD` env var, default empty                                                    | `@secure()`. Export it before you deploy.                             |

| Output                         | Example value                                              | Used for                               |
| ------------------------------ | ---------------------------------------------------------- | -------------------------------------- |
| `appInsightsConnectionString`  | `InstrumentationKey=…`                                     | Already set on the Web App             |
| `appServiceUrl`                | `https://app-appointme-devtest-<suffix>.azurewebsites.net` | The redirect URI, and the Web App name |
| `containerRegistryLoginServer` | `acrappointmedevtest<suffix>.azurecr.io`                   |                                        |
| `containerRegistryName`        | `acrappointmedevtest<suffix>`                              | `ACR_NAME`, the CI role in step 4      |
| `keyVaultName`, `keyVaultUri`  | `kv-appointme-<suffix>`                                    | Seeding secrets in step 3              |
| `sqlDatabaseName`              | `appointme`                                                | The `AppointMeSql` connection string   |
| `sqlServerFqdn`                | `sql-appointme-devtest-<suffix>.database.windows.net`      | The `AppointMeSql` connection string   |
| `storageAccountName`           | `stappointmedevtest<suffix>`                               | The `DataProtectionStorage` secret     |

`app-service.bicep` sets only secrets, App Service plumbing and the environment selector. Non-secret settings, such as the Entra IDs, the Wolverine transport and the demo flag, live in `appsettings.Devtest.json`.

| App setting                                     | Value                                                                  |
| ----------------------------------------------- | ---------------------------------------------------------------------- |
| `APPLICATIONINSIGHTS_CONNECTION_STRING`         | The App Insights connection string                                     |
| `ASPNETCORE_ENVIRONMENT`                        | `Devtest`                                                              |
| `ASPNETCORE_FORWARDEDHEADERS_ENABLED`           | `true`                                                                 |
| `Authentication__EntraExternalId__ClientSecret` | Key Vault reference to `EntraExternalIdClientSecret`                   |
| `ConnectionStrings__AppointMeSql`               | Key Vault reference to `AppointMeSql`                                  |
| `ConnectionStrings__DataProtectionStorage`      | Key Vault reference to `DataProtectionStorage`                         |
| `DOCKER_REGISTRY_SERVER_URL`                    | `https://<acr>.azurecr.io`                                             |
| `EntraIdentity__ClientSecret`                   | Key Vault reference to `EntraExternalIdClientSecret` (the same secret) |
| `OTEL_EXPORTER_OTLP_ENDPOINT`                   | `https://<location>.in.applicationinsights.azure.com/v2/track`         |
| `WEBSITES_ENABLE_APP_SERVICE_STORAGE`           | `false`                                                                |
| `WEBSITES_PORT`                                 | `8080`                                                                 |

A Key Vault reference resolves through the Web App’s system-assigned identity, which `role-assignments.bicep` makes a `Key Vault Secrets User`:

infra/modules/app-service.bicep

```bicep
        {
          name: 'Authentication__EntraExternalId__ClientSecret'
          value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/${entraExternalIdClientSecretSecretName}/)'
        }
        {
          name: 'EntraIdentity__ClientSecret'
          value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/${entraExternalIdClientSecretSecretName}/)'
        }
        {
          name: 'ConnectionStrings__AppointMeSql'
          value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/${sqlConnectionStringSecretName}/)'
        }
```

Every key the API reads, and where each is set, is in [Configuration](/docs/reference/configuration/).

## Optional: custom domain

**On F1 a Cloudflare Worker fronts your domain. On B1 and up, Bicep binds it with a free managed certificate.**

| Plan         | How                                                                                                                                         | You do                                                                                                                      |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| F1 (default) | The Worker in `infra/cloudflare-worker/` forwards to the `azurewebsites.net` origin and sends the public host as `X-Original-Host`          | Put DNS on Cloudflare (the free plan is enough), edit the Worker, run `npx wrangler deploy` from `infra/cloudflare-worker/` |
| B1 and up    | `CUSTOM_HOSTNAME` makes `main.bicep` run `custom-domain.bicep`: a hostname binding, an App Service Managed Certificate, then an SNI binding | Create the DNS records, export `CUSTOM_HOSTNAME`, and run `az deployment group create` again                                |

For the Worker, set `ORIGIN_HOST` and `PUBLIC_HOST` in `src/index.js`, and the `routes` pattern and `zone_name` in `wrangler.jsonc`:

infra/cloudflare-worker/src/index.js

```js
const ORIGIN_HOST = 'app-appointme-devtest-ze5tkm.azurewebsites.net';
const PUBLIC_HOST = 'app.appointme.dev';


export default {
    async fetch(request) {
        const url = new URL(request.url);
        url.hostname = ORIGIN_HOST;
        const upstream = new Request(url, request);
        upstream.headers.set('X-Original-Host', PUBLIC_HOST);
        return fetch(upstream);
    },
};
```

`Program.cs` sets `ForwardedHostHeaderName = "X-Original-Host"`, so OIDC redirects and generated URLs use your domain. For a B1+ binding, `custom-domain.bicep` expects two DNS records first. One is a TXT record at `asuid.<subdomain>` holding the Web App’s `customDomainVerificationId`. The other is a CNAME from `<subdomain>` to `<web-app-name>.azurewebsites.net`. It handles subdomains only, not an apex domain.

Either way, finish with three changes:

| Change                                                       | Where                      |
| ------------------------------------------------------------ | -------------------------- |
| Register `https://<your-host>/signin-oidc` as a redirect URI | The Entra app registration |
| Set `Frontend:BaseUrl` to `https://<your-host>`              | `appsettings.Devtest.json` |
| Set `APP_PUBLIC_URL` to `https://<your-host>`                | GitHub Actions variables   |

## Troubleshooting

**Most first-deploy failures are a missing role, a mistyped subject, or a secret that isn’t seeded yet.**

| Symptom                                                                     | Cause                                                                                                                                                                              | Fix                                                                          |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| The SQL server fails to deploy                                              | `SQL_ADMIN_PASSWORD` wasn’t exported, so the password is empty                                                                                                                     | Export a strong password and deploy again                                    |
| Deploying with `CUSTOM_HOSTNAME` fails                                      | F1 has no hostname bindings                                                                                                                                                        | Unset it and use the Worker, or move the plan to B1                          |
| A redeploy fails on the Key Vault name after you deleted the resource group | The vault is soft-deleted for 7 days. A new resource group with the same name produces the same name suffix.                                                                       | `az keyvault purge --name <kv-name>` (purge protection is off)               |
| `az keyvault secret set` returns `Forbidden`                                | The vault uses RBAC, and subscription Owner doesn’t include data-plane access                                                                                                      | Assign yourself `Key Vault Secrets Officer` on the vault                     |
| The container exits at startup with a connection-string format error        | A Key Vault reference didn’t resolve, so App Service passed the literal `@Microsoft.KeyVault(...)` string. The secret is missing, or the app hasn’t restarted since you seeded it. | Seed the secret, check the reference status, then run `az webapp restart`    |
| Runs sit in `queued`                                                        | Actions is disabled on the repo                                                                                                                                                    | Enable it under **Settings → Actions**                                       |
| `azure/login` fails with `AADSTS70021`                                      | The token’s subject doesn’t match the federated credential                                                                                                                         | The subject must be `repo:<owner>/<repo>:environment:devtest`, matching case |
| `az acr build` is denied                                                    | The CI identity has `AcrPush` but not `Contributor` on the registry                                                                                                                | Assign `Contributor` scoped to the registry                                  |
| HTTP 403 for the rest of the day                                            | The F1 daily CPU quota is used up                                                                                                                                                  | Wait until midnight UTC, or move to B1                                       |

## Next

* [Entra External ID](/docs/deploy/entra-external-id/) (Deploy)
* [GitHub Actions CI/CD](/docs/deploy/ci-cd/) (Deploy)
* [Production checklist](/docs/deploy/production-checklist/) (Deploy)
