At some point every homelabber hits the same wall. You’ve got Proxmox running a dozen VMs, a couple of cloud VPS instances for services that need a public IP, and a Cloudflare account managing DNS. You know what every resource does because you built it — but you clicked through UIs to create all of it, and the “documentation” lives entirely in your head. Disaster-recovery planning amounts to hoping you remember the steps if something goes wrong.
Terraform is the answer to this. It lets you describe your entire infrastructure — Proxmox VMs, Hetzner servers, Cloudflare DNS records, DigitalOcean droplets, all of it — as code, in a single consistent workflow. When you want a new VM, you write a resource block and run terraform apply. When you need to rebuild after a disk failure, you run terraform apply again. When something drifts from your declared state, terraform plan shows you exactly what changed and how to fix it.
This guide covers everything you need to go from clicking through UIs to managing a real homelab with Terraform: the fundamentals, Proxmox VMs and LXC containers, cloud overflow providers, DNS as code, remote state, reusable modules, and practical day-to-day workflows.
Infrastructure as Code Philosophy at Home
Infrastructure as Code (IaC) means your infrastructure exists as text files in a git repository, not as a set of steps you remember or a wiki page that’s three versions out of date. Every change is a commit. Every state of your homelab can be reproduced from source. Code review, rollback, and auditing are free side effects.
This matters even for a solo homelab. The discipline of writing down what you’re doing — in a format a machine can execute — forces clarity. You stop making one-off changes and start thinking about your infrastructure as a system.
Reproducibility: Rebuild from Scratch
The most immediately practical benefit: if your Proxmox host dies, you can provision a replacement and run terraform apply to recreate every VM, every container, every network config. You won’t be sitting there trying to remember whether that VM used 4GB or 8GB of RAM, or what the static IP was, or which SSH key you used.
This is the difference between a recoverable failure and a catastrophe.
Drift Detection
Terraform tracks the state of your infrastructure in a state file. When you run terraform plan, it compares that recorded state to what actually exists. If you manually changed a VM’s memory in the Proxmox UI — drift — Terraform will show you: “this resource differs from your configuration.” You can either update your code to match reality, or let Terraform revert the change on the next apply. Either way, you know about it.
Skills That Transfer to Work
Everything you learn managing a Proxmox homelab with Terraform transfers directly to managing AWS, Azure, or GCP at work. The workflow is identical: write HCL, run plan, apply. The providers and resource types differ, but the mental model and the tooling are the same. Many engineers get their first Terraform experience managing hobby infrastructure.
These tools are often conflated. They solve different problems.
Terraform is a provisioner. It creates and destroys infrastructure: VMs, containers, DNS records, firewalls, load balancers. It answers the question: “Does this resource exist with these properties?”
Ansible is a configuration manager. It installs software, writes config files, manages services on machines that already exist. It answers the question: “Is this machine in this desired state?”
The typical workflow is: Terraform provisions the VM, Ansible configures the software on it. You can use Terraform’s remote-exec or local-exec provisioners for simple post-creation tasks, but for anything beyond a few commands, reach for Ansible.
If you’ve never used Terraform, here’s the minimum you need to know. If you’re already comfortable with the basics, skip ahead to the Proxmox section.
Core Concepts
Providers are plugins that translate Terraform’s configuration language into API calls. There’s a provider for Proxmox, one for Hetzner, one for Cloudflare, one for AWS, and thousands more. Providers are published to the Terraform Registry.
Resources are the things you’re managing: a VM, a DNS record, a firewall rule, a storage bucket. Each resource has a type (proxmox_virtual_environment_vm) and a local name you assign.
Data sources let you read information about existing infrastructure without managing it. Useful for looking up an existing network, an SSH key you uploaded manually, or a cloud image ID.
Variables are inputs to your configuration. They let you parameterize resources so you can reuse the same code for different environments or machines.
Outputs are values Terraform exports after an apply — IP addresses, resource IDs, hostnames — useful for chaining configurations together or just for displaying results.
State is a JSON file (by default terraform.tfstate) that records what Terraform has created. Every resource Terraform manages is tracked here. Never edit this file by hand.
The Core Workflow
1
2
3
4
5
6
7
8
9
10
11
|
# Download providers and initialize the working directory
terraform init
# Show what will change — always do this before apply
terraform plan
# Apply the changes
terraform apply
# Tear everything down
terraform destroy
|
terraform plan is the most important command. It diffs your configuration against your state and against real infrastructure, and shows you exactly what will be created, modified, or destroyed — before touching anything. Make it a habit to always run plan and read the output before applying.
HCL Basics
Terraform uses HCL (HashiCorp Configuration Language). The syntax is readable and relatively forgiving.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
# A resource block: type = "proxmox_virtual_environment_vm", name = "my_vm"
resource "proxmox_virtual_environment_vm" "my_vm" {
name = "my-vm"
node_name = "pve"
# Nested blocks
cpu {
cores = 2
type = "x86-64-v2-AES"
}
# References to other resources
network_device {
bridge = proxmox_virtual_environment_network.vmbr1.name
}
# Expressions and functions
description = "Created on ${formatdate("YYYY-MM-DD", timestamp())}"
}
# A variable definition
variable "vm_memory" {
type = number
description = "RAM in megabytes"
default = 2048
}
# Using a variable
resource "proxmox_virtual_environment_vm" "another_vm" {
memory {
dedicated = var.vm_memory
}
}
# An output
output "vm_ip" {
value = proxmox_virtual_environment_vm.my_vm.ipv4_addresses
}
|
Variables and tfvars
Variables can be set in multiple ways, with this precedence (highest to lowest):
-var flags on the command line
terraform.tfvars or *.auto.tfvars files
TF_VAR_* environment variables
- Default values in variable definitions
terraform.tfvars is where you put your actual values — including secrets like API tokens. Add this file to .gitignore. Use environment variables or a secrets manager for anything you’re committing to git.
1
2
3
4
|
# terraform.tfvars — NOT committed to git
proxmox_api_token_secret = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
hetzner_api_token = "abc123..."
cloudflare_api_token = "xyz789..."
|
The Lock File
.terraform.lock.hcl records the exact provider versions and their checksums. Commit this file. It ensures everyone using the repo — including your CI pipeline — uses the same provider version, preventing surprise behavior from provider updates.
Project Structure for a Homelab
Small Homelab: Flat Structure
For a homelab managing a single environment, a flat structure is easiest:
homelab-infra/
├── .gitignore
├── .terraform.lock.hcl # committed
├── main.tf # resources
├── providers.tf # provider configurations
├── variables.tf # variable declarations
├── outputs.tf # outputs
└── terraform.tfvars # values (gitignored)
Larger Homelab: Directory-Per-Environment
When you have distinct environments (homelab, staging at Hetzner, production), separate them into directories. Each directory is an independent Terraform root with its own state.
homelab-infra/
├── proxmox/ # local Proxmox resources
│ ├── main.tf
│ ├── providers.tf
│ ├── variables.tf
│ └── terraform.tfvars
├── hetzner/ # cloud overflow
│ ├── main.tf
│ ├── providers.tf
│ └── terraform.tfvars
├── dns/ # Cloudflare DNS (standalone)
│ ├── main.tf
│ └── terraform.tfvars
└── modules/ # reusable modules
└── proxmox-vm/
├── main.tf
├── variables.tf
└── outputs.tf
Keeping Secrets Out
# .gitignore
*.tfvars
*.tfvars.json
.terraform/
terraform.tfstate
terraform.tfstate.backup
For secrets in CI/CD, use environment variables:
1
2
3
|
export TF_VAR_proxmox_api_token_secret="your-secret-here"
export TF_VAR_hetzner_api_token="your-token-here"
terraform apply
|
For production setups, the Terraform Vault provider lets you pull secrets from HashiCorp Vault at apply time — no secrets in files at all.
Proxmox Provider Deep Dive
Proxmox is the most common homelab hypervisor and has the most to gain from Terraform automation. There are two community Proxmox providers: the older Telmate/proxmox provider and the actively maintained bpg/proxmox provider. Use bpg/proxmox. It’s more complete, better documented, and under active development.
Setting Up Proxmox API Access
Before configuring the provider, create a dedicated API token in Proxmox. Don’t use root.
1
2
3
4
5
6
|
# On your Proxmox host, create a user and role
pveum useradd terraform@pve --comment "Terraform service account"
pveum roleadd TerraformRole \
--privs "Datastore.AllocateSpace,Datastore.AllocateTemplate,Datastore.Audit,Pool.Allocate,Sys.Audit,Sys.Console,Sys.Modify,VM.Allocate,VM.Audit,VM.Clone,VM.Config.CDROM,VM.Config.CPU,VM.Config.Cloudinit,VM.Config.Disk,VM.Config.HWType,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Migrate,VM.Monitor,VM.PowerMgmt,SDN.Use"
pveum aclmod / --roles TerraformRole --users terraform@pve
pveum user token add terraform@pve terraform --privsep 0
|
Note the token secret that’s printed — you only see it once.
Provider Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# providers.tf
terraform {
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.66"
}
}
required_version = ">= 1.5.0"
}
provider "proxmox" {
endpoint = "https://192.168.1.10:8006/"
api_token = "${var.proxmox_api_token_id}=${var.proxmox_api_token_secret}"
insecure = true # set to false if you have a valid TLS cert on Proxmox
ssh {
agent = true
username = "root"
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
|
# variables.tf
variable "proxmox_api_token_id" {
type = string
description = "Proxmox API token ID (e.g. terraform@pve!terraform)"
default = "terraform@pve!terraform"
}
variable "proxmox_api_token_secret" {
type = string
description = "Proxmox API token secret"
sensitive = true
}
|
Cloud-Init Template Workflow
The most powerful Proxmox + Terraform workflow uses cloud-init templates. You create a VM template once from a cloud-init image (Ubuntu, Debian, Rocky Linux, etc.), then Terraform clones it and injects per-VM configuration.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# On your Proxmox host: create a cloud-init template (do this once)
wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
qm create 9000 \
--name ubuntu-2204-cloudinit \
--memory 2048 \
--net0 virtio,bridge=vmbr0 \
--cores 2
qm importdisk 9000 jammy-server-cloudimg-amd64.img local-lvm
qm set 9000 \
--scsihw virtio-scsi-pci \
--scsi0 local-lvm:vm-9000-disk-0 \
--ide2 local-lvm:cloudinit \
--boot c \
--bootdisk scsi0 \
--serial0 socket \
--vga serial0 \
--agent enabled=1
qm template 9000
|
Creating VMs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
# main.tf — a single VM cloned from the cloud-init template
resource "proxmox_virtual_environment_vm" "web_server" {
name = "web-server"
node_name = "pve"
vm_id = 101
clone {
vm_id = 9000 # the template VM ID
full = true
retries = 3
}
cpu {
cores = 2
sockets = 1
type = "x86-64-v2-AES"
}
memory {
dedicated = 2048
}
disk {
datastore_id = "local-lvm"
interface = "scsi0"
size = 20 # GB — resize from the template's default
file_format = "raw"
discard = "on"
iothread = true
}
network_device {
bridge = "vmbr0"
model = "virtio"
firewall = false
}
# Cloud-init configuration
initialization {
ip_config {
ipv4 {
address = "192.168.1.101/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1", "8.8.8.8"]
}
user_account {
username = "ubuntu"
keys = [file("~/.ssh/id_ed25519.pub")]
}
}
# Start VM after creation
started = true
# Prevent accidental destruction
lifecycle {
prevent_destroy = true
}
}
output "web_server_ip" {
value = "192.168.1.101"
}
|
Complete Example: K3s Cluster (3 VMs)
Here’s a realistic, complete example: three VMs for a K3s cluster — one server, two agents.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
# variables.tf
variable "k3s_nodes" {
description = "K3s cluster node definitions"
type = map(object({
vm_id = number
ip = string
cores = number
memory = number
disk_gb = number
role = string
}))
default = {
server = {
vm_id = 200
ip = "192.168.1.200"
cores = 4
memory = 8192
disk_gb = 50
role = "server"
}
agent1 = {
vm_id = 201
ip = "192.168.1.201"
cores = 4
memory = 8192
disk_gb = 100
role = "agent"
}
agent2 = {
vm_id = 202
ip = "192.168.1.202"
cores = 4
memory = 8192
disk_gb = 100
role = "agent"
}
}
}
variable "ssh_public_key" {
type = string
default = "~/.ssh/id_ed25519.pub"
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
# main.tf — K3s cluster VMs
resource "proxmox_virtual_environment_vm" "k3s_nodes" {
for_each = var.k3s_nodes
name = "k3s-${each.key}"
node_name = "pve"
vm_id = each.value.vm_id
tags = ["k3s", each.value.role, "terraform"]
clone {
vm_id = 9000
full = true
retries = 3
}
cpu {
cores = each.value.cores
type = "x86-64-v2-AES"
}
memory {
dedicated = each.value.memory
}
disk {
datastore_id = "local-lvm"
interface = "scsi0"
size = each.value.disk_gb
file_format = "raw"
discard = "on"
iothread = true
}
network_device {
bridge = "vmbr0"
model = "virtio"
}
initialization {
ip_config {
ipv4 {
address = "${each.value.ip}/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["192.168.1.1"]
}
user_account {
username = "ubuntu"
keys = [file(var.ssh_public_key)]
}
}
started = true
}
output "k3s_node_ips" {
value = {
for name, node in var.k3s_nodes : name => node.ip
}
}
|
Run this with terraform apply and in a few minutes you have three VMs ready to join a K3s cluster. Add Ansible to bootstrap K3s on them and you have a fully reproducible cluster stack.
LXC Containers
For lightweight services (DNS resolvers, monitoring exporters, small databases), LXC containers are much more efficient than full VMs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
# Download an LXC template first (or use an existing one)
resource "proxmox_virtual_environment_download_file" "debian_lxc_template" {
content_type = "vztmpl"
datastore_id = "local"
node_name = "pve"
url = "http://download.proxmox.com/images/system/debian-12-standard_12.7-1_amd64.tar.zst"
}
resource "proxmox_virtual_environment_container" "pihole" {
description = "Pi-hole DNS sinkhole managed by Terraform"
node_name = "pve"
vm_id = 300
tags = ["dns", "terraform"]
initialization {
hostname = "pihole"
ip_config {
ipv4 {
address = "192.168.1.53/24"
gateway = "192.168.1.1"
}
}
user_account {
keys = [file("~/.ssh/id_ed25519.pub")]
password = var.lxc_root_password
}
}
cpu {
cores = 1
}
memory {
dedicated = 512
swap = 512
}
disk {
datastore_id = "local-lvm"
size = 8
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
operating_system {
template_file_id = proxmox_virtual_environment_download_file.debian_lxc_template.id
type = "debian"
}
started = true
unprivileged = true
}
|
Cloud Providers for Homelab Overflow
Not everything belongs on local hardware. Public IPs, geo-redundant endpoints, services that need to survive a home power outage — these are cloud use cases. Terraform handles cloud providers with the exact same workflow.
Hetzner Cloud
Hetzner is the go-to cloud for cost-conscious homelabbers. A CX22 (2 vCPU, 4GB RAM) runs around €4/month. Their API is clean and the Terraform provider is excellent.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# providers.tf addition
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.49"
}
}
}
provider "hcloud" {
token = var.hetzner_api_token
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
|
# Upload your SSH key to Hetzner
resource "hcloud_ssh_key" "homelab" {
name = "homelab-key"
public_key = file("~/.ssh/id_ed25519.pub")
}
# Create a private network for your cloud resources
resource "hcloud_network" "homelab_net" {
name = "homelab-net"
ip_range = "10.10.0.0/16"
}
resource "hcloud_network_subnet" "homelab_subnet" {
network_id = hcloud_network.homelab_net.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.10.1.0/24"
}
# Firewall: only allow what you need
resource "hcloud_firewall" "web_firewall" {
name = "web-firewall"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [
"0.0.0.0/0",
"::/0",
]
}
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "icmp"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
# The server itself
resource "hcloud_server" "vps" {
name = "homelab-vps"
image = "ubuntu-24.04"
server_type = "cx22"
location = "nbg1"
ssh_keys = [hcloud_ssh_key.homelab.id]
firewall_ids = [hcloud_firewall.web_firewall.id]
network {
network_id = hcloud_network.homelab_net.id
ip = "10.10.1.10"
}
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y curl git
# Bootstrap your server here
EOF
labels = {
environment = "homelab"
managed_by = "terraform"
}
}
output "vps_public_ip" {
value = hcloud_server.vps.ipv4_address
}
|
DigitalOcean
DigitalOcean is another popular option with a mature provider. The pattern is nearly identical:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
provider "digitalocean" {
token = var.do_token
}
resource "digitalocean_ssh_key" "homelab" {
name = "homelab"
public_key = file("~/.ssh/id_ed25519.pub")
}
resource "digitalocean_droplet" "vps" {
name = "homelab-vps"
size = "s-1vcpu-2gb"
image = "ubuntu-24-04-x64"
region = "nyc3"
ssh_keys = [digitalocean_ssh_key.homelab.fingerprint]
tags = ["terraform", "homelab"]
}
resource "digitalocean_firewall" "web" {
name = "web-firewall"
droplet_ids = [digitalocean_droplet.vps.id]
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
}
# DNS record pointing at the droplet
resource "digitalocean_record" "vps" {
domain = "example.com"
type = "A"
name = "vps"
value = digitalocean_droplet.vps.ipv4_address
ttl = 300
}
|
AWS for Specific Homelab Needs
You don’t need to run workloads in AWS to benefit from it. The free tier covers several genuinely useful homelab resources:
- Route 53: Managed DNS with programmable records — great for dynamic DNS or split-horizon setups
- S3: Object storage for Terraform remote state, backups (with lifecycle policies to Glacier for cost)
- ACM: Free TLS certificates for CloudFront distributions
- CloudFront: CDN fronting for your self-hosted sites
DNS is one of the highest-value, lowest-effort Terraform wins. DNS records change infrequently but changes are high-stakes: a misconfigured MX record breaks email, a wrong A record takes down a service. Managing DNS as code gives you git history, peer review, and reproducibility for free.
Cloudflare Provider
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# providers.tf addition
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.44"
}
}
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
|
# dns/main.tf — complete homelab domain DNS management
variable "zone_id" {
description = "Cloudflare Zone ID for your domain"
type = string
}
variable "domain" {
type = string
default = "example.com"
}
variable "home_ip" {
description = "Your home public IP address"
type = string
}
variable "vps_ip" {
description = "Hetzner VPS public IP"
type = string
}
# Root domain → home IP (with Cloudflare proxy for DDoS protection)
resource "cloudflare_record" "root" {
zone_id = var.zone_id
name = "@"
type = "A"
content = var.home_ip
ttl = 1 # 1 = automatic when proxied
proxied = true
}
# Wildcard for homelab services
resource "cloudflare_record" "wildcard_home" {
zone_id = var.zone_id
name = "*.home"
type = "A"
content = var.home_ip
ttl = 300
proxied = false # don't proxy internal services
}
# VPS subdomain
resource "cloudflare_record" "vps" {
zone_id = var.zone_id
name = "vps"
type = "A"
content = var.vps_ip
ttl = 300
proxied = true
}
# Gitea instance
resource "cloudflare_record" "git" {
zone_id = var.zone_id
name = "git"
type = "CNAME"
content = "vps.${var.domain}"
ttl = 1
proxied = true
}
# Mail records
resource "cloudflare_record" "mx" {
zone_id = var.zone_id
name = "@"
type = "MX"
content = "mail.${var.domain}"
priority = 10
ttl = 300
}
resource "cloudflare_record" "spf" {
zone_id = var.zone_id
name = "@"
type = "TXT"
content = "v=spf1 mx ~all"
ttl = 300
}
resource "cloudflare_record" "dmarc" {
zone_id = var.zone_id
name = "_dmarc"
type = "TXT"
content = "v=DMARC1; p=quarantine; rua=mailto:dmarc@${var.domain}"
ttl = 300
}
|
Every DNS change is now a git commit. You can see exactly when a record was added, who added it, and why (from the commit message). Rolling back a bad change is git revert plus terraform apply.
Remote State Management
Local state (terraform.tfstate in your project directory) works fine for getting started. It breaks down when you work across multiple machines, want to run Terraform in CI/CD, or simply want your state backed up somewhere other than your laptop.
Why Remote State
- Single source of truth: multiple machines read and write the same state
- State locking: prevents two concurrent applies from corrupting state
- Backup: state isn’t lost if your laptop fails
- Secrets separation: state often contains sensitive values — a remote backend with access controls is safer than a git repo
MinIO: Self-Hosted S3-Compatible Backend
MinIO is an open-source S3-compatible object store you can run at home. Terraform’s S3 backend works with MinIO without any changes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# docker-compose.yml for MinIO
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: "${MINIO_PASSWORD}"
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001"
restart: unless-stopped
volumes:
minio_data:
|
Create a bucket called terraform-state in the MinIO console, then configure your backend:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# backend.tf
terraform {
backend "s3" {
bucket = "terraform-state"
key = "proxmox/terraform.tfstate"
region = "us-east-1" # required but ignored by MinIO
endpoint = "http://192.168.1.50:9000"
access_key = "minioadmin"
secret_key = "your-minio-password"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
force_path_style = true
}
}
|
After adding the backend config, run terraform init again. Terraform will ask if you want to migrate existing local state to the backend — say yes.
If self-hosting MinIO sounds like too much overhead, HashiCorp’s Terraform Cloud has a free tier that includes remote state for up to 500 resources, state locking, and a nice UI for viewing plan output. For a solo homelab, the free tier is more than sufficient.
1
2
3
4
5
6
7
8
|
terraform {
cloud {
organization = "your-org"
workspaces {
name = "homelab-proxmox"
}
}
}
|
Modules: Avoiding Repetition
If you’re creating multiple VMs with similar configurations, modules let you define the pattern once and call it with different inputs. Think of a module as a function: it takes variables as inputs and returns outputs.
Writing a Proxmox VM Module
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
# modules/proxmox-vm/variables.tf
variable "name" {
type = string
description = "VM name"
}
variable "vm_id" {
type = number
description = "Proxmox VM ID (must be unique per node)"
}
variable "node_name" {
type = string
default = "pve"
}
variable "template_vm_id" {
type = number
default = 9000
}
variable "cores" {
type = number
default = 2
}
variable "memory_mb" {
type = number
default = 2048
}
variable "disk_gb" {
type = number
default = 20
}
variable "ip_address" {
type = string
description = "Static IP in CIDR notation, e.g. 192.168.1.100/24"
}
variable "gateway" {
type = string
default = "192.168.1.1"
}
variable "ssh_public_key" {
type = string
}
variable "tags" {
type = list(string)
default = []
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
# modules/proxmox-vm/main.tf
resource "proxmox_virtual_environment_vm" "this" {
name = var.name
node_name = var.node_name
vm_id = var.vm_id
tags = concat(var.tags, ["terraform"])
clone {
vm_id = var.template_vm_id
full = true
retries = 3
}
cpu {
cores = var.cores
type = "x86-64-v2-AES"
}
memory {
dedicated = var.memory_mb
}
disk {
datastore_id = "local-lvm"
interface = "scsi0"
size = var.disk_gb
file_format = "raw"
discard = "on"
iothread = true
}
network_device {
bridge = "vmbr0"
model = "virtio"
}
initialization {
ip_config {
ipv4 {
address = var.ip_address
gateway = var.gateway
}
}
dns {
servers = ["1.1.1.1", "8.8.8.8"]
}
user_account {
username = "ubuntu"
keys = [var.ssh_public_key]
}
}
started = true
}
|
1
2
3
4
5
6
7
8
|
# modules/proxmox-vm/outputs.tf
output "id" {
value = proxmox_virtual_environment_vm.this.id
}
output "name" {
value = proxmox_virtual_environment_vm.this.name
}
|
Using the Module
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
# main.tf
module "monitoring" {
source = "./modules/proxmox-vm"
name = "monitoring"
vm_id = 110
cores = 2
memory_mb = 4096
disk_gb = 50
ip_address = "192.168.1.110/24"
ssh_public_key = file("~/.ssh/id_ed25519.pub")
tags = ["monitoring", "grafana"]
}
module "gitea" {
source = "./modules/proxmox-vm"
name = "gitea"
vm_id = 111
cores = 2
memory_mb = 2048
disk_gb = 100
ip_address = "192.168.1.111/24"
ssh_public_key = file("~/.ssh/id_ed25519.pub")
tags = ["git", "gitea"]
}
module "vault" {
source = "./modules/proxmox-vm"
name = "vault"
vm_id = 112
cores = 2
memory_mb = 2048
disk_gb = 20
ip_address = "192.168.1.112/24"
ssh_public_key = file("~/.ssh/id_ed25519.pub")
tags = ["secrets", "vault"]
}
|
Three VMs, no code duplication. Adding a fourth is four lines.
Public Modules from the Registry
The Terraform Registry hosts thousands of community modules. You can use them the same way as local modules:
1
2
3
4
5
6
7
8
9
10
|
module "s3_backend_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "~> 4.0"
bucket = "my-homelab-terraform-state"
versioning = {
enabled = true
}
}
|
Practical Workflows
Safe Apply Pattern
Never run terraform apply without reviewing the plan first. Make it a habit to save the plan and apply only the saved plan:
1
2
3
4
5
6
7
8
|
# Save the plan to a binary file
terraform plan -out=tfplan
# Review the saved plan (optional but recommended for complex changes)
terraform show tfplan
# Apply exactly what was planned — no surprises
terraform apply tfplan
|
The plan file captures a snapshot of what will change. Applying it means you get exactly what you reviewed, even if infrastructure changed between plan and apply.
Targeting Specific Resources
When you only want to create or modify one resource without touching everything:
1
2
3
4
5
6
7
8
|
# Apply only one resource
terraform apply -target=proxmox_virtual_environment_vm.web_server
# Apply only a whole module
terraform apply -target=module.monitoring
# Destroy only one resource
terraform destroy -target=proxmox_virtual_environment_vm.old_vm
|
Use -target sparingly. It’s a workaround for dependency issues or partial rollouts — not a standard workflow. Overuse leads to state drift and confusion.
Importing Existing Resources
If you have infrastructure that was created before you started using Terraform, you can import it into state without recreating it.
1
2
3
4
5
|
# Import an existing Proxmox VM (VM ID 105) into a resource block
terraform import proxmox_virtual_environment_vm.existing_vm pve/105
# Import a Cloudflare DNS record
terraform import cloudflare_record.www <zone_id>/<record_id>
|
After importing, you need to write the matching resource block in your configuration. Terraform 1.5+ supports import blocks in HCL, which is cleaner:
1
2
3
4
|
import {
to = proxmox_virtual_environment_vm.existing_vm
id = "pve/105"
}
|
State Operations
1
2
3
4
5
6
7
8
9
10
11
12
|
# List all resources in state
terraform state list
# Show the full state of one resource
terraform state show proxmox_virtual_environment_vm.web_server
# Rename a resource in state (when you rename it in config)
terraform state mv proxmox_virtual_environment_vm.old_name proxmox_virtual_environment_vm.new_name
# Remove a resource from state without destroying it
# (useful when you want Terraform to stop managing something)
terraform state rm proxmox_virtual_environment_vm.web_server
|
Force-Replacing a Resource
The old terraform taint command (deprecated) marked a resource for recreation on the next apply. The modern equivalent:
1
|
terraform apply -replace=proxmox_virtual_environment_vm.web_server
|
This destroys and recreates the resource in a single apply. Useful when a VM gets corrupted and you want a fresh clone from the template.
Protecting Critical Resources
For resources you never want to accidentally destroy:
1
2
3
4
5
6
7
8
9
10
11
12
|
resource "proxmox_virtual_environment_vm" "nas" {
# ...
lifecycle {
prevent_destroy = true
# Also useful: don't recreate if these change — update in place instead
ignore_changes = [
initialization[0].user_account,
]
}
}
|
prevent_destroy = true makes terraform destroy (and any plan that would destroy this resource) fail with an error. You have to remove this block from the config before you can destroy the resource.
GitHub Actions: Plan on PR
The classic Terraform CI pattern: run plan on every pull request so you can review infra changes before merging, just like code changes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
branches: [main]
paths:
- 'proxmox/**'
- 'dns/**'
- 'hetzner/**'
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- name: Terraform Init
working-directory: proxmox
run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.MINIO_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.MINIO_SECRET_KEY }}
- name: Terraform Plan
working-directory: proxmox
run: terraform plan -no-color
env:
TF_VAR_proxmox_api_token_secret: ${{ secrets.PROXMOX_API_TOKEN_SECRET }}
|
Atlantis is a self-hosted server that watches your git repos and runs Terraform automatically. On a PR, it comments with the plan output. When you comment atlantis apply, it applies the plan and merges the PR.
This is full GitOps for your homelab infrastructure. It’s worth the setup if you want proper review workflows and an audit log of every infrastructure change.
Simple Makefile for Solo Use
For solo use without CI/CD overhead, a Makefile captures your common workflows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
# Makefile
.PHONY: init plan apply destroy fmt validate
DIR ?= proxmox
init:
cd $(DIR) && terraform init
plan:
cd $(DIR) && terraform plan -out=tfplan
apply:
cd $(DIR) && terraform apply tfplan
apply-auto:
cd $(DIR) && terraform apply -auto-approve
destroy:
cd $(DIR) && terraform destroy
fmt:
terraform fmt -recursive
validate:
cd $(DIR) && terraform validate
# Usage:
# make plan DIR=proxmox
# make apply DIR=dns
|
Tips and Gotchas
State Files Contain Secrets
The Terraform state file stores resource attributes in plaintext, including things like generated passwords, private keys injected via cloud-init, and database credentials. Treat your state file like a secrets file:
- Store remote state with encryption at rest (S3 bucket encryption, MinIO encryption)
- Restrict access to the state backend
- Never commit
terraform.tfstate to git — add it to .gitignore
- Use
sensitive = true in variable and output definitions to suppress values in logs
Version Pin Your Providers
Always pin provider versions in your required_providers block. Provider updates can introduce breaking changes.
1
2
3
4
5
6
7
8
9
10
|
# Good: constrained to 0.66.x
proxmox = {
source = "bpg/proxmox"
version = "~> 0.66"
}
# Bad: accepts any version including breaking major bumps
proxmox = {
source = "bpg/proxmox"
}
|
The ~> operator (pessimistic constraint) allows patch and minor version updates within the same major version. Use >= 0.66.0, < 0.68.0 for tighter control.
Explicit Dependencies with depends_on
Terraform infers most dependencies from resource references. Sometimes you need an explicit dependency that isn’t expressed through references:
1
2
3
4
5
6
7
|
resource "proxmox_virtual_environment_vm" "agent" {
# This VM should only start after the server is up,
# but there's no attribute reference between them
depends_on = [proxmox_virtual_environment_vm.server]
# ...
}
|
Use depends_on sparingly. If you find yourself using it a lot, it’s often a sign that your resource design could be improved.
Debug Logging
When things go wrong (wrong API responses, provider bugs, unexpected diffs), enable debug logging:
1
2
|
export TF_LOG=DEBUG
terraform apply 2>&1 | tee terraform-debug.log
|
TF_LOG levels: TRACE, DEBUG, INFO, WARN, ERROR. DEBUG is usually the right starting point. The output is verbose but invaluable for diagnosing provider issues.
terraform destroy destroys everything in your state. Add friction to prevent accidents:
1
2
3
4
5
6
7
8
9
10
|
# Always prompts for confirmation
terraform destroy
# For resources you can never accidentally destroy
# Add to their resource block:
lifecycle {
prevent_destroy = true
}
# For CI/CD: use workspaces or separate state to isolate blast radius
|
Consider using separate Terraform roots (directories) for stateful resources (databases, NAS VMs, critical containers) versus stateless ones (app VMs that can be recreated). This way a destroy in one root can’t nuke your NAS.
Make these two commands part of your workflow — or better, your pre-commit hooks:
1
2
3
4
5
|
# Reformat all .tf files to canonical HCL style
terraform fmt -recursive
# Validate syntax and basic logic (no API calls)
terraform validate
|
A .pre-commit-config.yaml that runs both ensures you never commit unformatted or syntactically broken Terraform.
Putting It All Together
Here’s the arc of a mature homelab Terraform setup:
-
Start flat: one directory, one state file, Proxmox provider. Get a few VMs under management.
-
Add remote state: move state to MinIO or Terraform Cloud. Start committing your .tf files to a private git repo.
-
Extract modules: when you notice you’re copy-pasting VM definitions, extract the pattern into a module.
-
Add cloud providers: Hetzner for overflow VMs, Cloudflare for DNS. Keep them in separate directories with separate state.
-
Add CI: a simple GitHub Actions workflow that runs terraform plan on every PR. You don’t need Atlantis until you’re working with others or want full GitOps.
-
Adopt the discipline: every infrastructure change goes through Terraform. No more clicking through UIs. When you want something new, you write it first, plan it, review the plan, then apply.
The payoff isn’t just reproducibility (though being able to rebuild your homelab from scratch in 20 minutes is genuinely satisfying). It’s the clarity that comes from having a single source of truth for what exists in your infrastructure — readable, version-controlled, and executable by a machine.
Your homelab stops being a collection of things you remember and becomes a codebase you maintain.
Comments