Skip to content
SHC Docs

Azure

Standing up an SHC cluster on Microsoft Azure: the substrate (resource group

  • VNet + VMs), the platform connection that lights DNS / managed-disk volumes / the Standard Load Balancer, and the first-live quirks a fresh Azure subscription throws at you.

This guide tracks a live azure/eastus estate kept in a private ops repo (the proven, running config) and the azure-shc example. Azure was the 5th cloud brought up in the multi-cloud campaign and went green end-to-end — 4-node cluster + apps + a real Let’s Encrypt cert — so everything below is verified live, with the two remaining UNVERIFIED-LIVE surfaces (managed-disk volumes, the Standard LB) called out.

SHC connection typeazure
CredFieldssubscription_id, resource_group, tenant_id, client_id, client_secret (+ optional region)
DNS approachesnative Azure DNS (estate default, NS-delegated) or cloudflare-parent (point at a zone you own)
DNS provider implazure (lego code azuredns)
Volume providerazure-disk (managed disks — StandardSSD_LRS/Premium_LRS) — UNVERIFIED-LIVE
LB providerazure-lb (Standard Load Balancer, supports UDP) — wired but off in the estate
Terraform moduleterraform/modules/shc/azure
Cluster VM usera non-reserved admin user (debian/shcadmin — never admin/root)

Azure needs one service principal and, for the DNS delegation, a Cloudflare token. There is no scoped daemon sub-credential on Azure (see the note below), so the same service principal does double duty.

Terraform authenticates to Azure with a service principal that has Owner (or Contributor + DNS Zone Contributor) on the subscription. The azurerm provider reads it natively from the environment — no TF_VAR remap:

Terminal window
export ARM_SUBSCRIPTION_ID=... # the subscription the cluster lives in
export ARM_TENANT_ID=... # the Entra ID (Azure AD) tenant
export ARM_CLIENT_ID=... # the SP's application (client) id
export ARM_CLIENT_SECRET=... # the SP's client secret

Create one with the CLI:

Terminal window
az ad sp create-for-rbac --name shc-terraform \
--role Owner \
--scopes /subscriptions/<SUBSCRIPTION_ID>
# → appId (ARM_CLIENT_ID), password (ARM_CLIENT_SECRET),
# tenant (ARM_TENANT_ID)

az login (a user principal) also works for interactive runs. Never put any of these in a tfvars file or state.

On AWS/GCP the estate mints a scoped sub-credential for the daemon. On Azure it can’t: minting a second service principal needs Entra ID Graph rights (Application.Administrator) that the subscription Owner role does not confer. So the daemon’s azure connection reuses the terraform service principal (the DigitalOcean estate’s account-token-reuse analogue). Because Owner is a superset of DNS Zone Contributor + Contributor, no extra role assignment is needed for the connection’s dns / volume / lb surfaces.

The catch: the azurerm provider cannot expose the client secret back to terraform, so the connection can’t read it from the provider. The SP’s id + secret therefore ride into the connection as variables:

Terminal window
export TF_VAR_azure_client_id="$ARM_CLIENT_ID"
export TF_VAR_azure_client_secret="$ARM_CLIENT_SECRET"

subscription_id and tenant_id are non-secret coordinates — the estate hardcodes them as locals (the AWS root’s account-id / GCP root’s project analogue); only the id + secret are inputs.

The Cloudflare token (native-DNS path only)

Section titled “The Cloudflare token (native-DNS path only)”

When you use native Azure DNS delegated out of a Cloudflare-hosted parent (the estate default), terraform creates the NS delegation records in the parent zone with a Cloudflare token scoped to Zone:DNS:Edit:

Terminal window
export TF_VAR_cloudflare_token=...

If you instead point DNS straight at a zone you own (the cloudflare-parent path — see below), this same token becomes the DNS connection’s credential.

One shc_connection { type = "azure" } lights three daemon provider slots off the single service principal:

slotimplneeds
dnsazure (Azure DNS)subscription_id + resource_group
volumeazure-disk (managed disks)subscription_id + resource_group (+ region)
proxyazure-lb (Standard LB)+ region (+ vnet, IP-based backend pool)

CredFields (all five required at connect): subscription_id, resource_group, tenant_id, client_id, client_secret. region (the Azure location) is an optional extra the azure-lb / azure-disk providers read; the azure-dns provider ignores it. The connect-time prober validates the credentials and confirms zone visibility, so a credential or role mistake fails loudly at apply — not as a silent runtime zone-miss. (Azure role propagation can lag ~a minute; re-apply if the first probe fails.)

