Automation intermediate ⏱ 60 minutes

OpenTofu: Open-Source Terraform for Homelabs

Infrastructure as Code without vendor lock-in. The community-backed Terraform alternative with state encryption.

By homelabbing.io • 2025-12-16
#opentofu#terraform#iac#infrastructure#open-source

Overview

In August 2023, HashiCorp changed Terraform’s license from the permissive Mozilla Public License (MPL) to the Business Source License (BSL), creating uncertainty for the open-source community and commercial users alike. Within days, the Linux Foundation announced OpenTofu - a true open-source fork of Terraform that preserves the original MPL license and community-driven development model.

For homelabbers, OpenTofu represents more than just a license choice. It’s a commitment to infrastructure-as-code principles without vendor lock-in, backed by industry giants including Spacelift, Gruntwork, Harness, env0, and Scalr. OpenTofu is 100% compatible with Terraform 1.5.x and earlier, making migration seamless while ensuring your infrastructure code remains truly open.

What you’ll learn:

  • Why OpenTofu matters for homelab infrastructure management
  • Installing and configuring OpenTofu on your preferred platform
  • HashiCorp Configuration Language (HCL) fundamentals
  • Managing Proxmox VMs with the Telmate provider
  • State management strategies for homelabs
  • Migrating existing Terraform projects to OpenTofu
  • Advanced patterns for multi-environment homelab deployments

Why OpenTofu for Homelabs?

Instead of manually creating VMs through the Proxmox web interface, clicking through endless configuration screens, OpenTofu lets you define your entire infrastructure in code. Spin up identical dev/staging/prod environments with a single command. Version control your infrastructure alongside your application code. Destroy and recreate your entire homelab in minutes when experimenting.


Prerequisites

Before starting, ensure you have:

  • Linux/macOS/Windows system with command-line access
  • Proxmox VE (or other infrastructure platform you want to manage)
  • Basic command-line proficiency (comfortable with terminal operations)
  • Understanding of virtualization concepts (VMs, networks, storage)
  • Git installed (for version control of infrastructure code)
  • Text editor with HCL syntax support (VS Code recommended)

Hardware requirements:

  • Any modern system (OpenTofu itself is lightweight)
  • Access to infrastructure you want to manage (Proxmox, Docker, cloud, etc.)

Optional but helpful:

  • Familiarity with Terraform (makes transition easier but not required)
  • Experience with declarative configuration systems
  • Basic programming/scripting knowledge

Understanding Infrastructure as Code

The Manual Problem

Picture this typical homelab workflow:

  1. Log into Proxmox web UI
  2. Click “Create VM”
  3. Fill out 15+ form fields (CPU, RAM, disk, network, etc.)
  4. Wait for VM creation
  5. Repeat steps 1-4 for each VM
  6. Document what you created in a spreadsheet or text file
  7. When you need to recreate something, reverse-engineer settings from screenshots

Now imagine you need to recreate your entire lab after hardware failure. Or replicate your production setup for testing. Or remember why you set that specific network configuration six months ago.

This is why infrastructure as code exists.

The OpenTofu Solution

With OpenTofu, that same workflow becomes:

resource "proxmox_vm_qemu" "web_server" {
  name        = "web-01"
  target_node = "pve-node-01"

  cores   = 4
  memory  = 8192
  scsihw  = "virtio-scsi-pci"

  disk {
    size    = "32G"
    storage = "local-lvm"
    type    = "scsi"
  }

  network {
    model  = "virtio"
    bridge = "vmbr0"
  }
}

Run tofu apply and your VM appears. Check this code into Git and your infrastructure is versioned, documented, and reproducible.

Key IaC Principles

Declarative Configuration You describe what you want, not how to create it:

  • “I want a VM with 4 cores and 8GB RAM”
  • OpenTofu figures out the steps to make it happen

Idempotent Operations Running the same configuration multiple times produces the same result:

  • First run: Creates the VM
  • Second run: Detects VM exists, does nothing
  • Third run: Still does nothing (unless you changed the config)

State Management OpenTofu tracks what it created in a state file:

  • Knows what resources exist
  • Detects drift (manual changes outside OpenTofu)
  • Enables updates and deletions

Provider Model Providers translate HCL into API calls for specific platforms:

  • Proxmox provider → Proxmox API
  • Docker provider → Docker API
  • AWS provider → AWS API

OpenTofu vs. Terraform: What Changed?

Compatibility:

  • OpenTofu 1.6.x is drop-in compatible with Terraform 1.5.x
  • Same HCL syntax
  • Same provider ecosystem
  • Same state file format (for now)

