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.
At a glance
Section titled “At a glance”| SHC connection type | azure |
| CredFields | subscription_id, resource_group, tenant_id, client_id, client_secret (+ optional region) |
| DNS approaches | native Azure DNS (estate default, NS-delegated) or cloudflare-parent (point at a zone you own) |
| DNS provider impl | azure (lego code azuredns) |
| Volume provider | azure-disk (managed disks — StandardSSD_LRS/Premium_LRS) — UNVERIFIED-LIVE |
| LB provider | azure-lb (Standard Load Balancer, supports UDP) — wired but off in the estate |
| Terraform module | terraform/modules/shc/azure |
| Cluster VM user | a non-reserved admin user (debian/shcadmin — never admin/root) |
1. Credentials
Section titled “1. Credentials”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.
The terraform service principal
Section titled “The terraform service principal”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:
export ARM_SUBSCRIPTION_ID=... # the subscription the cluster lives inexport ARM_TENANT_ID=... # the Entra ID (Azure AD) tenantexport ARM_CLIENT_ID=... # the SP's application (client) idexport ARM_CLIENT_SECRET=... # the SP's client secretCreate one with the CLI:
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.
Why the daemon reuses the same principal
Section titled “Why the daemon reuses the same principal”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:
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:
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.
2. The azure connection
Section titled “2. The azure connection”One shc_connection { type = "azure" } lights three daemon provider slots
off the single service principal:
| slot | impl | needs |
|---|---|---|
dns | azure (Azure DNS) | subscription_id + resource_group |
volume | azure-disk (managed disks) | subscription_id + resource_group (+ region) |
proxy | azure-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.
Native Azure DNS (the estate default)
Section titled “Native Azure DNS (the estate default)”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
azuresubdomain — never touch other records in the parent zone (productionapi/wwwCNAMEs, 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).
4. Minimal end-to-end deploy
Section titled “4. Minimal end-to-end deploy”The azure-shc example folds
substrate + bootstrap + platform + demo apps into one root. Short version:
# 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 itexport TF_VAR_vault_password=...
# 3. apply — substrate first (the shc provider transport needs the node IP)cd terraform/examples/azure-shctofu inittofu apply -target=module.shctofu applytofu output gitea_url # https://git.example.com, real LE certThe 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.
5. First-live gotchas
Section titled “5. First-live gotchas”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.
- Register the quota resource provider and raise total cores:
(10 → 24 gives headroom for a 4-node cluster.)
Terminal window az provider register --namespace Microsoft.Quotaaz quota update --resource-name cores \--scope /subscriptions/<SUB>/providers/Microsoft.Compute/locations/eastus \--limit-object value=24 - Pivot the VM size to the v4 family —
Standard_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 toStandard_D2s_v4for exactly this reason. Only reach for a v5 size after raising its family quota. (x86 amd64 only — the shc.debis amd64; never AmpereDpls/Dps/Epssizes.)
Reserved admin username
Section titled “Reserved admin username”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 azure → azuredns,
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 unset → regional 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.
6. Volumes and load balancer
Section titled “6. Volumes and load balancer”- Volumes —
azure-disk(managed disks;StandardSSD_LRSby default,Premium_LRSavailable). The connection already carries thesubscription_id+resource_groupthe provider needs (empty ones →X400334AZDCFG). Wire it withstorage: { volumes: { provider: azure } }inshc_config. UNVERIFIED-LIVE: theazurediskprovider 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-diskis also the one block provider still missingVolumeLocationReporter, 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). Theazureconnection 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.passwordwiring, mirroring the AWS root’s restic → S3.
See also
Section titled “See also”terraform/examples/azure-shc— the runnable example this guide references.tests/clouds/azure— the live smoke-test tier (make test/azure): DNS + Let’s Encrypt cert assertion.terraform/modules/shc/azure— the substrate module (its README has the azure-vs-hetzner/aws delta table and the SSH-on-Azure mechanics).