Deploying a site
What gets deployed where for each hosting model, which secrets and settings it needs, and the order of the first deployment. Start here before the Azure and pipeline pages. For developers.
On this page
A site built with the kit deploys the same way every time: a pipeline checks the site, builds it from a locked lake commit and sends the result to Azure. The kit writes the pipeline and the Azure infrastructure files for you with one command, npx mdlake hosting init. This page explains what those files do, what you need before you run them, and what changes for each hosting model.
What gets deployed where
| Static | Server | Split | |
|---|---|---|---|
| Pages | Static Web Apps | The App Service (served by the Node server) | Static Web Apps (Standard) |
| Server code | None | Astro on-demand routes in the same App Service | A separate backend in api/ on its own App Service |
| What the pipeline sends | The dist folder | dist plus production node_modules, started with node dist/server/entry.mjs | The API package, then the dist folder |
| Azure resources | Static Web App | App Service plan, Web App, Key Vault | Static Web App, App Service plan, API Web App, Key Vault, linked backend |
| Sign-in of the pipeline to Azure | A deployment token | OpenID Connect (GitHub) or a service connection (Azure DevOps) | Both: the token for the site, OpenID Connect or a service connection for the API |
| Plan | Free or Standard | Basic B1 by default | Standard for the Static Web App, B1 by default for the API |
Everything is generated for the model recorded in .mdlake/hosting.json. If the model changes, run hosting init again and the generated files change with it. See Azure and Bicep for the resources and parameters.
What the pipeline does
Every pipeline the kit writes runs on a push to main (and by hand: workflow_dispatch in GitHub Actions) and does the same things in this order:
- Check out the repository and install Node 22 (and the .NET 10 SDK when the backend is .NET).
- Install dependencies with
npm ci. - Restore caches for the lake clone and the image caches. The key contains
lake.lock. See The lake in CI, and caches. - Install Chromium for the accessibility check.
- Run
npm run check. This fetches the lake at the locked commit, runs the checks and builds the site. A failing check stops the deployment. - Deploy. On the split model the API goes first and the pages after it, so the new pages never call an API that does not exist yet.
Only one deployment runs at a time (concurrency: deploy in GitHub Actions, no cancelling). A second push waits.
The site is always built from the commit in lake.lock, not from the latest content in the lake. Publishing in the CMS therefore never changes a live site by itself. A developer refreshes the lock, reviews the change and commits it. See How sites work and Refresh content.
Before the first deployment
You need:
- An Azure subscription and the Azure CLI (
az), signed in withaz login. The commands in these pages work in bash and in PowerShell; where they differ the page shows both. - The site’s repository on GitHub or in Azure DevOps.
- A read-only token for the lake repository, to put in the pipeline as
MDLAKE_LAKE_TOKEN. See The lake in CI, and caches. - The
lake.lockfile committed. Without it the pipeline cannot know which content to build. npm run checkpassing on your machine.- For mail from forms: a Resend API key and a verified sender address. See Mail.
- For Turnstile: a widget in Cloudflare. See Turnstile.
The first deployment, in order
-
Choose the model. Run
npx mdlake hostingand read what it says. See Choose a hosting model. -
Write the files.
npx mdlake hosting init --ci github,azure-devops --name acme-site--ciis required. Leave out the CI systems you do not use. The command prints the manual steps for your model. Keep that output. -
Create the resource group and deploy the infrastructure with
az. See Azure and Bicep. -
Set the secrets. The resource group’s Key Vault holds the app’s secrets. The pipeline’s own secrets go in GitHub or Azure DevOps. See Secrets and settings.
-
Give the pipeline access to Azure. See GitHub Actions or Azure DevOps.
-
Commit and push to
main. The pipeline runs. The first run is the slowest, because no cache exists yet. -
Open the address. Static Web Apps prints it as the
siteHostNameoutput of the deployment (the server model prints the Web App host name).
Secrets and settings
Three kinds of values exist. Keep them apart.
| Kind | Examples | Where it lives |
|---|---|---|
| Build-time, public | SITE_URL, PUBLIC_TURNSTILE_SITE_KEY, PUBLIC_ASSET_BASE_URL | Pipeline variables. The value ends up in the built pages, so it must not be secret. |
| Build-time, secret | MDLAKE_LAKE_TOKEN, AZURE_STATIC_WEB_APPS_API_TOKEN | Pipeline secrets. |
| Run-time, secret | RESEND_API_KEY, TURNSTILE_SECRET_KEY | The Key Vault (default). The app reads them through Key Vault references. |
| Run-time, not secret | MAIL_PROVIDER, MAIL_FROM, CONTACT_FORM_TO, TRUSTED_PROXY_HOPS | The settings object in infra/parameters/<environment>.bicepparam. |
Warning
Never write a secret into a file in the repository, including the parameter files. Pass secrets on the command line when you deploy, or set them in the Key Vault with az keyvault secret set.
A static site has no run-time settings, because it has no server. The static pipeline still passes SITE_URL and PUBLIC_TURNSTILE_SITE_KEY to the build.
Staging and production
hosting init writes two parameter files, infra/parameters/staging.bicepparam and infra/parameters/production.bicepparam. Deploy each into its own resource group to get two independent environments (names <base>-stg and <base>-prod). The staging parameter files use the Free plan for a static site.
The generated pipeline deploys one environment, the production one, from main. To deploy staging from another branch, copy the pipeline, change the trigger and the variables, and keep your edit with npx mdlake adopt <file>. See Ownership zones.
For a staging site that search engines must not index, set SITE_INDEXABLE=false in the build. Every page then gets noindex and robots.txt disallows everything. See Site configuration.
Custom domains
The DNS records you create depend on the hosting model. npx mdlake hosting init prints the same steps for your tier.
Static and split: Static Web Apps
Static Web Apps issues and renews the HTTPS certificate itself once the domain validates. The customDomainType parameter in the Bicep decides how the domain is validated:
| Value | Validation | Use for |
|---|---|---|
auto (default) | cname-delegation for a name with three or more labels, dns-txt-token for a name with two labels | Most domains |
subdomain | cname-delegation | www.acme.com |
apex | dns-txt-token | acme.com, and a domain under a two-part suffix such as acme.co.uk |
auto counts labels, so acme.co.uk looks like a subdomain to it. Set param customDomainType = 'apex' in infra/parameters/production.bicepparam for such a domain.
A subdomain (www.acme.com):
- Create a
CNAMErecord for the name that points to the default host name: thesiteHostNameoutput on the split model, thedefaultHostNameoutput on the static model. - Set
customDomainininfra/parameters/production.bicepparam. - Deploy the infrastructure again.
An apex domain (acme.com): an apex domain cannot have a CNAME, so the validation uses a token.
- Set
customDomainininfra/parameters/production.bicepparamand deploy the infrastructure. - Read the token:
az staticwebapp hostname show --name <app name> --hostname acme.com --query validationToken -o tsv. - Create a
TXTrecord for@with that value. - Point the apex at the site: an
ALIASorANAMErecord for@that points to the default host name (in Azure DNS, an alias record set), or anArecord for@that points to thestableInboundIPshown in the Static Web App’s JSON view.
The domain stays in the state Validating until the TXT record is public. That can take up to an hour. Changes to an apex record can take up to 72 hours to spread.
Server: App Service
On the server model the domain is on the Web App. The Bicep creates the free App Service managed certificate and binds it, but only when customDomain is set. The plan must be B1 or larger. The DNS records come first, because the deployment stops at the host name binding without them.
| Domain | Records |
|---|---|
Subdomain (www.acme.com) | CNAME www to <app name>.azurewebsites.net (the defaultHostName output), and TXT asuid.www with the value of the customDomainVerificationId output |
Apex (acme.com) | A @ to the inbound IP address of the app (Azure portal, the Web App, Custom domains), and TXT asuid with the same verification id |
Managed certificates support an apex domain but not a wildcard. Then set customDomain in the parameter file and deploy again. The deployment binds the host name, creates the certificate, binds it again with SNI (in infra/modules/hostnameSni.bicep) and Azure renews it. Read the outputs with az deployment group show and --query properties.outputs.
Set SITE_URL to the final address in the pipeline variables, so canonical links, the sitemap and social tags use it.
After the first deployment
- A normal change is a push to
main. Nothing else is needed. - To change the infrastructure (a new setting, a bigger plan, a custom domain), edit the parameter file and run
az deployment group createagain. The deployment is incremental and safe to repeat. - To change the pipeline, rerun
hosting init. A generated file you edited by hand is not overwritten. The command exits with code 3 and says which file it refused. See Azure and Bicep.
What’s next
- Azure and Bicep for what the Bicep creates and how to deploy it.
- GitHub Actions or Azure DevOps for the pipeline.
- The lake in CI, and caches for the lake token and the caches.
- Troubleshooting when a deployment fails.
