Skip to content
mdlake

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

Bash
npx mdlake hosting init --ci github,azure-devops --name acme-site
FlagMeaning
--ci github,azure-devopsRequired. 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.
--overwriteReplace 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

FileStaticServerSplit
infra/main.bicepyesyesyes
infra/modules/staticWebApp.bicepyesyes
infra/modules/appService.bicepyes
infra/modules/apiApp.bicepyes
infra/modules/linkedBackend.bicepyes
infra/modules/keyVault.bicepyesyes
infra/parameters/staging.bicepparamyesyesyes
infra/parameters/production.bicepparamyesyesyes
.github/workflows/deploy.ymlwith --ci githubwith --ci githubwith --ci github
azure-pipelines.ymlwith --ci azure-devopswith --ci azure-devopswith --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 Unchanged for 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 new main.bicep by hand. A split site needs staticWebAppLocation and apiSku; a static site does not take settings;
  • leaves the modules of the old model behind when you switch, and tells you to delete them (infra/modules/appService.bicep is 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.

ParameterDefaultMeaning
namenoneThe Static Web App name.
locationwesteuropeStatic Web Apps exist in a few regions only.
skuFreeFree or Standard. The generated production file sets Standard, staging keeps Free.
customDomainemptyA domain such as www.acme.com, or empty. See Custom domains.
customDomainTypeautoauto, 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.

ResourceNotes
App Service planLinux, reserved: true. Size from sku, default B1.
Web AppNode 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 VaultName <name>-kv cut to 24 characters. Access by role (RBAC), soft delete for 30 days.
Role assignmentThe 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.

ParameterDefaultMeaning
namenoneThe Web App name, globally unique (<name>.azurewebsites.net). The plan is <name>-plan.
locationthe resource group’s locationRegion.
skuB1Plan size.
settings{}Non-secret settings.
secretNames[]Names of secret settings.
useKeyVaulttrueKeep secrets in a Key Vault.
secrets{}Secret values to write now. Marked @secure(): never stored in deployment history.
customDomainemptyA 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.

ResourceNotes
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>-apiLinux. 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 VaultAs on the server model, with the API’s identity as reader.
Linked backend apiConnects 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:

Bicep
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'
}
ParameterWhat to put
nameA name that is free in Azure. The generated value is <base>-prod.
settingsMAIL_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.
secretNamesThe secrets the app reads. RESEND_API_KEY is always listed. TURNSTILE_SECRET_KEY is added when a form uses Turnstile.
customDomainLeave 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.

  1. Sign in and choose the subscription.

    Bash
    az login
    az account set --subscription "<subscription id or name>"
  2. Create the resource group. The server and split models suggest swedencentral; a static site suggests westeurope.

    Bash
    az group create --name rg-acme-site --location swedencentral
  3. Deploy. Pass secrets on the command line, never in a file. In bash:

    Bash
    az deployment group create \
      --resource-group rg-acme-site \
      --parameters infra/parameters/production.bicepparam \
      --parameters secrets='{"RESEND_API_KEY":"re_xxxxxxxx"}'

    In PowerShell:

    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.

  4. Check the outputs. Open the host name from defaultHostName or siteHostName. 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 secrets when you deploy, as shown above. The Bicep writes every secret that is both in secretNames and in secrets.

  • Set it later with the Azure CLI:

    Bash
    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

SecretWhenWhere it is read
RESEND_API_KEYForms send mail with ResendThe server or API
TURNSTILE_SECRET_KEYA form has TurnstileThe server or API
AZURE_STATIC_WEB_APPS_API_TOKENAlwaysThe pipeline, not Azure
MDLAKE_LAKE_TOKENAlwaysThe 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.

Bash
az staticwebapp secrets list --name acme-site-prod --query properties.apiKey -o tsv

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

  1. The resource group.
  2. The parameter file and the deployment.
  3. The Key Vault secrets.
  4. The Static Web Apps deployment token (static and split models).
  5. The sign-in of the pipeline to Azure (server and split models).
  6. 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:

  1. Run npx mdlake adopt infra/main.bicep.
  2. Add your module and parameters.
  3. From now on you maintain main.bicep. The generated modules still update with hosting init.

What’s next