Skip to content

Terraform

On this page

Infrastructure as Code with HCL: the core workflow, state, modules, and ready-to-adapt provider snippets for AWS, GCP, Azure, and Proxmox.

Basics

Terraform (HashiCorp) provisions infrastructure declaratively. You describe the desired end state in HCL; Terraform builds a dependency graph, computes a plan (the diff between desired and real state), and applies it via provider APIs.

Licensing note

Terraform moved to the BUSL license in 2023. OpenTofu is an MPL-licensed, drop-in open-source fork (CLI tofu). The HCL and workflow below apply to both.

Core concepts

Concept What it is
Provider Plugin that talks to a platform's API (aws, google, azurerm, proxmox).
Resource A managed object (aws_instance, google_compute_instance).
Data source Read-only lookup of existing infra.
Variable / Output Inputs / exported values.
State Terraform's record of what it manages (terraform.tfstate).
Module Reusable group of resources with inputs/outputs.
Backend Where state lives (local, S3, GCS, Azure Blob, Terraform Cloud).

State — the thing to respect

State maps your config to real-world resources. Never edit it by hand. For teams, use a remote backend with locking (e.g. S3 + DynamoDB, GCS, Azure Blob, or HCP Terraform) so two people can't apply at once. Treat state as sensitive — it can contain secrets.

The core workflow

write HCL → terraform init → plan → apply → (later) destroy
                  │            │       │
              plugins      preview  make it so

Cheatsheet

terraform init           # download providers, configure backend
terraform fmt -recursive # format
terraform validate       # syntax/type check
terraform plan -out tf.plan
terraform apply tf.plan  # apply a saved plan (recommended)
terraform apply          # plan + apply interactively
terraform destroy

terraform state list
terraform state show aws_instance.web
terraform output
terraform import aws_instance.web i-0abc123   # adopt existing resource
terraform taint / -replace="aws_instance.web" # force recreate (use -replace)
terraform workspace new staging               # multiple state instances
terraform plan -var-file=prod.tfvars

Project skeleton

.
├── main.tf          # resources
├── variables.tf     # inputs
├── outputs.tf       # outputs
├── providers.tf     # provider + backend config
├── terraform.tfvars # values (don't commit secrets)
└── modules/
    └── network/...

Remote backend (AWS S3 example)

terraform {
  required_version = ">= 1.6"
  backend "s3" {
    bucket         = "moin-tfstate"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks"   # state locking
    encrypt        = true
  }
}

Variables, outputs, modules

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

output "public_ip" {
  value = aws_instance.web.public_ip
}

module "network" {
  source     = "./modules/network"
  cidr_block = "10.0.0.0/16"
}

Provider snippets

terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}
provider "aws" {
  region = "us-east-1"   # creds via env/SSO/profile, never hardcoded
}

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
  tags = { Name = "main" }
}

resource "aws_subnet" "public" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "us-east-1a"
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = var.instance_type
  subnet_id     = aws_subnet.public.id
  tags = { Name = "web" }
}

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter { name = "name"  values = ["al2023-ami-*-x86_64"] }
}
terraform {
  required_providers {
    google = { source = "hashicorp/google", version = "~> 5.0" }
  }
}
provider "google" {
  project = "my-project-id"
  region  = "us-central1"
}

resource "google_compute_network" "vpc" {
  name                    = "main"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "subnet" {
  name          = "subnet"
  ip_cidr_range = "10.0.1.0/24"
  region        = "us-central1"
  network       = google_compute_network.vpc.id
}

resource "google_compute_instance" "web" {
  name         = "web"
  machine_type = "e2-micro"
  zone         = "us-central1-a"
  boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
  network_interface {
    subnetwork = google_compute_subnetwork.subnet.id
    access_config {}   # ephemeral public IP
  }
}
terraform {
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 3.0" }
  }
}
provider "azurerm" { features {} }

resource "azurerm_resource_group" "rg" {
  name     = "rg-main"
  location = "eastus"
}

resource "azurerm_virtual_network" "vnet" {
  name                = "vnet-main"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_subnet" "subnet" {
  name                 = "subnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.1.0/24"]
}

