Skip to content
mdlake

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

StaticServerSplit
PagesStatic Web AppsThe App Service (served by the Node server)Static Web Apps (Standard)
Server codeNoneAstro on-demand routes in the same App ServiceA separate backend in api/ on its own App Service
What the pipeline sendsThe dist folderdist plus production node_modules, started with node dist/server/entry.mjsThe API package, then the dist folder
Azure resourcesStatic Web AppApp Service plan, Web App, Key VaultStatic Web App, App Service plan, API Web App, Key Vault, linked backend
Sign-in of the pipeline to AzureA deployment tokenOpenID Connect (GitHub) or a service connection (Azure DevOps)Both: the token for the site, OpenID Connect or a service connection for the API
PlanFree or StandardBasic B1 by defaultStandard 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:

  1. Check out the repository and install Node 22 (and the .NET 10 SDK when the backend is .NET).
  2. Install dependencies with npm ci.
  3. Restore caches for the lake clone and the image caches. The key contains lake.lock. See The lake in CI, and caches.
  4. Install Chromium for the accessibility check.
  5. 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.
  6. 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 with az 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.lock file committed. Without it the pipeline cannot know which content to build.
  • npm run check passing 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

  1. Choose the model. Run npx mdlake hosting and read what it says. See Choose a hosting model.

  2. Write the files.

    Bash
    npx mdlake hosting init --ci github,azure-devops --name acme-site

    --ci is required. Leave out the CI systems you do not use. The command prints the manual steps for your model. Keep that output.

  3. Create the resource group and deploy the infrastructure with az. See Azure and Bicep.

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

  5. Give the pipeline access to Azure. See GitHub Actions or Azure DevOps.

  6. Commit and push to main. The pipeline runs. The first run is the slowest, because no cache exists yet.

  7. Open the address. Static Web Apps prints it as the siteHostName output of the deployment (the server model prints the Web App host name).

Secrets and settings

Three kinds of values exist. Keep them apart.

KindExamplesWhere it lives
Build-time, publicSITE_URL, PUBLIC_TURNSTILE_SITE_KEY, PUBLIC_ASSET_BASE_URLPipeline variables. The value ends up in the built pages, so it must not be secret.
Build-time, secretMDLAKE_LAKE_TOKEN, AZURE_STATIC_WEB_APPS_API_TOKENPipeline secrets.
Run-time, secretRESEND_API_KEY, TURNSTILE_SECRET_KEYThe Key Vault (default). The app reads them through Key Vault references.
Run-time, not secretMAIL_PROVIDER, MAIL_FROM, CONTACT_FORM_TO, TRUSTED_PROXY_HOPSThe 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:

ValueValidationUse for
auto (default)cname-delegation for a name with three or more labels, dns-txt-token for a name with two labelsMost domains
subdomaincname-delegationwww.acme.com
apexdns-txt-tokenacme.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):

  1. Create a CNAME record for the name that points to the default host name: the siteHostName output on the split model, the defaultHostName output on the static model.
  2. Set customDomain in infra/parameters/production.bicepparam.
  3. Deploy the infrastructure again.

An apex domain (acme.com): an apex domain cannot have a CNAME, so the validation uses a token.

  1. Set customDomain in infra/parameters/production.bicepparam and deploy the infrastructure.
  2. Read the token: az staticwebapp hostname show --name <app name> --hostname acme.com --query validationToken -o tsv.
  3. Create a TXT record for @ with that value.
  4. Point the apex at the site: an ALIAS or ANAME record for @ that points to the default host name (in Azure DNS, an alias record set), or an A record for @ that points to the stableInboundIP shown 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.

DomainRecords
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 create again. 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