Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 202610 min read

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

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.

    Terminal window
    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. 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 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
    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 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.

    Terminal window
    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.

    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://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
    Terminal 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 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.

    Terminal window
    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 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.

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.

Terminal window
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

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.bicepView source ↗
{
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.

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.jsView source ↗
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

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