My Entire Homelab Is Code

At the start of my homelab journey, every VM began with an ISO and manual configuration: install the OS, set networking, expand disks, and install the required software. It worked, but every machine repeated the same steps.

I quickly moved to a Proxmox template with a script that set the IP address, installed Docker when needed, and expanded the disk. That was a major improvement, but I still had to use and adapt the template and script for every VM.

Recently, I put that workflow behind Infrastructure as Code. Terraform declares the VMs, cloud-init gives them a consistent first boot, Ansible maintains both new and existing machines, and Docker Compose describes the services that run on them. An operator is still deliberately in the loop, but the workflow is far more repeatable and inspectable.

What “entire homelab” means here

My homelab is now largely described in code. That does not mean every part runs unattended or that I have removed judgement from the process. It means I can review what should exist, create it from a known starting point, and apply recurring changes without relying on a checklist or my memory.

The claim is intentionally practical rather than absolute. The parts I manage day to day are declarations: VM definitions, their bootstrap profiles, ongoing operating-system configuration, and the Docker Compose stacks that run services. My access layer follows the same pattern with small YAML declarations.

Physical hardware and the parts of the environment that are not yet represented in a repository do not become code merely because the VMs do. The point is to move the recurring work—the work that used to depend on a remembered sequence of clicks and commands—into reviewable configuration.

One workflow, three layers

The VM flow is: declare intent, provision and bootstrap, then operate and change.

Diagram showing declared VM and service configuration flowing through Terraform, Proxmox, cloud-init, an Ubuntu VM, and Ansible.
From declared VM configuration to repeatable operations.

A VM is described in a versioned inventory instead of being recreated from a checklist. Terraform turns that declaration into a Proxmox VM, cloud-init handles first boot, and Ansible applies repeatable maintenance and configuration changes once the machine is reachable.

A VM starts as a declaration

My Terraform repository starts with inventory/vms.json. Terraform reads the inventory and creates Proxmox resources with for_each. This is a shortened example of two VM declarations:

{
  "vms": {
    "example1": {
      "cpu_cores": 2,
      "disk_gb": 20,
      "docker": true,
      "ipv4": "192.168.0.141",
      "memory_mb": 4096,
      "node": "prox",
      "profile": "small",
      "storage": "local-lvm",
      "template_id": 902
    },
    "examplevm2": {
      "cpu_cores": 16,
      "disk_gb": 20,
      "docker": true,
      "ipv4": "192.168.0.140",
      "memory_mb": 12288,
      "node": "prox",
      "profile": "large",
      "storage": "local-lvm",
      "template_id": 902
    }
  }
}

Adding a machine is primarily a reviewable inventory change, not a sequence of clicks and remembered defaults. The declaration records its resources, placement, storage, networking, template, and bootstrap profile in one place.

Terraform and cloud-init

The workflow full-clones a fixed Ubuntu Server template and applies the declared resources, placement, storage, networking, and bootstrap profile. An interactive manager validates the configuration, checks Proxmox resources, writes the inventory atomically, and runs formatting, validation, and a saved plan before confirmation. The Proxmox API token is retrieved from Vault at runtime rather than committed beside the VM declaration.

Cloud-init gives new machines a consistent starting point. I keep vendor-data snippets for a base configuration and a Docker-oriented configuration, so that choice is declared instead of being a post-install checklist.

Ansible handles life after creation

Terraform is not only useful for the next VM. The older VMs in my homelab are automatically included in Ansible once they are reachable, so the operating layer is not limited to machines created after Terraform was introduced.

That mattered when I wanted the QEMU guest agent installed everywhere. New VMs now receive it through the provisioning workflow, while Ansible can apply the same change to the older machines that already existed. A baseline improvement does not have to leave the existing fleet behind.

My Ansible repository runs in Docker and handles updates, agents, and configuration changes over time. SSH certificates and host-key verification remain in place, and maintenance runs conservatively, host by host.

Docker Compose describes the services

Docker Compose was already the declarative part of my homelab before Terraform arrived. Each service stack lives separately from the infrastructure repository and describes the containers, networks, volumes, and settings required to run that service. Terraform answers “where does this VM exist?”, while Compose answers “what runs on it?”

Keeping those repositories separate makes the boundary useful rather than theoretical. I can change a service without treating its VM as an application deployment mechanism, and I can create or adjust a VM without embedding every service definition in Terraform.

The same idea applies to my access layer

In my earlier post, Route It, Protect It, Publish It: A Repeatable Access Layer, I wrote about reusable YAML declarations. The principle is the same: describe the intended result in a small, reviewable configuration file instead of relying on memory.

Infrastructure as code, not infrastructure without judgement

VM deletion is protected with prevent_destroy, making destructive changes deliberately non-routine. The manager requires explicit confirmation and runs in a privileged execution environment by design. Good automation is about choosing where repeatability is valuable, where confirmation is necessary, and where a human stays accountable.

This workflow also has a cost: repositories, templates, tool versions, and automation all need to be maintained. For a throwaway experiment, creating a VM manually may still be quicker. The value appears when a pattern is repeated, a baseline changes, or a machine must be understood later without reconstructing its setup from memory.

I can now declare a machine, provision it from a known template, bootstrap it with cloud-init, maintain it with Ansible, and run its services from Docker Compose. Most importantly, I no longer have to rely on remembering every setup step.

Trade-offs and challenges

Making recurring work declarative has an upfront cost.
The repositories, templates, provider versions, and automation that make the workflow repeatable also need to be maintained.
The first few VMs do not automatically justify that investment.

The challenge is not only learning Terraform or Ansible.
It is keeping the declarations useful as the homelab changes: deciding which settings belong in a VM definition, which belong in a cloud-init profile, which should be applied later with Ansible, and which should remain with an individual Docker Compose stack.

There is also a temptation to automate a one-off task because the tooling is available. For a throwaway experiment, creating a VM manually may still be quicker.
The value appears when a pattern is repeated, a baseline changes, or a machine must be understood later without reconstructing its setup from memory.

This keeps the section grounded in challenges you actually have, rather than inventing disaster-recovery or Terraform-state concerns you explicitly did not want to cover.