What’s Different:

  • License: MPL 2.0 (truly open-source) vs. BSL (source-available)
  • Governance: Linux Foundation vs. HashiCorp
  • Development: Community-driven vs. vendor-controlled
  • Future: Independent roadmap vs. tied to commercial products

Migration Path:

  • Replace terraform binary with tofu
  • Update provider registry URLs (automatic for most providers)
  • Continue using existing .tf files unchanged

Installation and Initial Setup

Installing OpenTofu

Method 1: Package Manager (Recommended)

Ubuntu/Debian:

# Add OpenTofu repository
curl -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
sudo bash install-opentofu.sh

# Verify installation
tofu version

macOS (Homebrew):

# Install via Homebrew
brew install opentofu

# Verify installation
tofu version

Arch Linux:

# Install from AUR
yay -S opentofu

# Verify installation
tofu version

Method 2: Manual Installation

Download the latest release from opentofu.org/downloads:

# Download (replace VERSION with latest)
wget https://github.com/opentofu/opentofu/releases/download/v1.6.0/tofu_1.6.0_linux_amd64.zip

# Extract
unzip tofu_1.6.0_linux_amd64.zip

# Move to PATH
sudo mv tofu /usr/local/bin/

# Verify
tofu version

Method 3: Docker Container

# Create alias for containerized OpenTofu
alias tofu='docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/opentofu/opentofu:latest'

# Verify
tofu version

Setting Up Your First Project

Create project structure:

# Create project directory
mkdir ~/homelab-infrastructure
cd ~/homelab-infrastructure

# Create subdirectories
mkdir -p {modules,environments/{dev,staging,prod},terraform.tfstate.d}

# Initialize Git repository
git init

Create .gitignore:

cat > .gitignore << 'EOF'
# Local .terraform directories
**/.terraform/*

# .tfstate files (contain sensitive data)
*.tfstate
*.tfstate.*

# Crash log files
crash.log

# Exclude all .tfvars files (may contain secrets)
*.tfvars

# Ignore override files
override.tf
override.tf.json

# Ignore CLI configuration files
.terraformrc
terraform.rc

# Ignore lock files (or commit them, depending on preference)
# .terraform.lock.hcl
EOF

Configuring VS Code for HCL

Install HashiCorp Terraform extension:

  1. Open VS Code
  2. Extensions → Search “HashiCorp Terraform”
  3. Install extension
  4. Restart VS Code

Configure extension:

Add to .vscode/settings.json:

{
  "terraform.languageServer": {
    "enabled": true,
    "args": []
  },
  "terraform.experimentalFeatures": {
    "validateOnSave": true
  },
  "[terraform]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "hashicorp.terraform"
  }
}

This provides syntax highlighting, auto-completion, and validation for OpenTofu/Terraform files.


HashiCorp Configuration Language (HCL) Basics

Understanding HCL Syntax

HCL is designed to be human-readable while remaining machine-parseable. It’s not quite JSON, not quite YAML - it’s purpose-built for infrastructure configuration.

Basic Structure:

# Comments start with #

// Or with double slashes

/*
Multi-line comments
use C-style syntax
*/

# Block structure
block_type "block_label" "optional_second_label" {
  # Arguments use key = value syntax
  argument_name = "value"

  # Nested blocks
  nested_block {
    nested_argument = 123
  }
}

Data Types:

# String
name = "web-server"

# Number
count = 3
memory = 8192

# Boolean
enable_backups = true

# List
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]

# Map
tags = {
  Environment = "production"
  Project     = "homelab"
  ManagedBy   = "opentofu"
}

# Null
optional_value = null

Essential HCL Blocks

Provider Block:

terraform {
  required_providers {
    proxmox = {
      source  = "telmate/proxmox"
      version = "~> 2.9"
    }
  }
}

provider "proxmox" {
  pm_api_url          = "https://proxmox.example.com:8006/api2/json"
  pm_api_token_id     = "root@pam!terraform"
  pm_api_token_secret = var.proxmox_token
  pm_tls_insecure     = true  # Only for self-signed certs
}

Resource Block:

resource "resource_type" "resource_name" {
  argument = "value"

  nested_block {
    nested_argument = "value"
  }
}

Variable Block:

variable "vm_count" {
  description = "Number of VMs to create"
  type        = number
  default     = 3

  validation {
    condition     = var.vm_count > 0 && var.vm_count <= 10
    error_message = "VM count must be between 1 and 10"
  }
}

Output Block:

