Terraform

Declarative infrastructure as code with Terraform - state management, modules, providers for home lab and cloud.

Terraform Basics

# Initialize working directory
terraform init

# Validate configuration
terraform validate

# Format files
terraform fmt
terraform fmt -recursive             # All subdirs

# Plan changes (dry run)
terraform plan
terraform plan -out=tfplan           # Save plan

# Apply changes
terraform apply
terraform apply tfplan               # Apply saved plan
terraform apply -auto-approve        # Skip confirmation

# Destroy infrastructure
terraform destroy
terraform destroy -auto-approve

MUSCLE MEMORY: terraform fmt && terraform validate && terraform plan before apply.

Standard Workflow

# 1. Initialize
terraform init

# 2. Format & validate
terraform fmt -recursive
terraform validate

# 3. Plan (review changes)
terraform plan -out=tfplan

# 4. Apply saved plan
terraform apply tfplan

# 5. Verify
terraform show

# Refresh state from real infra
terraform refresh

# Target specific resource
terraform plan -target=proxmox_vm_qemu.web
terraform apply -target=proxmox_vm_qemu.web

# Replace resource (recreate)
terraform apply -replace=proxmox_vm_qemu.web

PATTERN: Save plans in CI/CD for auditable deployments.

State Management

# Show current state
terraform show
terraform show -json | jq

# List resources in state
terraform state list

# Show specific resource
terraform state show proxmox_vm_qemu.web

# Move resource in state (rename)
terraform state mv proxmox_vm_qemu.old proxmox_vm_qemu.new

# Remove from state (don't destroy)
terraform state rm proxmox_vm_qemu.imported

# Import existing resource
terraform import proxmox_vm_qemu.existing 'qemu/100'
terraform import aws_instance.existing i-1234567890abcdef0

# Pull remote state
terraform state pull > state.json

# Push state (dangerous)
terraform state push state.json

# Unlock stuck state
terraform force-unlock LOCK_ID

GOTCHA: Never edit terraform.tfstate directly. Always use terraform state commands.

Variables & Outputs

# variables.tf
variable "vm_count" {
  description = "Number of VMs to create"
  type        = number
  default     = 1
}

variable "environment" {
  description = "Environment name"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "tags" {
  description = "Resource tags"
  type        = map(string)
  default     = {}
}

# outputs.tf
output "vm_ips" {
  description = "IP addresses of created VMs"
  value       = proxmox_vm_qemu.web[*].default_ipv4_address
}

output "connection_string" {
  description = "SSH connection command"
  value       = "ssh root@${proxmox_vm_qemu.web.default_ipv4_address}"
  sensitive   = false
}
# Set variables
terraform plan -var="vm_count=3"
terraform plan -var-file="prod.tfvars"

# Environment variables
export TF_VAR_vm_count=3
terraform plan

# Show outputs
terraform output
terraform output vm_ips
terraform output -json | jq

Modules

# Using local module
module "web_server" {
  source = "./modules/vm"

  name        = "web-01"
  memory      = 4096
  cores       = 2
  disk_size   = 32
  environment = "prod"
}

# Using registry module
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"
}

# Using Git module
module "network" {
  source = "git::https://github.com/user/terraform-modules.git//network?ref=v1.0.0"
}
# Get module updates
terraform get
terraform get -update

# Initialize with module upgrade
terraform init -upgrade

# List modules
terraform providers

STRUCTURE: Modules = reusable infrastructure components.

Workspaces

# List workspaces
terraform workspace list

# Create workspace
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

# Switch workspace
terraform workspace select prod

# Show current
terraform workspace show

# Delete workspace
terraform workspace delete dev
# Use workspace in config
locals {
  environment = terraform.workspace

  instance_type = {
    dev     = "t3.micro"
    staging = "t3.small"
    prod    = "t3.medium"
  }
}

resource "aws_instance" "web" {
  instance_type = local.instance_type[local.environment]
  tags = {
    Environment = local.environment
  }
}

USE CASE: Same config, different state per environment.

Provider Configuration

# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    proxmox = {
      source  = "telmate/proxmox"
      version = ">= 2.9.0"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# providers.tf
provider "proxmox" {
  pm_api_url          = var.proxmox_url
  pm_api_token_id     = var.proxmox_token_id
  pm_api_token_secret = var.proxmox_token_secret
  pm_tls_insecure     = true
}

provider "aws" {
  region  = "us-west-2"
  profile = "home-lab"
}

# Multiple provider configs (aliases)
provider "aws" {
  alias  = "east"
  region = "us-east-1"
}

resource "aws_instance" "east_server" {
  provider = aws.east
  # ...
}

Proxmox Provider (Home Lab)

# VM from template
resource "proxmox_vm_qemu" "k3s_node" {
  count       = 3
  name        = "k3s-${count.index + 1}"
  target_node = "pve"
  clone       = "rocky9-template"
  agent       = 1
  os_type     = "cloud-init"
  cores       = 4
  memory      = 8192
  scsihw      = "virtio-scsi-pci"

  disk {
    slot    = "scsi0"
    size    = "50G"
    type    = "disk"
    storage = "local-lvm"
  }

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

  # Cloud-init
  ciuser     = "ansible"
  sshkeys    = file("~/.ssh/id_ed25519.pub")
  ipconfig0  = "ip=10.50.1.${100 + count.index}/24,gw=10.50.1.1"
  nameserver = "10.50.1.50"
}

