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 |
|
Format all files |
|
Validate config |
|
Plan changes |
|
Apply plan |
|
Destroy all |
|
List resources |
|
Import resource |
|
Remove from state |
|
Show outputs |
|
Switch workspace |
|
Upgrade providers |
|
Console mode |
|