DEV Community

Cover image for Phase 0, Part 1: Turning Copy-Pasted Terraform Into a Reusable Proxmox Module
Jonathan Bouligny
Jonathan Bouligny

Posted on Originally published at bouligny.dev AI-assisted

Phase 0, Part 1: Turning Copy-Pasted Terraform Into a Reusable Proxmox Module

My original infrastructure is three bare-metal Proxmox machines clustered together. The clustering just makes administration a bit easier. Those run the VMs that hold my k3s nodes and other lab systems like an Ansible Automation Platform. All of it was hand-configured.

My goal is to have the foundation infrastructure come up from code too, the same way the cluster does, so I can add machines or spin everything down and back up without clicking through a UI. It's also a good exercise on its own. This piece is the DNS setup using Technitium.


Why an LXC Container for DNS

The plan for DNS is to spin up an LXC container, provision it, and back it up. I chose an LXC container because it's lighter than a full VM while keeping the features I care about.

The reason it's not just the containerized (Docker) version of Technitium: in that version the logs show every request coming from the bridge, so you can't tell who actually asked. A real OS install fixes that, but I don't want to hand a whole VM's worth of resources to one small service.

I also don't want all my services on one VM. If that VM goes down, that's one big blast surface, so I'd rather have a bunch of small machines. LXC containers act like smaller VMs for services you treat like pets instead of cattle. I can run them on Proxmox, on bare metal with systemd-nspawn, or on another hypervisor through Incus. I still get backups, and if I ever need to pull a service out, the migration path is easier because the backup is just the filesystem, so I can mount it somewhere else later.


Centralizing the Common Values

I worked in a strange order, doing things as they came to me. One was centralizing the important values, so I made a common-outputs folder that just exports the things every foundation system needs.

The thing that pushed me to it was upstream DNS versus the DNS server. Most machines need to use the dns_server, and then the DNS server itself needs to use the upstream_dns_server. Keeping those in one place beat redefining them in every repo.

output "proxmox_endpoint"    { value = "https://10.0.0.10:8006/" }
output "gateway"             { value = "10.0.0.1" }
output "dns_server"          { value = "10.0.0.31" }   # Technitium, guests resolve here
output "upstream_dns_server" { value = "10.0.0.1" }    # router, what the resolver forwards to
output "provider_ssh_user"   { value = "terraform" }
output "machine_ssh_user"    { value = "jon" }
output "ssh_keys"            { value = [trimspace(file("~/.ssh/id_ed25519.pub"))] }
Enter fullscreen mode Exit fullscreen mode

The Code I Kept Copying

I wanted to stop copying the same block of Terraform around. This resource has spun up VMs across at least three of my repos, probably more, and every copy was one more place to fix when something changed. Here's the block that was living in every repo, reaching into a common module for the shared values:

resource "proxmox_virtual_environment_vm" "vms" {
  for_each  = local.vms
  name      = each.key
  node_name = each.value.node_name

  clone { vm_id = each.value.template_vm_id }
  agent { enabled = true }
  cpu    { cores = each.value.cores }
  memory { dedicated = each.value.memory }

  initialization {
    dns { servers = [module.common.dns_server] }
    ip_config {
      ipv4 {
        address = each.value.ip_address
        gateway = module.common.gateway
      }
    }
    user_account {
      username = "jon"
      keys     = [trimspace(file("~/.ssh/id_ed25519.pub"))]
    }
  }

  disk {
    interface = each.value.disk.interface
    size      = each.value.disk.size
  }
}
Enter fullscreen mode Exit fullscreen mode

Making It a Module

A module is just a few files: main.tf for the resource, variables.tf for the inputs, outputs.tf for what it returns, a versions.tf to pin the provider, and a README. The resource itself barely changes. Every hardcoded or module.common.* value becomes a var.*, so the block stops knowing anything about my specific setup. The real edits:

-  for_each  = local.vms
+  for_each  = var.input_vms

-    dns { servers = [module.common.dns_server] }
+    dns { servers = [var.dns_server] }
-        gateway = module.common.gateway
+        gateway = var.gateway

-      username = "jon"
-      keys     = [trimspace(file("~/.ssh/id_ed25519.pub"))]
+      username = var.ssh_user
+      keys     = var.ssh_keys
Enter fullscreen mode Exit fullscreen mode

Then the inputs those vars come from. ssh_user and gateway get defaults so a caller only overrides them when they differ. dns_server has no default on purpose, because the whole point was that some machines get the resolver and the resolver gets upstream, so the caller must say which. input_vms is the typed shape of the machine map:

