Infrastructure as Code
Terraform describes cloud infrastructure in declarative configuration files and provisions it through provider APIs. You state the desired end state; Terraform computes the diff against reality and makes only the changes needed. This is declarative IaC, in contrast to imperative scripts that spell out each step.
Why IaC
Infrastructure becomes version-controlled, reviewable, and reproducible. The same config yields the same environment every time, eliminating snowflake servers and manual console drift.
HCL Syntax and Blocks
Configuration is written in HCL (HashiCorp Configuration Language). Everything is a block: a type, optional labels, and a body of arguments.
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
provider "aws" {
region = var.region
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-${var.environment}"
}
}
| Block | Purpose |
|---|---|
| provider | Configures a plugin that talks to a platform API (AWS, GCP, Azure) |
| resource | Declares an infrastructure object Terraform manages |
| data | Reads existing infrastructure it does not manage |
| variable | Input parameter for the configuration |
| output | Exported value shown after apply / consumed by other modules |
| module | Reusable, parameterized bundle of resources |
Variables and Outputs
Variables parameterize a config; outputs surface computed values. Reference a variable with var.name and a resource attribute with aws_instance.web.public_ip.
variable "environment" {
type = string
description = "Deployment environment"
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
variable "instance_count" {
type = number
default = 2
}
output "web_ip" {
value = aws_instance.web.public_ip
description = "Public IP of the web server"
}
Set values via terraform.tfvars, -var flags, or TF_VAR_* environment variables.
The Core Workflow
Four commands drive Terraform. Always review the plan before applying.
# Download providers and initialize the backend
terraform init
# Preview changes without touching anything
terraform plan -out=tfplan
# Apply the reviewed plan
terraform apply tfplan
# Tear everything down
terraform destroy
| Command | What it does |
|---|---|
| init | Installs providers/modules, configures backend |
| plan | Shows the diff between config and real state |
| apply | Makes reality match the config |
| destroy | Removes all managed resources |
| fmt / validate | Canonically formats / syntactically checks config |
State
Terraform records what it manages in a state file (terraform.tfstate). State maps config resources to real-world IDs and caches attributes. For teams, store state in a remote backend so it is shared and locked.
terraform {
backend "s3" {
bucket = "acme-tf-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true # native S3 state locking (Terraform 1.10+)
}
}
State locking
Remote state locking prevents two people from running apply at once and corrupting state. S3 backends now support native lockfiles, so a separate DynamoDB table is no longer required.
Never edit state by hand or commit it to Git — it can contain secrets. Use CLI commands like terraform state list, state mv, and import for surgical changes.
Data Sources
A data source reads information from a provider without managing it — for example, looking up the latest AMI or an existing VPC.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-noble-24.04-amd64-server-*"]
}
}
count and for_each
Both create multiple instances of a resource. Use count for identical copies indexed by number; use for_each when instances are keyed by a stable identifier — this avoids destructive re-indexing when the list changes.
# count — indexed 0..N
resource "aws_instance" "worker" {
count = var.instance_count
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = { Name = "worker-${count.index}" }
}
# for_each — keyed by a set/map, stable across changes
resource "aws_s3_bucket" "bucket" {
for_each = toset(["logs", "assets", "backups"])
bucket = "acme-${each.key}"
}
Modules
A module is a directory of .tf files you call with inputs and read outputs from. Modules make infrastructure reusable and composable.
module "network" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}
# consume a module output
resource "aws_instance" "app" {
subnet_id = module.network.private_subnets[0]
}
Workspaces
Workspaces let one configuration hold multiple independent states — handy for quick per-branch or per-tester environments. For long-lived prod/staging separation, most teams prefer separate directories or backends over workspaces.
terraform workspace new staging
terraform workspace select staging
terraform workspace list
# reference in config
resource "aws_instance" "web" {
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
}
Lifecycle Meta-Arguments
The lifecycle block fine-tunes how Terraform treats a resource during changes.
resource "aws_db_instance" "main" {
# ... config ...
lifecycle {
create_before_destroy = true # avoid downtime on replacement
prevent_destroy = true # guard critical resources
ignore_changes = [tags["LastScanned"]]
}
}
Practice Exercises
- Write a config that provisions a single instance, run
fmt,validate,plan, thenapply. - Add an input variable with a validation rule and an output that exposes the instance IP.
- Configure a remote S3 backend with native state locking and migrate your local state to it.
- Rewrite a
count-based resource to usefor_eachand observe the plan diff. - Use a data source to look up the latest Ubuntu AMI and reference it in your instance.
- Extract your instance and networking into a local module and call it twice with different inputs.