Skip to content

11 July 2026

The Same Pattern, Two Platforms: From Proxmox to Azure AVM

The Terraform-to-Ansible contract I designed on my homelab is the same contract I deploy at work with Azure Verified Modules. The pattern transfers; only the providers change.

Architectural cross-section of the same three-layer machine built on two substrates — bare rock and machined bay — with one figure comparing the identical interface couplings

Roles All the Way Down showed how Terraform tier and role fields feed an Ansible inventory, which feeds k3s cluster formation. That post ended at the homelab: bare-metal Proxmox, three nodes, a cluster that assembles itself from a single playbook run.

This post picks up the same architectural pattern and applies it to Azure using Azure Verified Modules (AVM). The hook is simple: the interface between Terraform and Ansible I designed on my homelab is the same interface I deploy at work. The pattern transfers. Only the providers change.


The pattern, recapped

The homelab pattern has three moving parts:

  1. Terraform declares infrastructure with structured fields. Not just names — tier and role fields that carry intent.
  2. Ansible consumes a derived inventory. The inventory is generated from Terraform outputs at apply time. It doesn't exist independently.
  3. The contract between layers is explicit and machine-readable. One source of truth for what machines exist and what they are. Ansible reads from it; it doesn't maintain a parallel copy.

That's the whole pattern. It's substrate-independent. It works on Proxmox because the contract is the thing that matters, not the hypervisor underneath.


Why AVM changes things

Most Azure infrastructure-as-code content still talks about CARML (the now-deprecated module library) or raw ARM/Bicep templates written from scratch. AVM replaced both. It splits into two tiers:

  • Resource Modules (avm-res-*): one module per Azure resource type — virtual machines, virtual networks, storage accounts. Each ships with Well-Architected Framework defaults baked in: availability zones, Entra ID authentication, RBAC, private endpoints where applicable.
  • Pattern Modules (avm-ptn-*): opinionated compositions that solve a complete problem — landing zones, hub-and-spoke networking, policy-driven governance.

The shift is this: you don't write the module. You compose it. Microsoft maintains the module, keeps it aligned with WAF guidance, and you consume it. This is the Azure equivalent of writing a Terraform module for Proxmox VMs — except someone else maintains the module and it already encodes hundreds of hours of hard-won Azure opinion.

On the homelab, I wrote my own VM module because there was no one to write it for me. On Azure, AVM gives me that module for free, and it's better than what I'd write alone.


The bridge: Terraform AVM to Ansible

This is the interesting technical bit.

The AVM resource module for virtual machines (avm-res-compute-virtualmachine) provisions a VM with WAF defaults. What it does not do is configure the OS. It makes the machine exist. Making the machine what it's supposed to be is still your problem.

On the homelab, Ansible handles all of it because Proxmox doesn't have cloud-init in the way Azure does. On Azure, you split the work between cloud-init and Ansible — but the Terraform-to-Ansible bridge is identical.

The pattern: declare VMs with structured fields in Terraform, render those fields into an Ansible inventory at apply time.

module "vm" {
  source  = "Azure/avm-res-compute-virtualmachine/azurerm"
  version = "~> 0.18"

  name                = "vm-app-01"
  resource_group_name = module.rg.name
  location            = module.rg.location
  zone                = 1

  os_type             = "linux"
  sku_name            = "Standard_D2s_v5"
  image_reference    = local.ubuntu_image

  admin_username = local.admin_user
  ssh_public_key = local.ssh_pubkey

  tags = {
    tier = "app"
    role = "webserver"
  }
}

The tier and role tags are the same fields from the homelab. They're not Azure tags for billing or policy — they're the vocabulary the next layer consumes. The VM gets provisioned with WAF defaults (zone-pinned, Entra ID auth, RBAC). Then Terraform renders the inventory:

resource "local_file" "ansible_inventory" {
  filename = "${path.module}/../ansible/inventory.yml"
  content = templatefile("inventory.yml.tftpl", {
    hosts = [
      for vm in module.vm : {
        name        = vm.name
        ip_address  = vm.private_ip_address
        tier        = vm.tags.tier
        role        = vm.tags.role
      }
    ]
  })
}

Same template, same derived inventory, same single source of truth. The provider changed from bpg/proxmox to hashicorp/azurerm and the module changed from my hand-written VM module to AVM. The contract between layers didn't change at all.

Azure offers a dynamic inventory plugin (azure_rm) that queries the Azure API at playbook runtime instead of reading a static file. It's the right tool when your fleet is large and churns frequently. But for a platform where you control the Terraform and the Ansible in the same pipeline, derived inventory from Terraform state gives you one source of truth and a reproducible build. The homelab taught me that. It transfers.


cloud-init vs Ansible: when each wins

This is where Azure genuinely differs from the homelab, and the split is worth being explicit about.

