Azure and Bicep
What `npx mdlake hosting init` writes, the Azure resources each hosting model creates, the Bicep parameters you edit, and how to deploy the infrastructure and keep secrets in a Key Vault. For developers.
On this page
The kit describes the Azure side of a site as Bicep files in infra/. You edit one small parameter file, deploy with the Azure CLI, and the resources exist. The pipeline then deploys the site into them. This page covers the files, the resources, the parameters, the secrets and the commands, for all three hosting models.
In Claude Code the setup-hosting skill does the same job and stops for your approval before it changes the hosting model. See Skills.
Write the files
npx mdlake hosting init --ci github,azure-devops --name acme-site| Flag | Meaning |
|---|---|
--ci github,azure-devops | Required. Which pipelines to write: github, azure-devops or both. The command exits with code 2 without it, or with any other value. |
--name <base> | The base name for resources. It becomes the Static Web App name (<base>-prod, <base>-stg) and the resource group suggestion (rg-<base>). The default is the name in the site’s package.json, or else the folder name, made safe for Azure. |
--overwrite | Replace a generated file you edited by hand. Without it the command refuses and exits with code 3. |
The model it writes for is the one the kit works out for the site: the record in .mdlake/hosting.json, or else what the code shows (a site with on-demand routes is server; a site with none is static). Settings and secrets it lists in the parameter files come from the forms in src/forms. See Choose a hosting model.
Files by model
| File | Static | Server | Split |
|---|---|---|---|
infra/main.bicep | yes | yes | yes |
infra/modules/staticWebApp.bicep | yes | yes | |
infra/modules/appService.bicep | yes | ||
infra/modules/apiApp.bicep | yes | ||
infra/modules/linkedBackend.bicep | yes | ||
infra/modules/keyVault.bicep | yes | yes | |
infra/parameters/staging.bicepparam | yes | yes | yes |
infra/parameters/production.bicepparam | yes | yes | yes |
.github/workflows/deploy.yml | with --ci github | with --ci github | with --ci github |
azure-pipelines.yml | with --ci azure-devops | with --ci azure-devops | with --ci azure-devops |
Every generated file starts with a marker line such as:
// mdlake-hosting: tier=split runtime=dotnet (generated by `npx mdlake hosting init`; keep your edits with `npx mdlake adopt <file>`)The marker belongs to the generated ownership zone: hosting init may rewrite these files, so do not edit them. The two parameter files are the exception. They are created once and are yours, and the command never overwrites them.
Regenerating and your own changes
Run hosting init again after you change the hosting model, the runtime or the CI systems. The command:
- rewrites every generated file that you have not edited, and says
Unchangedfor those that need no change; - refuses a generated file that you edited and exits with code 3, naming it;
- never touches your parameter files. If they exist it prints
... exists already; left as it is (check that it fits the <model> tier). After a model change, compare them with the newmain.bicepby hand. A split site needsstaticWebAppLocationandapiSku; a static site does not takesettings; - leaves the modules of the old model behind when you switch, and tells you to delete them (
infra/modules/appService.bicepis not used by a split site).
If you need a change in a generated file, make it and run npx mdlake adopt infra/main.bicep. The file then counts as yours and hosting init stops rewriting it. The cost is that you also stop getting improvements to that file. --overwrite throws your change away instead.
The resources
Static
One resource: an Azure Static Web App from modules/staticWebApp.bicep.
| Parameter | Default | Meaning |
|---|---|---|
name | none | The Static Web App name. |
location | westeurope | Static Web Apps exist in a few regions only. |
sku | Free | Free or Standard. The generated production file sets Standard, staging keeps Free. |
customDomain | empty | A domain such as www.acme.com, or empty. See Custom domains. |
customDomainType | auto | auto, subdomain or apex: how Static Web Apps validates the domain. |
tags | {} | Tags on the resource. |
Outputs: defaultHostName and name.
Server
main.bicep combines modules/appService.bicep and, by default, modules/keyVault.bicep.
| Resource | Notes |
|---|---|
| App Service plan | Linux, reserved: true. Size from sku, default B1. |
| Web App | Node 22 (NODE|22-lts), start command node dist/server/entry.mjs, always on (unless F1), HTTPS only, TLS 1.2 or later, FTPS disabled, HTTP/2, health check on /, a system-assigned managed identity. |
| Key Vault | Name <name>-kv cut to 24 characters. Access by role (RBAC), soft delete for 30 days. |
| Role assignment | The Web App identity gets Key Vault Secrets User on the vault. |
The Web App always has these settings: HOST=0.0.0.0, NODE_ENV=production, SCM_DO_BUILD_DURING_DEPLOYMENT=false. Your settings and secretNames are added to them.
| Parameter | Default | Meaning |
|---|---|---|
name | none | The Web App name, globally unique (<name>.azurewebsites.net). The plan is <name>-plan. |
location | the resource group’s location | Region. |
sku | B1 | Plan size. |
settings | {} | Non-secret settings. |
secretNames | [] | Names of secret settings. |
useKeyVault | true | Keep secrets in a Key Vault. |
secrets | {} | Secret values to write now. Marked @secure(): never stored in deployment history. |
customDomain | empty | A custom host name for the Web App. When set, the Bicep creates a free managed certificate for it and binds it. The plan must be B1 or larger. |
tags | {} | Tags. |
Outputs: defaultHostName, name and customDomainVerificationId (the value of the asuid TXT record).
Split
main.bicep combines four modules.
| Resource | Notes |
|---|---|
Static Web App <name> | Always the Standard plan, because linked backends need it. Region staticWebAppLocation, default westeurope. |
App Service plan <name>-api-plan and Web App <name>-api | Linux. NODE|22-lts with start command node server.mjs, or DOTNETCORE|10.0 for .NET. System-assigned identity. Base settings include TRUSTED_PROXY_HOPS=2. No health check path. |
| Key Vault | As on the server model, with the API’s identity as reader. |
Linked backend api | Connects the Web App to the Static Web App. /api/* on the site’s domain goes to the API. |
Extra parameters on the split model: staticWebAppLocation (default westeurope), apiSku (default B1) and customDomainType (default auto). The API’s region is location, which defaults to the resource group’s.
Outputs: siteHostName, apiHostName, staticWebAppName and apiName.
Note
Linking adds an identity provider to the API’s Web App so that it accepts only traffic that comes through the Static Web App. To call the API directly (a test, another client), change its Authentication settings in the Azure portal. Linked backends do not work in Static Web Apps pull request environments.
The parameter files
Edit infra/parameters/production.bicepparam before you deploy. A file for a split .NET site looks like this:
using '../main.bicep'
param name = 'acme-site-prod'
param staticWebAppLocation = 'westeurope'
param apiSku = 'B1'
param useKeyVault = true
param settings = {
MAIL_PROVIDER: 'resend'
MAIL_FROM: ''
CONTACT_FORM_TO: ''
}
param secretNames = [
'RESEND_API_KEY'
]
param customDomain = ''
param tags = {
environment: 'production'
}| Parameter | What to put |
|---|---|
name | A name that is free in Azure. The generated value is <base>-prod. |
settings | MAIL_FROM (the verified sender) and one recipient variable per form (CONTACT_FORM_TO, or the name you gave add-form --recipient-env). Fill in the empty values. See Mail. |
secretNames | The secrets the app reads. RESEND_API_KEY is always listed. TURNSTILE_SECRET_KEY is added when a form uses Turnstile. |
customDomain | Leave empty at first. See Custom domains. |
The static model’s file has only name, sku, customDomain, customDomainType and tags, since it has no run-time settings.
Deploy the infrastructure
You do this once per environment, and again when you change a parameter file.
-
Sign in and choose the subscription.
az login az account set --subscription "<subscription id or name>" -
Create the resource group. The server and split models suggest
swedencentral; a static site suggestswesteurope.az group create --name rg-acme-site --location swedencentral -
Deploy. Pass secrets on the command line, never in a file. In bash:
az deployment group create \ --resource-group rg-acme-site \ --parameters infra/parameters/production.bicepparam \ --parameters secrets='{"RESEND_API_KEY":"re_xxxxxxxx"}'In PowerShell:
az deployment group create ` --resource-group rg-acme-site ` --parameters infra/parameters/production.bicepparam ` --parameters secrets='{\"RESEND_API_KEY\":\"re_xxxxxxxx\"}'A static site has no secrets: leave out the last line. The command prints the outputs when it finishes.
-
Check the outputs. Open the host name from
defaultHostNameorsiteHostName. A new Static Web App shows Azure’s placeholder page until the pipeline has deployed.
The deployment is incremental: running it again changes only what differs.
Tip
hosting init prints this command with bash quoting. PowerShell versions differ in how they pass quotes to native commands. If the Azure CLI says the secrets value is not valid JSON, leave secrets out of the deployment and set each value with az keyvault secret set as shown below.
Key Vault and secrets
With useKeyVault = true (the default) the app settings do not hold secret values. Each secret in secretNames becomes a Key Vault reference:
@Microsoft.KeyVault(VaultName=acme-site-prod-kv;SecretName=RESEND_API_KEY)The Web App’s managed identity resolves the reference when the app starts. The vault holds the real value.
There are two ways to put a value in the vault:
-
Pass it in
secretswhen you deploy, as shown above. The Bicep writes every secret that is both insecretNamesand insecrets. -
Set it later with the Azure CLI:
az keyvault secret set --vault-name acme-site-prod-kv --name RESEND_API_KEY --value "re_xxxxxxxx"
Azure re-reads Key Vault references on its own schedule, so a new value can take a while to reach the app. Restart the Web App after you change a secret so that it picks up the new value straight away.
Your own account needs permission to write secrets. The vault uses role-based access, so give yourself Key Vault Secrets Officer on the vault if az keyvault secret set is refused.
With useKeyVault = false the Bicep puts the secret values into the app settings directly, taken from secrets. This is simpler and less safe: anyone who can read the Web App’s configuration can read them. Use it only for a short experiment.
Which secrets exist
| Secret | When | Where it is read |
|---|---|---|
RESEND_API_KEY | Forms send mail with Resend | The server or API |
TURNSTILE_SECRET_KEY | A form has Turnstile | The server or API |
AZURE_STATIC_WEB_APPS_API_TOKEN | Always | The pipeline, not Azure |
MDLAKE_LAKE_TOKEN | Always | The pipeline, not Azure |
The last two are not Key Vault secrets. They belong to the pipeline. See GitHub Actions and Azure DevOps.
Get the deployment token
The pipeline sends the pages to Static Web Apps with a deployment token. Read it once and store it as a pipeline secret.
az staticwebapp secrets list --name acme-site-prod --query properties.apiKey -o tsvThe name is the name parameter (the Static Web App itself, not the API).
Manual steps that remain
hosting init prints the steps that are left for you, adapted to your model and CI systems. Read them once after you run the command. They are the ones on this page and in GitHub Actions and Azure DevOps:
- The resource group.
- The parameter file and the deployment.
- The Key Vault secrets.
- The Static Web Apps deployment token (static and split models).
- The sign-in of the pipeline to Azure (server and split models).
- The custom domain.
Cost
Rough sizes, to help you choose; check the Azure price calculator for current prices.
- Static: the Free plan costs nothing. Standard costs a monthly fee per app.
- Server: one Basic B1 App Service plan.
- Split: Static Web Apps Standard plus one Basic B1 plan, plus the Key Vault (a small per-operation cost).
A site that does not need server code should stay static.
Edit the Bicep
Prefer parameters over editing modules. If you need something the modules do not offer (a database, a storage account, a second Web App), add your own Bicep file in infra/ and reference it from your own copy of main.bicep:
- Run
npx mdlake adopt infra/main.bicep. - Add your module and parameters.
- From now on you maintain
main.bicep. The generated modules still update withhosting init.
What’s next
- GitHub Actions or Azure DevOps to connect the pipeline.
- The lake in CI, and caches.
- Troubleshooting.