# LXC container
resource "proxmox_lxc" "pihole" {
  hostname    = "pihole"
  target_node = "pve"
  ostemplate  = "local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst"
  password    = var.lxc_password
  unprivileged = true
  start       = true
  memory      = 512
  cores       = 1

  rootfs {
    storage = "local-lvm"
    size    = "8G"
  }

  network {
    name   = "eth0"
    bridge = "vmbr0"
    ip     = "10.50.1.53/24"
    gw     = "10.50.1.1"
  }
}

Kubernetes Provider

provider "kubernetes" {
  config_path = "~/.kube/config"
  # Or explicit config
  host                   = var.k8s_host
  client_certificate     = base64decode(var.k8s_client_cert)
  client_key             = base64decode(var.k8s_client_key)
  cluster_ca_certificate = base64decode(var.k8s_ca_cert)
}

provider "helm" {
  kubernetes {
    config_path = "~/.kube/config"
  }
}

# Namespace
resource "kubernetes_namespace" "app" {
  metadata {
    name = "myapp"
    labels = {
      environment = "prod"
    }
  }
}

# ConfigMap
resource "kubernetes_config_map" "app_config" {
  metadata {
    name      = "app-config"
    namespace = kubernetes_namespace.app.metadata[0].name
  }

  data = {
    "config.yaml" = file("${path.module}/config.yaml")
  }
}

# Helm release
resource "helm_release" "nginx" {
  name       = "nginx"
  repository = "https://charts.bitnami.com/bitnami"
  chart      = "nginx"
  namespace  = "web"
  version    = "15.0.0"

  values = [
    file("${path.module}/nginx-values.yaml")
  ]

  set {
    name  = "service.type"
    value = "LoadBalancer"
  }
}

Remote State Backend

# S3 backend (AWS)
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/infrastructure.tfstate"
    region         = "us-west-2"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

# Consul backend (home lab)
terraform {
  backend "consul" {
    address = "consul.inside.domusdigitalis.dev:8500"
    scheme  = "https"
    path    = "terraform/prod/state"
    lock    = true
  }
}

# Local backend (default)
terraform {
  backend "local" {
    path = "terraform.tfstate"
  }
}

# HTTP backend (GitLab)
terraform {
  backend "http" {
    address        = "https://gitlab.com/api/v4/projects/12345/terraform/state/prod"
    lock_address   = "https://gitlab.com/api/v4/projects/12345/terraform/state/prod/lock"
    unlock_address = "https://gitlab.com/api/v4/projects/12345/terraform/state/prod/lock"
    lock_method    = "POST"
    unlock_method  = "DELETE"
    retry_wait_min = 5
  }
}

HOME LAB: MinIO + local backend works great for single-user.

Loops & Conditionals

# count (simple)
resource "proxmox_vm_qemu" "node" {
  count = 3
  name  = "node-${count.index + 1}"
}

# for_each (map)
variable "vms" {
  default = {
    web   = { cores = 2, memory = 4096 }
    api   = { cores = 4, memory = 8192 }
    db    = { cores = 4, memory = 16384 }
  }
}

resource "proxmox_vm_qemu" "server" {
  for_each = var.vms
  name     = each.key
  cores    = each.value.cores
  memory   = each.value.memory
}

# for_each (set of strings)
resource "proxmox_vm_qemu" "node" {
  for_each = toset(["web-1", "web-2", "web-3"])
  name     = each.value
}

# Conditional
resource "aws_eip" "public" {
  count    = var.create_public_ip ? 1 : 0
  instance = aws_instance.web.id
}

# Dynamic blocks
resource "aws_security_group" "web" {
  name = "web-sg"

  dynamic "ingress" {
    for_each = var.allowed_ports
    content {
      from_port   = ingress.value
      to_port     = ingress.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

Data Sources

# Read existing resources
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

data "aws_vpc" "existing" {
  tags = {
    Name = "main-vpc"
  }
}

# Use in resource
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  vpc_id        = data.aws_vpc.existing.id
  instance_type = "t3.micro"
}

# External data (run script)
data "external" "git_info" {
  program = ["bash", "${path.module}/scripts/git-info.sh"]
}

output "commit_sha" {
  value = data.external.git_info.result.sha
}

# Remote state data
data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "my-terraform-state"
    key    = "network/terraform.tfstate"
    region = "us-west-2"
  }
}

output "vpc_id" {
  value = data.terraform_remote_state.network.outputs.vpc_id
}

Debugging & Troubleshooting

# Enable debug logging
export TF_LOG=DEBUG
export TF_LOG_PATH=terraform.log
terraform plan

# Log levels: TRACE, DEBUG, INFO, WARN, ERROR

# Show dependency graph
terraform graph | dot -Tpng > graph.png

# Validate syntax only
terraform validate

# Check state consistency
terraform plan -refresh-only

# Force state refresh
terraform apply -refresh-only

# List providers
terraform providers

# Provider schema
terraform providers schema -json | jq

# Console (interactive)
terraform console
> var.vm_count
> proxmox_vm_qemu.web[0].default_ipv4_address
> cidrsubnet("10.0.0.0/16", 8, 1)

DEBUG PATTERN: TF_LOG=DEBUG terraform plan 2>&1 | tee debug.log

Quick Reference

Task Command

Initialize

terraform init

Format all files

terraform fmt -recursive

Validate config

terraform validate

Plan changes

terraform plan -out=tfplan

Apply plan

terraform apply tfplan

Destroy all

terraform destroy

List resources

terraform state list

Import resource

terraform import <resource> <id>

Remove from state

terraform state rm <resource>

Show outputs

terraform output -json

Switch workspace

terraform workspace select <name>

Upgrade providers

terraform init -upgrade

Console mode

terraform console