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¶
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
planbeforeapply, 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,-replaceinstead. - One state per environment (workspaces or separate dirs/backends) — don't mix prod and dev.
destroyis irreversible. Guard prod withprevent_destroyand required approvals.terraform fmt+validatein 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_destroyand approvals for stateful/prod resources. fmt,validate, and a security scanner (tfsec/Checkov) in the pipeline.
Official Sources¶
- Terraform Documentation — https://developer.hashicorp.com/terraform/docs
- Language (HCL) reference — https://developer.hashicorp.com/terraform/language
- Terraform Registry (providers & modules) — https://registry.terraform.io/
- AWS provider — https://registry.terraform.io/providers/hashicorp/aws/latest/docs
- Google provider — https://registry.terraform.io/providers/hashicorp/google/latest/docs
- AzureRM provider — https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs
- Proxmox provider (bpg) — https://registry.terraform.io/providers/bpg/proxmox/latest/docs
- OpenTofu — https://opentofu.org/docs/