output "vm_ip_addresses" {
  description = "IP addresses of created VMs"
  value       = proxmox_vm_qemu.web[*].default_ipv4_address
}

Data Source Block:

data "proxmox_virtual_environment_nodes" "available_nodes" {
}

# Use data source output
locals {
  first_node = data.proxmox_virtual_environment_nodes.available_nodes.names[0]
}

Variables and Expressions

Referencing Variables:

# Define variable
variable "vm_memory" {
  default = 4096
}

# Use variable
resource "proxmox_vm_qemu" "example" {
  memory = var.vm_memory  # References the variable
}

String Interpolation:

variable "environment" {
  default = "production"
}

resource "proxmox_vm_qemu" "web" {
  name = "web-${var.environment}-01"  # Produces "web-production-01"

  description = <<-EOT
    Web server for ${var.environment}
    Managed by OpenTofu
    Created on ${timestamp()}
  EOT
}

Conditional Expressions:

variable "environment" {
  default = "dev"
}

resource "proxmox_vm_qemu" "web" {
  # Use more memory in production
  memory = var.environment == "production" ? 8192 : 4096

  # Enable backups only in production
  backup = var.environment == "production" ? true : false
}

For Expressions:

variable "vm_names" {
  default = ["web", "db", "cache"]
}

# Create uppercase version of each name
locals {
  uppercase_names = [for name in var.vm_names : upper(name)]
  # Result: ["WEB", "DB", "CACHE"]

  # Create map with index
  indexed_names = {
    for idx, name in var.vm_names :
    idx => name
  }
  # Result: { 0 = "web", 1 = "db", 2 = "cache" }
}

Managing Proxmox Infrastructure

Setting Up Proxmox Provider

The Telmate Proxmox provider is the community standard for managing Proxmox with OpenTofu/Terraform.

Create API token in Proxmox:

  1. Log into Proxmox web interface
  2. Navigate to Datacenter → Permissions → API Tokens
  3. Click Add
  4. Configure:
    • User: root@pam
    • Token ID: terraform
    • Privilege Separation: Unchecked (for full access)
  5. Copy the secret (you won’t see it again)

Configure provider:

Create providers.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    proxmox = {
      source  = "telmate/proxmox"
      version = "~> 2.9"
    }
  }
}

provider "proxmox" {
  pm_api_url          = var.proxmox_api_url
  pm_api_token_id     = var.proxmox_token_id
  pm_api_token_secret = var.proxmox_token_secret
  pm_tls_insecure     = true  # Set to false with valid SSL cert
  pm_parallel         = 2     # Limit concurrent API requests
  pm_timeout          = 600   # Timeout for operations (seconds)
}

Create variables file:

Create variables.tf:

variable "proxmox_api_url" {
  description = "Proxmox API endpoint"
  type        = string
}

variable "proxmox_token_id" {
  description = "Proxmox API token ID"
  type        = string
}

variable "proxmox_token_secret" {
  description = "Proxmox API token secret"
  type        = string
  sensitive   = true
}

variable "target_node" {
  description = "Proxmox node name"
  type        = string
  default     = "pve"
}

Create terraform.tfvars (DO NOT COMMIT):

proxmox_api_url      = "https://192.168.1.100:8006/api2/json"
proxmox_token_id     = "root@pam!terraform"
proxmox_token_secret = "your-secret-here"
target_node          = "pve-node-01"

Initialize OpenTofu:

# Download provider and initialize
tofu init

# Verify provider installation
tofu providers

Creating Your First VM

Basic VM configuration:

Create main.tf:

resource "proxmox_vm_qemu" "ubuntu_server" {
  name        = "ubuntu-web-01"
  target_node = var.target_node

  # VM Type
  agent    = 1     # Enable QEMU guest agent
  os_type  = "l26" # Linux 2.6+ kernel
  qemu_os  = "l26"

  # CPU Configuration
  cores   = 2
  sockets = 1
  cpu     = "host"

  # Memory
  memory = 4096

  # BIOS and Machine Type
  bios    = "seabios"
  scsihw  = "virtio-scsi-pci"
  boot    = "order=scsi0;ide2;net0"

  # Disk
  disk {
    size    = "32G"
    type    = "scsi"
    storage = "local-lvm"
    iothread = 1
    discard  = "on"
  }

  # Network
  network {
    model  = "virtio"
    bridge = "vmbr0"
  }

  # Lifecycle
  onboot = true

  # Cloud-init (if using cloud-init image)
  ipconfig0 = "ip=dhcp"

  # Description
  desc = "Ubuntu web server - Managed by OpenTofu"
}