variable "dns_server" {
  type        = string
  description = "the dns server to use, can be upstream or downstream"
}

variable "input_vms" {
  type = map(object({
    node_name      = string
    template_vm_id = number
    cores          = number
    memory         = number
    ip_address     = string
    disk = object({
      interface = string
      size      = number
    })
  }))
  description = "The list of vms to be created"
}
Enter fullscreen mode Exit fullscreen mode

One output change worth calling out: the old version grabbed ipv4_addresses[1][0], which assumes the second NIC entry is the real one and the first is loopback. That's fragile. I changed it to filter loopback out explicitly instead of trusting the index:

-  value = { for k, v in proxmox_virtual_environment_vm.vms : k => v.ipv4_addresses[1][0] }
+  value = {
+    for k, v in proxmox_virtual_environment_vm.vms :
+    k => [for addr in flatten(v.ipv4_addresses) : addr if addr != "127.0.0.1"][0]
+  }
Enter fullscreen mode Exit fullscreen mode

The LXC Version

Once the VM module existed, the container version was only a few changes off it. The resource type changes, a hostname shows up, there's no agent block, the VM has a name the container doesn't, and the disk loses its interface:

-resource "proxmox_virtual_environment_vm" "vms" {
+resource "proxmox_virtual_environment_container" "containers" {
-  for_each  = var.input_vms
-  name      = each.key
+  for_each  = var.input_containers
   node_name = each.value.node_name

-  agent { enabled = true }

   initialization {
+    hostname = each.key
     dns { servers = [var.dns_server] }

     user_account {
-      username = var.ssh_user
       keys     = var.ssh_keys
     }

   disk {
-    interface = each.value.disk.interface
     size      = each.value.disk.size
   }
Enter fullscreen mode Exit fullscreen mode

The input_containers variable is the same shape as input_vms minus the disk interface, and the output reads .ipv4 off the container instead of filtering the VM's address list:

-  value = {
-    for k, v in proxmox_virtual_environment_vm.vms :
-    k => [for addr in flatten(v.ipv4_addresses) : addr if addr != "127.0.0.1"][0]
-  }
+  value = { for k, v in proxmox_virtual_environment_container.containers : k => v.ipv4 }
Enter fullscreen mode Exit fullscreen mode

Using the Module in the Foundation Repo

With the module written, each foundation system just describes its machines and calls it. Here's the DNS one, 2-dns/terraform/technitium.tf. It pulls shared values from common, defines the one VM, and hands it to the module:

module "common" {
  source = "../../common-outputs/"
}

locals {
  vms = {
    technitium = {
      node_name      = "nibbler"
      template_vm_id = 9000
      cores          = 4
      memory         = 8192
      ip_address     = "10.0.0.31/24"
      disk = {
        interface = "scsi0"
        size      = 60
      }
    }
  }
}

module "proxmox_create_vms" {
  source = "../../terraform-common/proxmox_create_vms"

  input_vms  = local.vms
  ssh_user   = module.common.machine_ssh_user
  ssh_keys   = module.common.ssh_keys
  dns_server = module.common.upstream_dns_server
  gateway    = module.common.gateway
}

output "vm_ipv4_address" {
  value = module.proxmox_create_vms.vm_ipv4_address
}
Enter fullscreen mode Exit fullscreen mode

The provider block is tiny too, since it also reads from common:

provider "proxmox" {
  endpoint = module.common.proxmox_endpoint
  insecure = true

  ssh {
    agent    = true
    username = module.common.provider_ssh_user
  }
}
Enter fullscreen mode Exit fullscreen mode

The Forgejo system (3-git/terraform/forgejo.tf) is the same file with a different machine map, pointing at the same module. That's the whole payoff. Adding a new foundation service is now a locals block and a module call, not another copy of the resource.


Why Git Subtree Instead of a Submodule or a Registry

One decision I made about how the module actually lives in the foundation repo: the module code is its own thing, but I pulled it into the foundation repo with git subtree rather than referencing it as a submodule or a remote source.

The reason is backups. This foundation repo, state included, is going on a USB stick as a recovery copy. It also lives in Forgejo, but I want to be able to rebuild foundation infra from just the USB, with nothing else reachable. A submodule or a remote module source would mean the USB copy is incomplete without pulling from somewhere. A subtree copies the module's files directly into this repo, so the one repo on the stick has everything.

The state going on the stick is a nice-to-have, not critical. If I lost it I could terraform import my way back, but that's work I'd rather avoid, so it rides along.

Top comments (0)