cloud-init handles first-boot basics:

  • Package installation
  • User creation and SSH key injection
  • Hostname and network config
  • Any one-shot bootstrap that doesn't need to run again

Ansible handles everything after that:

  • Domain join (where applicable)
  • Monitoring agent configuration
  • Application stack install and config
  • Anything that might need to re-run or drift-correct

On the homelab, Ansible does both because Proxmox doesn't have cloud-init in the same way. On Azure, cloud-init takes the first-boot work, and Ansible picks up once the machine is up and addressable. The split keeps cloud-init small (first-boot should be fast and idempotent) and keeps Ansible authoritative for ongoing configuration state.

The mistake is treating them as competitors. They're not. cloud-init is the chassis; Ansible is the role. Same conceptual split as Terraform (chassis) and Ansible (role) on the homelab — just with cloud-init inserted as a first-boot shim.


The landing zone as platform

The AVM pattern module for landing zones (avm-ptn-alz) deploys the governance layer: management group hierarchy, policy definitions and assignments, RBAC role assignments, and the connectivity foundation. This is the Azure Landing Zone (ALZ) as code.

This is the governance layer. On the homelab, my governance layer is VLANs and deny-by-default inter-VLAN routing — the network defines what can talk to what, and everything else is least-privilege on top. On Azure, Azure Policy definitions are the equivalent: they define what can't exist, not just what can.

Policy assignments are the Azure equivalent of firewall rules. A policy that denies public IP on storage accounts is the same shape as a firewall rule that denies a VLAN from reaching the internet. Both define the boundary of the allowed. Both are enforced before the workload runs, not after.

The landing zone is the platform the product teams build on. On the homelab, that platform is the VLAN and routing fabric plus the base Ansible role every machine gets. On Azure, it's the management group hierarchy, policy assignments, and RBAC that every subscription inherits. Same concept, different substrate.


What I'd do differently

Some things translate cleanly. Some don't.

Transfers cleanly:

  • The Terraform-to-Ansible inventory bridge. Identical pattern, different provider. This was the biggest surprise — I expected to redesign the contract for Azure and didn't.
  • Role and tier as structured tags feeding downstream consumers. AVM modules support tags natively, so the vocabulary travels.
  • The chassis/role split. Terraform makes the machine exist; Ansible makes it what it's supposed to be. cloud-init slots in as a first-boot shim. The three-layer model holds.

Doesn't transfer:

  • Proxmox's vm_id as a stable identifier. Azure resources have lifecycle-independent resource IDs, so the integer IDs I used on the homelab are unnecessary. Let the provider manage identity.
  • Linked clones from a template. Azure uses marketplace images or custom images in a compute gallery. The template-clone pattern doesn't exist; image references replace it.
  • Manual IP assignment in Terraform locals. Azure allocates private IPs dynamically by default and lets you override. On the homelab I had to manage the whole IP range in code. On Azure, let the platform allocate and export what it allocated into the inventory.

Where AVM's opinions saved me from mistakes I'd already made:

  • Availability zones. On the homelab I have one node, so HA isn't an option. AVM VM modules default to zone-pinned deployments, which I would have had to design from scratch with raw Terraform.
  • Entra ID-based RBAC instead of SSH key management. AVM VM modules default to Entra ID SSH login, which is more defensible than the static-key approach on my homelab.
  • Private endpoints for PaaS. AVM resource modules default to private endpoints where the resource supports them. I had to build that discipline manually on the homelab; AVM hands it to you.

The pattern I designed in the basement is the pattern I deploy at work. That's not a coincidence. Platform engineering isn't about the substrate — it's about the contracts between layers. Get those right on bare metal and they hold up in the cloud.


The takeaway

The homelab and the Azure platform run on different providers, different modules, and different substrates. The thing that's the same is the contract: Terraform declares intent with structured fields, Ansible consumes a derived inventory, and the layers compose because their interface is explicit.

AVM didn't change that. It made the Terraform layer cheaper to maintain by handing me well-architected modules I'd otherwise write myself. The bridge to Ansible is the same bridge.

If you're moving from self-hosted IaC to cloud IaC and wondering whether your patterns survive the move: the ones worth keeping will. The ones that don't were probably substrate-specific all along, and AVM will tell you which is which by handing you a better default than the one you wrote.


Where have you seen a homelab pattern survive the move to a cloud platform? Let me know in the comments.

Found this useful? Share on LinkedIn

Comments load automatically via GitHub (Utterances), which may set cookies. See the privacy policy.

Get new notes in your inbox

I publish a few times a month on platform engineering, Azure infrastructure, and building platforms that don't rely on heroics. No spam, unsubscribe anytime.

Your email is sent to my self-hosted endpoint and stored via Resend. See the privacy policy.