Apply configuration:

# Preview changes
tofu plan

# Apply changes
tofu apply

# Review output and type 'yes' to confirm

Verify creation:

# Show state
tofu show

# List resources
tofu state list

Advanced VM Configuration with Cloud-Init

Cloud-init enables automated VM provisioning. First, create a cloud-init enabled template in Proxmox:

# Download Ubuntu cloud image
wget https://cloud-images.ubuntu.com/releases/22.04/release/ubuntu-22.04-server-cloudimg-amd64.img

# Create VM template
qm create 9000 --name "ubuntu-22.04-cloud" --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0
qm importdisk 9000 ubuntu-22.04-server-cloudimg-amd64.img local-lvm
qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-9000-disk-0
qm set 9000 --boot c --bootdisk scsi0
qm set 9000 --ide2 local-lvm:cloudinit
qm set 9000 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
qm template 9000

Use template with OpenTofu:

resource "proxmox_vm_qemu" "ubuntu_cloud" {
  name        = "web-server-${count.index + 1}"
  count       = 3  # Create 3 VMs
  target_node = var.target_node

  # Clone from template
  clone = "ubuntu-22.04-cloud"

  # Full clone (not linked clone)
  full_clone = true

  # VM Configuration
  cores  = 2
  memory = 4096
  agent  = 1

  # Cloud-init Configuration
  cicustom = "user=local:snippets/cloud-init-user.yml"

  # Network configuration with static IP
  ipconfig0 = "ip=192.168.1.${10 + count.index}/24,gw=192.168.1.1"

  # DNS
  nameserver = "192.168.1.1"
  searchdomain = "homelab.local"

  # SSH Keys
  sshkeys = file("~/.ssh/id_rsa.pub")

  # User
  ciuser = "ubuntu"
  cipassword = var.default_password
}

Create cloud-init user data:

Save to Proxmox at /var/lib/vz/snippets/cloud-init-user.yml:

#cloud-config
users:
  - name: ubuntu
    groups: sudo
    shell: /bin/bash
    sudo: ['ALL=(ALL) NOPASSWD:ALL']
    ssh_authorized_keys:
      - ssh-rsa AAAAB3Nza...your-public-key...

packages:
  - qemu-guest-agent
  - curl
  - git
  - vim

package_update: true
package_upgrade: true

runcmd:
  - systemctl enable qemu-guest-agent
  - systemctl start qemu-guest-agent

Creating Multiple VMs with Count

variable "web_server_count" {
  description = "Number of web servers to create"
  type        = number
  default     = 3
}

resource "proxmox_vm_qemu" "web_cluster" {
  count       = var.web_server_count
  name        = "web-${count.index + 1}"
  target_node = var.target_node

  clone      = "ubuntu-22.04-cloud"
  full_clone = true

  cores  = 2
  memory = 4096

  ipconfig0 = "ip=192.168.1.${20 + count.index}/24,gw=192.168.1.1"

  disk {
    size    = "32G"
    storage = "local-lvm"
    type    = "scsi"
  }

  network {
    model  = "virtio"
    bridge = "vmbr0"
  }
}

# Output all IP addresses
output "web_cluster_ips" {
  value = proxmox_vm_qemu.web_cluster[*].default_ipv4_address
}

State Management for Homelabs

Understanding OpenTofu State

OpenTofu tracks managed resources in a state file (terraform.tfstate). This file:

  • Maps configuration to real-world resources
  • Stores metadata (resource IDs, attributes)
  • Enables updates and deletions
  • Contains sensitive data (DO NOT COMMIT TO GIT)

Default state location:

project/
├── terraform.tfstate      # Current state
├── terraform.tfstate.backup  # Previous state

Local State Management

Advantages:

  • Simple setup
  • No additional infrastructure
  • Works offline
  • Fast operations

Disadvantages:

  • Not suitable for teams
  • No locking (concurrent access risks)
  • Manual backup management
  • State on single machine

Best practices for local state:

# Backup state before major changes
cp terraform.tfstate terraform.tfstate.$(date +%Y%m%d-%H%M%S)

# Store backups separately
mkdir -p ~/homelab-infrastructure/state-backups
cp terraform.tfstate ~/homelab-infrastructure/state-backups/terraform.tfstate.$(date +%Y%m%d-%H%M%S)

# Encrypt state backups (contains secrets)
gpg --encrypt --recipient [email protected] terraform.tfstate

Remote State Backends

For better reliability and team collaboration, use remote state backends.

Option 1: S3-Compatible Backend (Minio)

Install Minio in your homelab:

