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.
- You'll build
- A running
devtestenvironment 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
Section titled “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.
-
Create the resource group and provision.
main.bicepparamreads the SQL admin password from an environment variable, so nothing secret is committed. Runaz deployment group what-ifwith the same arguments first if you want a preview.Terminal window az group create --name rg-appointme-devtest --location westeuropeexport 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. The Web App starts on a public hello-world placeholder image, which step 7 replaces with yours.
-
Set up Entra External ID. Create the tenant and the app registration, and fill in
appsettings.Devtest.json. Use theappServiceUrlhost for the redirect URI. Entra External ID has the full steps. You come back with a client secret value. -
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 AppointMeSqlConnectionStrings__AppointMeSqlServer=tcp:<sqlServerFqdn>,1433;Initial Catalog=appointme;User ID=appointme_admin;Password=<password>;Encrypt=True;TrustServerCertificate=False;DataProtectionStorageConnectionStrings__DataProtectionStorageThe output of az storage account show-connection-string --resource-group rg-appointme-devtest --name <storageAccountName> --query connectionString -o tsvEntraExternalIdClientSecretAuthentication__EntraExternalId__ClientSecretandEntraIdentity__ClientSecretThe client secret from step 2 Terminal window 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 referencesaz webapp restart --name <web-app-name> --resource-group rg-appointme-devtestThere’s no output for the Web App name. It’s the
appServiceUrlhost without.azurewebsites.net, or runaz 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. -
Create the CI deployer identity. GitHub Actions signs in as a user-assigned managed identity with two roles, each scoped to a single resource.
Terminal window az identity create \--name id-appointme-devtest-ci \--resource-group rg-appointme-devtestPRINCIPAL_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 ContributorThe container registry az acr buildruns a server-side ACR Task, which needs control-plane access to read the registry and schedule the run.AcrPushis data-plane only and isn’t enough.Website ContributorThe Web App az webapp config container setandaz webapp restartIf you switch the pipeline to
docker buildanddocker push,AcrPushon the registry is enough. -
Add the federated credential. Every Azure call runs in the
deploy-devtestjob, which targets thedevtestGitHub environment. One environment-scoped credential covers every release tag.Terminal window 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://AzureADTokenExchangeThe audience must be
api://AzureADTokenExchange, which is whatazure/loginrequests by default. -
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_NAMESecret The containerRegistryNameoutput, without.azurecr.ioAPP_PUBLIC_URLVariable (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_NAMESecret The Web App name, not its URL AZURE_CLIENT_IDSecret az identity show -n id-appointme-devtest-ci -g rg-appointme-devtest --query clientId -o tsvAZURE_RESOURCE_GROUPSecret rg-appointme-devtestAZURE_SUBSCRIPTION_IDSecret Your subscription ID AZURE_TENANT_IDSecret The tenant that holds your subscription: az account show --query tenantId -o tsvTerminal window gh secret set AZURE_RESOURCE_GROUP --body "rg-appointme-devtest"gh variable set APP_PUBLIC_URL --body "https://app.example.com"Then create the
devtestenvironment under Settings → Environments. Set Deployment branches and tags to Selected branches and tags, with the tag patternv*and the branchmain. 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. -
Tag to deploy. A pushed
v*tag runs the fulldevtestworkflow: build and test, then build the image in ACR, point the Web App at it and restart it.Terminal window git tag v1.2.0git push origin v1.2.0To redeploy a release, run the workflow manually from the Actions tab against its tag. To deploy
mainwithout a release, run it manually againstmain, and the footer showsmain-<short sha>. A manual run against any other branch only builds and tests. GitHub Actions CI/CD has the details.
Why one environment-scoped credential instead of one per tag?1 min · optional
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
Section titled “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.
az webapp log config --name <web-app-name> --resource-group rg-appointme-devtest --docker-container-logging filesystemaz webapp log tail --name <web-app-name> --resource-group rg-appointme-devtestParameters, outputs and app settings
Section titled “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:
{ 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.
Optional: custom domain
Section titled “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:
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
Section titled “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 |