Provider >= 0.0.322 creates the connection at system scope, so the tenant-less cluster-host DNS surface and every default-tenant app resolve it directly.

resource "shc_connection" "azure" {
name = "azure"
type = "azure"
credentials = {
subscription_id = local.subscription_id
tenant_id = local.tenant_id
client_id = var.azure_client_id
client_secret = var.azure_client_secret
resource_group = module.shc.resource_group_name
region = module.shc.region
}
zones = ["azure.example.com"]
depends_on = [module.shc]
}

3. DNS: native Azure DNS vs cloudflare-parent

Section titled “3. DNS: native Azure DNS vs cloudflare-parent”

SHC on Azure supports two DNS shapes. Pick by which zone hosts your records.

azure.example.com is a real Azure DNS zone, NS-delegated out of a Cloudflare-hosted parent example.com. Terraform creates the (empty) zone in the same resource group as the cluster, then writes exactly the NS rows for the azure subdomain into the parent (Azure always assigns 4 name servers per zone). The daemon fills the zone through the azure connection.

Why native DNS here. NS-delegation only pays off where zone hosting is bundled free — Azure/GCP/DigitalOcean. Co-locating the DNS zone, the managed disks, and the VMs in one resource group keeps the connection’s single resource_group credential coherent (the azure-dns provider finds the zone and the azure-disk provider creates disks under the same cred), and makes tofu destroy remove the whole estate cleanly. The daemon self-registers the cluster-host A record (cloud.azure.example.com) every 60s leader tick via RegisterSystemHostDNS once dns.provider + bootstrap.host are set — no hand-managed record, no drift on a node rebuild.

Strict scope on the Cloudflare side. The delegation root must create ONLY the NS rows for the azure subdomain — never touch other records in the parent zone (production api/www CNAMEs, etc.).

Cloudflare-parent (point at a zone you own)

Section titled “Cloudflare-parent (point at a zone you own)”

Skip Azure DNS entirely: make the DNS connection a cloudflare connection (CredFields = [token]) whose zones is a domain you already own, and the daemon writes records straight into that zone — no delegation, no Azure DNS zone. This is the “cloudflare-parent-mix” the campaign converged on as the documented default estate shape, and it is what the azure-shc example and the tests/clouds/azure smoke test use by default, so a tester needs only a single Cloudflare domain. The Azure substrate is unchanged; only the DNS slot moves. (You lose the one-credential co-location with azure-disk — wire the volume slot with a separate azure connection if you need managed disks.)

Either way, traefik gets a real Let’s Encrypt cert via DNS-01 (the azure DNS provider maps to the lego code azuredns; cloudflare maps to cloudflare).

The azure-shc example folds substrate + bootstrap + platform + demo apps into one root. Short version:

Terminal window
# 1. shc provider mirror (the provider is not on the public registry)
cat > ~/.tofurc <<'EOF'
provider_installation {
network_mirror { url = "https://<your-group>-<project>.gitlab.io/mirror/" }
direct { exclude = ["registry.opentofu.org/selfhosted-cloud/shc"] }
}
EOF
# 2. credentials (env only)
export ARM_SUBSCRIPTION_ID=... ARM_TENANT_ID=... ARM_CLIENT_ID=... ARM_CLIENT_SECRET=...
export TF_VAR_dns_zone=example.com # a zone you own (cloudflare)
export TF_VAR_cloudflare_token=... # Zone:DNS:Edit on it
export TF_VAR_vault_password=...
# 3. apply — substrate first (the shc provider transport needs the node IP)
cd terraform/examples/azure-shc
tofu init
tofu apply -target=module.shc
tofu apply
tofu output gitea_url # https://git.example.com, real LE cert

The example builds three Standard_D2s_v4 compose nodes (regional), a cloudflare DNS connection, dns.provider + acme.email, and postgres + gitea (public). See its README for the full variable table and the native-Azure-DNS variant.

Every one of these was hit on a real fresh subscription (2026-07-13/14) and is a one-layer module/estate fix — none needed daemon changes.

Fresh-subscription core quota (the big one)

Section titled “Fresh-subscription core quota (the big one)”

Symptom. A brand-new Azure subscription ships 10 total regional vCPU cores, and most VM families — including DSv5 (Standard_D2s_v5, …) — ship at quota 0. A tofu apply with a v5 size fails at VM create: Operation could not be completed as it results in exceeding approved … quota. Current Limit: 0, and the request is auto-denied.