docker run -d   -p 9000:9000   -p 9001:9001   --name minio   -v /mnt/data/minio:/data   -e "MINIO_ROOT_USER=admin"   -e "MINIO_ROOT_PASSWORD=your-secure-password"   quay.io/minio/minio server /data --console-address ":9001"

Configure backend:

terraform {
  backend "s3" {
    bucket = "opentofu-state"
    key    = "homelab/terraform.tfstate"
    region = "us-east-1"  # Required but ignored

    endpoint                    = "http://minio.homelab.local:9000"
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    skip_region_validation      = true
    force_path_style            = true

    access_key = "your-access-key"
    secret_key = "your-secret-key"
  }
}

Option 2: HTTP Backend (GitLab/Gitea)

If you run self-hosted Git:

terraform {
  backend "http" {
    address        = "https://gitlab.homelab.local/api/v4/projects/123/terraform/state/homelab"
    lock_address   = "https://gitlab.homelab.local/api/v4/projects/123/terraform/state/homelab/lock"
    unlock_address = "https://gitlab.homelab.local/api/v4/projects/123/terraform/state/homelab/lock"
    username       = "your-username"
    password       = var.gitlab_token
    lock_method    = "POST"
    unlock_method  = "DELETE"
    retry_wait_min = 5
  }
}

Option 3: Local with Version Control

Compromise solution - local state with Git LFS:

# Install Git LFS
git lfs install

# Track state files
git lfs track "*.tfstate"

# Add .gitattributes
git add .gitattributes
git commit -m "Track state files with Git LFS"

# Encrypt state before committing
git-crypt init

State File Encryption

Using SOPS (Secrets OPerationS):

# Install SOPS
brew install sops  # macOS
# or
sudo apt install sops  # Ubuntu

# Create .sops.yaml
cat > .sops.yaml << 'EOF'
creation_rules:
  - path_regex: .tfstate$
    encrypted_regex: '^(data|modules|resources)$'
    pgp: 'YOUR_PGP_KEY_FINGERPRINT'
EOF

# Encrypt state
sops -e terraform.tfstate > terraform.tfstate.enc

# Decrypt when needed
sops -d terraform.tfstate.enc > terraform.tfstate

State Operations

Inspect state:

# List all resources
tofu state list

# Show specific resource
tofu state show proxmox_vm_qemu.web_server

# Show all state
tofu show

Modify state:

# Remove resource from state (doesn't destroy resource)
tofu state rm proxmox_vm_qemu.old_server

# Move/rename resource
tofu state mv proxmox_vm_qemu.old_name proxmox_vm_qemu.new_name

# Import existing resource
tofu import proxmox_vm_qemu.existing_vm pve:qemu/100

Recover from state issues:

# Pull remote state
tofu state pull > terraform.tfstate

# Push local state to remote
tofu state push terraform.tfstate

# Refresh state from actual infrastructure
tofu refresh

Modular Infrastructure Design

Creating Reusable Modules

Modules enable code reuse and organization. A module is just a directory with .tf files.

Module structure:

modules/
└── proxmox-vm/
    ├── main.tf      # Resources
    ├── variables.tf # Input variables
    ├── outputs.tf   # Output values
    └── README.md    # Documentation

Example: VM module

modules/proxmox-vm/variables.tf:

variable "vm_name" {
  description = "Name of the VM"
  type        = string
}

variable "target_node" {
  description = "Proxmox node name"
  type        = string
}

variable "cores" {
  description = "Number of CPU cores"
  type        = number
  default     = 2
}

variable "memory" {
  description = "Memory in MB"
  type        = number
  default     = 4096
}

variable "disk_size" {
  description = "Disk size (e.g., '32G')"
  type        = string
  default     = "32G"
}

variable "storage" {
  description = "Storage pool name"
  type        = string
  default     = "local-lvm"
}

variable "template_name" {
  description = "Cloud-init template name"
  type        = string
}

variable "ip_address" {
  description = "Static IP address (CIDR notation)"
  type        = string
}

variable "gateway" {
  description = "Network gateway"
  type        = string
}

variable "ssh_keys" {
  description = "SSH public keys"
  type        = list(string)
}

modules/proxmox-vm/main.tf:

