tofu fmt,
tofu validate and tofu test against mocked providers, and tofu plan ran against the
owner’s Azure subscription and the Cloudflare zone of veristar.app (section 3.6). Nothing has
been applied yet. Section 5 lists what still needs an owner decision or input.
1. Inventory contract (OpenTofu to Ansible)
This section is the agreement with the Ansible side. Change it only together.File
make staging-applyandmake staging-inventorywriteinfra/ansible/inventories/staging/hosts.json; production writesinfra/ansible/inventories/production/hosts.json.- The file is the JSON value of the OpenTofu output
ansible_inventory(tofu output -json ansible_inventory). It is generated, never edited, and gitignored. - The format is Ansible’s YAML inventory format written as JSON. The stock
yamlinventory plugin reads.jsonfiles, so no custom plugin is needed. Point Ansible at the directory (-i infra/ansible/inventories/staging/) sogroup_vars/besidehosts.jsonis picked up.group_vars/all.ymlis committed and holds what every Azure node shares (sa_tls: cloudflare,sa_public_bind_ipv4, see below).
Shape
- Groups are always
edge(3 hosts),core(2 hosts),probeandrestore(0 or 1 host, present only whilerestore_enabled, section 4). The group keys are always present. The probe runs outside Azure as a container (probe.md), so OpenTofu leavesprobeempty; the key stays so playbooks that name the group keep working. - Inventory host names equal the VM names:
<env>-<tier>-<index>, index from 1. Index 1 ofcoreis the node that runs PostgreSQL first. - Values written as
<I4>and<I13>above are the real inputs once supplied; the file never contains anything else in their place. sa_object_storagekeeps the shape it had for S3:endpointis the storage account’s blob endpoint,backup_bucketthe container. Thebackuprole derives the account name from the endpoint.
Host vars (every host)
Peers are derived by Ansible from the groups (for example NATS routes and etcd members
from
groups['edge'] | map('extract', hostvars, 'sa_private_ip')). OpenTofu does not
emit peer lists, so there is one source of truth.
Things the Ansible and Caddy side can rely on
- Azure maps each public IPv4 address onto the VM’s one network interface (1:1 NAT): the VM
holds only its private address. Public ports are therefore published on the private address
(
sa_public_bind_ipv4ingroup_vars/all.yml), and thebaserole accepts that the public and private interface are the same on Azure. The client’s source address is kept. - The network security group, on every subnet: TCP 80, TCP 443, UDP 443 (HTTP/3) and ICMP from
the internet; TCP 22 only from the admin CIDRs (I13); everything else from the internet is
denied. Azure’s default rules allow traffic inside the virtual network; the host firewall of
the
baserole and the internal mTLS cover it, as on every private link. - Traffic Manager probes every node over HTTP on port 80, path
/, and counts 200 to 299 and 308 as healthy (it does not follow redirects). The Host header it sends is the endpoint’s address, so the port 80 health answer must not depend on the Host header (it does not). - Cloud-init only creates
opsand installs SSH keys. Changes tocustom_data, the admin keys and the image version are ignored after creation, so key rotation through Ansible never replaces a VM. - The core and restore nodes carry the user-assigned managed identity
<env>-core, which holds Storage Blob Data Contributor on the backup container and nothing else. Edge nodes have no identity.
Secrets handed to OpenTofu
Azure is reached through the operator’saz login (the azurerm provider and backend use the
Azure CLI credential), so no Azure secret is stored. The make targets load the rest with
sops exec-env infra/secrets/<env>/tofu.enc.env '<tofu command>'. That SOPS dotenv file must be
covered by the creation_rules in infra/secrets/.sops.yaml and holds:
Non-secret inputs live in
infra/tofu/envs/<env>/terraform.tfvars (committed once the
inputs exist). tests/infra/sample.tfvars shows every variable with documentation-only
values; it is a test fixture, not a template with real values.
2. Layout
Subnets:
10.x.1.0/24 zone 1, 10.x.2.0/24 zone 2, 10.x.3.0/24 zone 3. Node addresses in
their zone’s subnet are .11 to .13 for edge nodes 1 to 3, .21, .22 for core nodes 1 and
2 and .41 for the restore node (staging: edge-1 10.20.1.11, edge-2 10.20.2.12, edge-3
10.20.3.13, core-1 10.20.1.21, core-2 10.20.2.22), so a private address tells the tier and
zone. Each subnet has the Microsoft.Storage service endpoint and no default outbound access
(every node has its own public address).
3. Research findings (pinned versions, read 2026-11-13)
3.1 OpenTofu 1.13.9
Unchanged pin (plan section 5). Releases come withtofu_<version>_SHA256SUMS; on 2026-11-13
tofu_1.13.9_darwin_arm64.zip matched its line in that file before it was unpacked.
3.2 hashicorp/azurerm 5.13.0
Newest release on registry.opentofu.org on 2026-11-13 (published 2026-11-05). azurerm cuts a
minor release every week, so the pin follows the plan’s rule for fast-moving packages: the
newest release at least 7 days old. Arguments were checked against the provider’s schema
(tofu providers schema -json) and by tofu validate and tofu plan. Points that shaped the
modules:
azurerm_subnettakesservice_endpointblocks anddefault_outbound_access_enabled.- The provider is configured with
resource_provider_registrations = "none", so OpenTofu needs no subscription-wide registration rights, andstorage_use_azuread = true, because the storage accounts accept no account keys. azurerm_storage_accountsets account-level version-level immutability throughimmutability_policy(period_since_creation_in_days,state), which needs versioning and is set when the account is created.azurerm_traffic_manager_profilesupportstraffic_routing_method = "MultiValue",max_returnandmonitor_config.expected_status_code_ranges.
3.3 cloudflare/cloudflare 5.28.1
Newest release at least 7 days old on 2026-11-13 (5.29.0 was 2 days old). cloudflare_dns_record
takes the full record name; CAA values go in data = { flags, tag, value }. The zone is looked
up with data "cloudflare_zone" and filter = { name = ... }, and is never created or changed
by OpenTofu: it belongs to the registrar account.
3.4 Health-checked failover: Traffic Manager, compared with the alternatives
The edge and core nodes each keep their own public address, as before; what was needed is DNS that answers with the healthy ones. Three options were compared on 2026-11-13:
Traffic Manager meets the need at the lowest published price: about 17.40 USD a month for the
ten endpoints, with 10 s probing on the two ingestion names and 30 s on the dashboard names. It
keeps the shape the design was built on (three independent edge addresses, any of which serves
a client) and Microsoft’s service level agreement for Traffic Manager states 99.99 %. Cloudflare points each
hostname at its profile with a DNS-only CNAME (
<domain>-<env>-<label>.trafficmanager.net).
Failover time per name (Traffic Manager monitoring docs): an endpoint leaves after three failed
probes in a row (tolerated_number_of_failures = 2). For in and otlp: 3 x 10 s, plus the 5 s
timeout, plus up to 10 s of cached answers. For app and web: 3 x 30 s, plus the 10 s timeout,
plus up to 30 s. When every endpoint of a profile fails, Traffic Manager answers with all of them
rather than none.
CAA: in.<domain> and the others are CNAMEs to trafficmanager.net. A CA looks up CAA at the
name, follows the CNAME, finds none at the profile name, and continues at the parent of the
original name, <domain>, whose CAA allows letsencrypt.org only (RFC 8659). DNS-01 writes
_acme-challenge.<name> TXT records, which are separate names and unaffected by the CNAMEs.
3.5 State backend: azurerm
The state of each environment is one blob,<env>/tofu.tfstate, in container tfstate of a
storage account that make state-bootstrap creates outside the environment’s resource group
(zone-redundant, account keys off, blob versioning, 35 days of soft delete). The backend signs in
with Entra ID (use_azuread_auth = true, from the az CLI login) and locks the state with a blob
lease. The signed-in user needs Storage Blob Data Contributor on the state account, which the
bootstrap script grants.
3.6 Plan against the real subscription (2026-11-13)
Run on the owner’s Mac (the only machine with the az login): the configuration was copied to a scratch directory, OpenTofu 1.13.9 installed there after the checksum check, and a local backend override used in place of the not yet existing state account. Inputs:base_domain = "veristar.app", a documentation-only admin range (192.0.2.0/24), a throwaway SSH key,
server_image_version = "0.20261104.2652", backup accounts veristarprodbackups and
veristarstgbackups (both reported available by az storage account check-name). The
Cloudflare provider used the zone-scoped token of input I6 read-only; the zone lookup returned
the veristar.app zone, which held no records.
Production, by kind: 1 resource group, 1 managed identity, 1 virtual network, 3 subnets with 3
NSG associations, 1 NSG with 6 rules, 5 public IPv4 addresses, 5 network interfaces, 5 VMs,
10 delete locks on VMs and addresses, 1 storage account with 1 container, 1 lifecycle policy,
1 role assignment and 1 delete lock, 4 Traffic Manager profiles with 10 endpoints, and in
Cloudflare 4 CNAMEs and 2 CAA records. Nothing was applied.
3.7 Local validation (2026-11-13)
tofu fmt -recursive -check,tofu init -backend=falseandtofu validatepass for both envs with the pinned providers.tofu test(tests/infra, mocked providers,command = apply) renders both roots fromtests/infra/sample.tfvarsand checks: the groups, host names, every host var, zones, the private address scheme, the group vars, the default VM sizes, the Traffic Manager endpoints (three edge addresses forinandotlp, two core forappandweb), MultiValue routing, the HTTP probe on port 80 accepting 308, 10 s probing for ingestion and 30 s for dashboards, one DNS-only CNAME per hostname to its profile, CAA (issue letsencrypt.org,issuewild ;), the restore node, and that0.0.0.0/0for SSH, a short passphrase and a floating image version are refused. 8 of 8 runs pass per env.
3.8 Production apply (2026-11-13)
Run on the owner’s Mac, from a scratch copy of the repository (synced with rsync, withoutresources/), with OpenTofu 1.13.9 and SOPS 3.13.3 for darwin_arm64, each checked against its
release checksums file. The Mac holds the az login and the owner’s age identity, so no Azure
credential was created for another machine. SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt is
needed on macOS, where SOPS otherwise looks under ~/Library/Application Support/sops/age/.
The failed VM objects and their OS disks were deleted after each attempt, so the state holds the
38 created resources and the next
make production-plan shows the remaining 28 (5 VMs, 5 VM
locks, 4 Traffic Manager profiles, 10 endpoints, 4 CNAMEs).
Azure suggested Standard_D2lds_v5 or Standard_D2ls_v5 for the edge size and
Standard_D2ads_v5 or Standard_D2s_v5 for the core size. Pay-as-you-go prices in
swedencentral on 2026-11-13 (USD per hour): Standard_B2als_v2 0.0389, Standard_B2as_v2
0.0778, Standard_D2als_v6 0.086, Standard_D2ls_v5 0.091, Standard_D2as_v5 0.092,
Standard_D2as_v6 0.097, Standard_D2s_v5 0.102. The sizes are the variables edge_vm_size
and core_vm_size.
Cloudflare publishes its own CAA records beside the two of this module (for its Universal SSL
issuers), so dig CAA veristar.app also lists comodoca.com, digicert.com, pki.goog and
ssl.com.
4. Make targets
State history (finding O-24)
Blob versioning on the state account keeps every earlier state as a version, still encrypted with the state passphrase, and soft delete keeps a deleted blob for 35 days. To recover from an overwritten or deleted state, with notofu command running:
Restore node (finding O-4)
restore_enabled = true adds <env>-restore-1 (tier restore, sized like a core node, with the
core identity, private address .41 in restore_zone) to the VMs and to the inventory group
restore, which playbooks/restore.yml targets
(../runbooks/point-in-time-restore.md,
../runbooks/disaster-recovery.md). Set it back to false
after the restore.
5. Open items
6. Procedure, step by step
Staging is shown; production is the same withproduction (and CONFIRM=production only for
production-destroy). Needs prerequisites.md done (including az login),
sops and tofu on PATH, and infra/secrets/staging/tofu.enc.env from
secrets.md section 5.
-
Write
infra/tofu/envs/staging/terraform.tfvarswith the non-secret inputs. Its shape istests/infra/sample.tfvars, with real values:subscription_id,state_resource_groupandstate_storage_accountmust each be on one plainkey = "value"line (the scripts read them withsed). Check the account names withaz storage account check-name -n <name>. Without this file every target stops withinfra/tofu/envs/staging/terraform.tfvars is missing (inputs I4, I5, I13). -
Create the state storage (once per environment):
Expected: the created resources, then
ok: state storage <account> in veristar-tfstate (zrs, versioned, entra id only). -
Plan:
Expected on a new environment:
Plan: 55 to add, 0 to change, 0 to destroy.(section 3.6). Nothing should be destroyed on a first run. -
Apply:
Expected:
Apply complete! Resources: 55 added, 0 changed, 0 destroyed., then the target writesinfra/ansible/inventories/staging/hosts.json. Without a plan file it stops withrun make staging-plan first. -
Check the inventory matches section 1:
-
Check SSH to one node (cloud-init made
opswith the keys of<input I15>): -
Check Traffic Manager sees the nodes once Ansible has converged them (before that, port
80 does not answer and every endpoint shows
Degraded):Expected after the converge: every endpointOnline. -
Rewrite the inventory later without changing anything:
make staging-inventory.