resource "azurerm_linux_virtual_machine" "web" {
  name                = "web"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  size                = "Standard_B1s"
  admin_username      = "azureuser"
  network_interface_ids = [azurerm_network_interface.nic.id]
  admin_ssh_key {
    username   = "azureuser"
    public_key = file("~/.ssh/id_ed25519.pub")
  }
  os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
  source_image_reference {
    publisher = "Canonical"
    offer     = "ubuntu-24_04-lts"
    sku       = "server"
    version   = "latest"
  }
}

Proxmox provider

Proxmox isn't an official HashiCorp provider; community providers are commonly used — most notably bpg/proxmox (actively maintained) and the older Telmate/proxmox. Verify the current version and argument names on the Terraform Registry before use.

terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"     # check registry for latest version
      version = "~> 0.60"
    }
  }
}

provider "proxmox" {
  endpoint = "https://pve.example.com:8006/"
  # Prefer an API token over username/password
  api_token = var.proxmox_api_token   # "user@pam!tokenid=uuid"
  insecure  = false                    # true only with self-signed cert in labs
}

# Clone a VM from a template
resource "proxmox_virtual_environment_vm" "web" {
  name      = "web-01"
  node_name = "pve"

  clone {
    vm_id = 9000   # template ID
  }
  cpu    { cores = 2 }
  memory { dedicated = 2048 }

  agent { enabled = true }

  initialization {                 # cloud-init
    ip_config {
      ipv4 { address = "10.0.0.50/24"  gateway = "10.0.0.1" }
    }
  }
}

Thumb Rules

Rules of thumb

  • Always plan before apply, and apply a saved plan in CI so what you reviewed is what runs.
  • Remote state + locking for any team. Local state is for solo experiments only.
  • Never hardcode secrets/creds. Use env vars, cloud auth (SSO/OIDC), and mark outputs sensitive.
  • Pin provider and module versions (~>), and commit the lockfile (.terraform.lock.hcl).
  • Modules for anything you build twice. Keep root configs thin.
  • Don't hand-edit state. Use import, state mv, -replace instead.
  • One state per environment (workspaces or separate dirs/backends) — don't mix prod and dev.
  • destroy is irreversible. Guard prod with prevent_destroy and required approvals.
  • terraform fmt + validate in CI keep diffs clean.

Use Cases

  • Provisioning cloud infra (VPCs, compute, databases, IAM) reproducibly across AWS/GCP/Azure.
  • Multi-cloud / hybrid — one workflow, many providers, including on-prem Proxmox.
  • Environment cloning — spin up identical dev/stage/prod from the same modules.
  • Day-2 management — track drift, apply controlled changes, tear down test infra cheaply.
  • Platform building blocks — publish internal modules for teams to consume.
  • Pairs with config mgmt — Terraform builds the box, Ansible/Chef configures it.

Common Issues

State drift / “resource changed outside Terraform”

Someone changed infra in the console. terraform plan shows the diff; reconcile by importing/adjusting config or re-applying. Avoid manual changes to managed resources.

State lock errors

A previous run crashed holding the lock, or two applies overlap. Verify nobody's running, then terraform force-unlock <LOCK_ID> carefully.

Provider authentication failures

Missing/expired credentials or wrong env vars. Each provider has its own auth (AWS profile/SSO, GCP ADC, Azure CLI login, Proxmox API token). Confirm with the provider's auth docs.

“Resource already exists” on apply

The object exists but isn't in state. Use terraform import to adopt it instead of recreating.

Cycle / dependency errors

Implicit or explicit (depends_on) cycles. Break the loop; let Terraform infer dependencies from references where possible.

Destroying the wrong thing

Running in the wrong workspace/dir. Always check terraform workspace show and read the plan's destroy lines before approving.

Best Practices

  • Remote backend with locking and encryption; separate state per environment.
  • Version-pin providers/modules; commit .terraform.lock.hcl.
  • Structure with modules; keep root configs declarative and small.
  • Run plan/apply through CI with peer review and policy checks (OPA/Sentinel).
  • Keep secrets out of code and state where possible; use dynamic credentials/OIDC.
  • Tag/label everything for cost tracking and ownership.
  • Use prevent_destroy and approvals for stateful/prod resources.
  • fmt, validate, and a security scanner (tfsec/Checkov) in the pipeline.

Official Sources