resource "proxmox_vm_qemu" "vm" {
  name        = var.vm_name
  target_node = var.target_node

  clone      = var.template_name
  full_clone = true

  cores  = var.cores
  memory = var.memory
  agent  = 1

  disk {
    size    = var.disk_size
    storage = var.storage
    type    = "scsi"
    iothread = 1
  }

  network {
    model  = "virtio"
    bridge = "vmbr0"
  }

  ipconfig0    = "ip=${var.ip_address},gw=${var.gateway}"
  nameserver   = var.gateway
  searchdomain = "homelab.local"

  sshkeys = join("\n", var.ssh_keys)
  ciuser  = "ubuntu"

  lifecycle {
    ignore_changes = [
      network,
    ]
  }
}

modules/proxmox-vm/outputs.tf:

output "vm_id" {
  description = "VM ID"
  value       = proxmox_vm_qemu.vm.id
}

output "vm_name" {
  description = "VM name"
  value       = proxmox_vm_qemu.vm.name
}

output "ip_address" {
  description = "IP address"
  value       = proxmox_vm_qemu.vm.default_ipv4_address
}

Using Modules

Root module using the VM module:

module "web_servers" {
  source = "./modules/proxmox-vm"
  count  = 3

  vm_name       = "web-${count.index + 1}"
  target_node   = "pve-node-01"
  cores         = 2
  memory        = 4096
  disk_size     = "32G"
  storage       = "local-lvm"
  template_name = "ubuntu-22.04-cloud"
  ip_address    = "192.168.1.${20 + count.index}/24"
  gateway       = "192.168.1.1"
  ssh_keys      = [file("~/.ssh/id_rsa.pub")]
}

module "database_server" {
  source = "./modules/proxmox-vm"

  vm_name       = "postgres-01"
  target_node   = "pve-node-01"
  cores         = 4
  memory        = 16384
  disk_size     = "100G"
  storage       = "local-lvm"
  template_name = "ubuntu-22.04-cloud"
  ip_address    = "192.168.1.30/24"
  gateway       = "192.168.1.1"
  ssh_keys      = [file("~/.ssh/id_rsa.pub")]
}

# Access module outputs
output "web_cluster_ips" {
  value = module.web_servers[*].ip_address
}

output "database_ip" {
  value = module.database_server.ip_address
}

Multi-Environment Deployments

Directory structure:

homelab-infrastructure/
├── modules/
│   └── proxmox-vm/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       ├── variables.tf
│       └── terraform.tfvars
└── shared/
    ├── providers.tf
    └── versions.tf

environments/dev/main.tf:

module "web_server" {
  source = "../../modules/proxmox-vm"

  vm_name       = "${var.environment}-web-01"
  target_node   = var.target_node
  cores         = 1  # Dev uses fewer resources
  memory        = 2048
  disk_size     = "20G"
  storage       = var.storage
  template_name = var.template_name
  ip_address    = var.web_ip
  gateway       = var.gateway
  ssh_keys      = var.ssh_keys
}

environments/dev/terraform.tfvars:

environment   = "dev"
target_node   = "pve-node-01"
storage       = "local-lvm"
template_name = "ubuntu-22.04-cloud"
web_ip        = "192.168.1.50/24"
gateway       = "192.168.1.1"

environments/prod/terraform.tfvars:

environment   = "prod"
target_node   = "pve-node-01"
storage       = "fast-ssd"
template_name = "ubuntu-22.04-cloud"
web_ip        = "192.168.1.20/24"
gateway       = "192.168.1.1"

Deploy to specific environment:

# Deploy to dev
cd environments/dev
tofu init
tofu apply

# Deploy to production
cd environments/prod
tofu init
tofu apply

Migrating from Terraform to OpenTofu

Pre-Migration Checklist

  • Backup all Terraform state files
  • Document current Terraform version
  • List all providers and their versions
  • Test migration in dev/staging first
  • Review OpenTofu compatibility

Migration Steps

Step 1: Install OpenTofu

Follow installation instructions from earlier in this guide.

Step 2: Verify Compatibility

# Check current Terraform version
terraform version

# Ensure you're on Terraform <= 1.5.x for easiest migration
# If using 1.6+, some features may not be compatible yet

Step 3: Update Backend Configuration (if needed)

OpenTofu uses the same backend configuration, but update registry URLs if you’re using the Terraform Registry:

terraform {
  required_providers {
    # OLD (Terraform Registry)
    proxmox = {
      source  = "telmate/proxmox"
      version = "~> 2.9"
    }

    # NEW (OpenTofu Registry) - often the same
    # No changes needed for most providers
  }
}

Step 4: Migrate State

# Backup existing state
cp terraform.tfstate terraform.tfstate.backup.$(date +%Y%m%d)

# Initialize with OpenTofu
tofu init -migrate-state

# OpenTofu will ask: "Do you want to copy existing state to the new backend?"
# Answer: yes