Fix / workaround.

  1. Register the quota resource provider and raise total cores:
    Terminal window
    az provider register --namespace Microsoft.Quota
    az quota update --resource-name cores \
    --scope /subscriptions/<SUB>/providers/Microsoft.Compute/locations/eastus \
    --limit-object value=24
    (10 → 24 gives headroom for a 4-node cluster.)
  2. Pivot the VM size to the v4 familyStandard_D2s_v4 (2 vCPU / 8 GB) draws on the v4 family’s default 10-core quota, so it works with no quota request at all. The example and the estate both default to Standard_D2s_v4 for exactly this reason. Only reach for a v5 size after raising its family quota. (x86 amd64 only — the shc .deb is amd64; never Ampere Dpls/Dps/Eps sizes.)

Symptom. First apply fails at plan/create for all nodes: "admin_username" specified is not allowed, got "admin". Azure blocks a fixed list of reserved usernames — admin, root, administrator, user, guest, … — and azurerm_linux_virtual_machine rejects them.

Fix. Set admin_username to something off the list. The module defaults to debian — a deliberate divergence from every other SHC cloud module, which dials a dedicated admin user Azure simply cannot; the live estate uses shcadmin. Whatever you pick, ssh.user must match it — that account is the one SHC dials (Azure lands the VM’s admin_ssh_key on it, cloud-init layers the merged keys + NOPASSWD sudo, and the provider wraps every remote command in sudo -n; root SSH is disabled at the image).

DNS-01 lego code mismatch (fixed upstream)

Section titled “DNS-01 lego code mismatch (fixed upstream)”

Symptom (historical). Traefik DNS-01 cert issuance silently failed on Azure because the seed rendered the SHC impl name azure as the lego provider code, but lego wants azuredns.

Fix. Already fixed in-product (LegoProvider() maps azureazuredns, commit 0fe8033c2) — nothing to do on a current build. Noted so it isn’t rediscovered. The live estate serves a real LE cert via DNS-01 on Azure DNS.

NS delegation must exist before the connection probes

Section titled “NS delegation must exist before the connection probes”

Symptom. The azure connection’s connect-time prober can’t see the zone (or DNS-01 fails) if azure.example.com isn’t delegated yet.

Fix. The delegation is a day-0 concern: create the Azure DNS zone and its parent NS records first (terraform sequences this via depends_on on the connection). The zone is a real Azure DNS zone delegated out of the Cloudflare-hosted parent; self-registration writes into that zone, not Cloudflare. (Irrelevant on the cloudflare-parent path — you already own the zone.)

Regional placement (deliberate) vs zonal disks

Section titled “Regional placement (deliberate) vs zonal disks”

Symptom. Azure managed disks are zone-scoped — a zone-pinned VM can only attach a same-zone disk, so a multi-zone cluster would strand the azure-disk volume provider’s disks.

Fix. The module leaves zone unsetregional placement, keeping VMs and their managed disks co-located without betting on azure-disk’s (still UNVERIFIED-LIVE) zonal behavior. Pin a single zone only once the volume provider’s zone handling is proven live.

  • Volumes — azure-disk (managed disks; StandardSSD_LRS by default, Premium_LRS available). The connection already carries the subscription_id + resource_group the provider needs (empty ones → X400334AZDCFG). Wire it with storage: { volumes: { provider: azure } } in shc_config. UNVERIFIED-LIVE: the azuredisk provider has never run a full provision/attach cycle against a real subscription — the regional (no-zone) cluster keeps disk/VM placement coherent for its first exercise. azure-disk is also the one block provider still missing VolumeLocationReporter, so it can’t yet participate in location-based cross-node rebind (a known coverage gap).
  • Load balancer — azure-lb (Standard Load Balancer; supports UDP). The azure connection lights the proxy slot; the 30000-32767 node-port range the module opens is where the LB passthrough lands, so no extra NSG work is needed. Off in the estate — flip on with a port attach on the system traefik stack (conn@80 / conn@443).
  • Backup (restic → Azure Blob) — deliberately absent. Azure Blob uses a storage-account key / SAS (console- or terraform-minted), not the reused SP secret, so it needs its own azurerm_storage_account + azurerm_storage_container + backup.repo/backup.password wiring, mirroring the AWS root’s restic → S3.