# Verify migration
tofu state list

# Compare with Terraform
terraform state list

Step 5: Test Planning

# Run plan to verify no unexpected changes
tofu plan

# If plan shows changes, investigate before applying
# Expected: None (if infrastructure unchanged)
# Unexpected: Provider differences, state format changes

Step 6: Update CI/CD Pipelines

Replace terraform commands with tofu:

# GitHub Actions example
- name: OpenTofu Apply
  run: |
    tofu init
    tofu plan
    tofu apply -auto-approve

Step 7: Update Documentation

  • Update README files
  • Change references from Terraform to OpenTofu
  • Update team runbooks

Handling Provider Differences

Most providers work identically, but verify:

# Check provider availability
tofu providers

# If provider not found in OpenTofu registry, use explicit source
terraform {
  required_providers {
    custom_provider = {
      source = "registry.terraform.io/custom/provider"
      # Explicitly reference Terraform registry if needed
    }
  }
}

Rollback Plan

If migration fails, rollback is simple:

# Restore original state
cp terraform.tfstate.backup.20251216 terraform.tfstate

# Continue using Terraform
terraform init
terraform plan

Advanced Patterns and Best Practices

Workspaces for Environment Management

Workspaces provide state isolation within a single configuration:

# Create workspaces
tofu workspace new dev
tofu workspace new staging
tofu workspace new prod

# List workspaces
tofu workspace list

# Switch workspace
tofu workspace select dev

# Show current workspace
tofu workspace show

Use workspace in configuration:

locals {
  environment = terraform.workspace

  # Environment-specific values
  vm_count = {
    dev     = 1
    staging = 2
    prod    = 5
  }

  memory = {
    dev     = 2048
    staging = 4096
    prod    = 8192
  }
}

resource "proxmox_vm_qemu" "web" {
  count  = local.vm_count[local.environment]
  name   = "${local.environment}-web-${count.index + 1}"
  memory = local.memory[local.environment]

  # ... other configuration
}

Dynamic Blocks for Flexibility

Dynamic blocks generate repeated nested blocks:

variable "additional_disks" {
  type = list(object({
    size    = string
    storage = string
  }))
  default = [
    { size = "50G", storage = "local-lvm" },
    { size = "100G", storage = "slow-hdd" },
  ]
}

resource "proxmox_vm_qemu" "storage_server" {
  name = "storage-01"

  # Primary disk
  disk {
    size    = "32G"
    storage = "fast-ssd"
    type    = "scsi"
  }

  # Additional disks dynamically
  dynamic "disk" {
    for_each = var.additional_disks
    content {
      size    = disk.value.size
      storage = disk.value.storage
      type    = "scsi"
    }
  }
}

Terraform Functions for Power Users

File and template functions:

# Read file contents
locals {
  ssh_keys = file("~/.ssh/id_rsa.pub")
}

# Template file with variables
data "template_file" "cloud_init" {
  template = file("${path.module}/cloud-init.tpl")
  vars = {
    hostname = "web-01"
    domain   = "homelab.local"
  }
}

# YAML/JSON encoding
locals {
  cloud_init_config = yamlencode({
    users = [
      {
        name   = "admin"
        groups = ["sudo"]
      }
    ]
  })
}

Collection functions:

variable "vm_configs" {
  type = map(object({
    cores  = number
    memory = number
  }))
  default = {
    web = { cores = 2, memory = 4096 }
    db  = { cores = 4, memory = 8192 }
  }
}

# Create VMs from map
resource "proxmox_vm_qemu" "servers" {
  for_each = var.vm_configs

  name   = each.key
  cores  = each.value.cores
  memory = each.value.memory
}

# Lookup function
locals {
  web_memory = lookup(var.vm_configs, "web", { cores = 2, memory = 2048 }).memory
}

String manipulation:

locals {
  # Join list into string
  all_ips = join(",", proxmox_vm_qemu.web[*].default_ipv4_address)

  # Split string into list
  zones = split(",", "us-east-1a,us-east-1b,us-east-1c")

  # Format strings
  vm_name = format("web-%03d", 1)  # Produces "web-001"

  # Replace in string
  safe_name = replace(var.environment, "_", "-")
}

Provisioners (Use Sparingly)

Provisioners run scripts after resource creation. Use Ansible/cloud-init instead when possible.

resource "proxmox_vm_qemu" "web" {
  name = "web-01"
  # ... configuration

  # Run local script after creation
  provisioner "local-exec" {
    command = "echo ${self.default_ipv4_address} >> inventory.txt"
  }

  # Run script on remote VM
  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y nginx",
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")
      host        = self.default_ipv4_address
    }
  }

  # Cleanup on destroy
  provisioner "local-exec" {
    when    = destroy
    command = "echo 'VM destroyed' >> destroy.log"
  }
}

Import Existing Infrastructure

Bring existing resources under OpenTofu management:

# Find resource ID (Proxmox VM ID)
# VM ID 100 on node "pve"

# Import into OpenTofu
tofu import proxmox_vm_qemu.existing_vm pve:qemu/100

# Generate configuration from state
tofu show -json | jq '.values.root_module.resources[] | select(.address=="proxmox_vm_qemu.existing_vm")'

# Manually create matching configuration
# Then verify with:
tofu plan  # Should show no changes

Troubleshooting Common Issues

Provider Connection Errors

Error: Error creating VM: 401 Unauthorized

Solutions:

# Verify API token
curl -k https://proxmox.example.com:8006/api2/json/access/ticket   -d "username=root@pam"   -d "password=yourpassword"

# Check token permissions in Proxmox UI
# Datacenter → Permissions → API Tokens
# Ensure "Privilege Separation" is unchecked

# Test API access
curl -k -H "Authorization: PVEAPIToken=root@pam!terraform=YOUR-SECRET"   https://proxmox.example.com:8006/api2/json/nodes

State Lock Errors

Error: Error locking state: state blob is already locked

Solutions:

# Force unlock (use with caution)
tofu force-unlock LOCK_ID

# If using S3 backend, manually remove lock
aws s3api delete-object   --bucket terraform-state   --key homelab/terraform.tfstate.lock

# Prevent locks: use different workspaces for parallel operations

Resource Already Exists

Error: A resource with this name already exists

Solutions:

# Option 1: Import existing resource
tofu import proxmox_vm_qemu.conflicting_vm pve:qemu/VM_ID

# Option 2: Rename in configuration
# Change 'name' attribute to something unique

# Option 3: Delete from Proxmox manually
# Then rerun tofu apply

Cloud-Init Not Working

Symptoms: VM created but cloud-init doesn’t configure IP/SSH

Solutions:

# Verify template has cloud-init disk
qm config TEMPLATE_ID | grep ide2

# Should show: ide2: local-lvm:cloudinit

# Check cloud-init logs on VM
ssh ubuntu@VM_IP
sudo cat /var/log/cloud-init.log
sudo cloud-init status

# Rebuild cloud-init
sudo cloud-init clean
sudo cloud-init init

Plan Shows Unexpected Changes

Symptom: tofu plan shows changes on every run

Solutions:

# Use lifecycle ignore_changes for attributes that drift
resource "proxmox_vm_qemu" "example" {
  name = "example"

  lifecycle {
    ignore_changes = [
      network,      # Proxmox may modify MAC address
      disk,         # Disk size might show differently
      ipconfig0,    # DHCP vs static inconsistencies
    ]
  }
}

# Or refresh state to match reality
tofu refresh

Conclusion and Next Steps

OpenTofu brings infrastructure-as-code to your homelab with true open-source licensing, community governance, and a commitment to remaining free forever. You’ve learned how to:

  • Install and configure OpenTofu
  • Write HCL configurations for Proxmox infrastructure
  • Manage state files securely
  • Create reusable modules
  • Migrate from Terraform
  • Implement advanced patterns

Key Takeaways

  • Declarative IaC makes homelab infrastructure reproducible and version-controlled
  • OpenTofu is 100% compatible with Terraform 1.5.x configurations
  • State management is critical - backup, encrypt, and protect your state files
  • Modules enable reuse - write once, deploy many times
  • Start simple - one VM, then expand to complex multi-environment deployments
  1. Deploy your first VM with OpenTofu today
  2. Version control everything - create Git repo for your infrastructure
  3. Build reusable modules for common patterns in your homelab
  4. Automate with CI/CD - use Gitea Actions or Woodpecker CI
  5. Explore other providers - Docker, Kubernetes, DNS, etc.
  6. Join the community - contribute to OpenTofu development
  • Gitea Actions - CI/CD for your infrastructure code
  • Ansible - Configuration management to complement OpenTofu
  • Proxmox Advanced - Deep dive into Proxmox features
  • Kubernetes with K3s - Manage K8s clusters with OpenTofu

Resources


Document Version: 1.0 Last Updated: December 16, 2025 Based on: OpenTofu 1.6.x, Proxmox VE 8.x Estimated Completion Time: 90 minutes (installation + first deployment)

Happy infrastructure coding!