Clicking through cloud consoles doesn’t scale. Infrastructure as Code (IaC) lets you version, review, and automate your infrastructure just like application code.
Terraform has become the de facto standard. Here’s how to use it effectively.
The Basics# Terraform uses HCL (HashiCorp Configuration Language) to declare resources:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}
1
2
3
4
terraform init # Download providers
terraform plan # Preview changes
terraform apply # Create resources
terraform destroy # Tear down everything
State Management# Terraform tracks what it created in a state file. Never lose this file.
Remote State (Required for Teams)# Store state in S3 with locking via DynamoDB:
1
2
3
4
5
6
7
8
9
10
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Create the backend resources first:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# bootstrap/main.tf - run this once manually
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-terraform-state"
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket . terraform_state . id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
State Commands# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
# List resources in state
terraform state list
# Show details of a resource
terraform state show aws_instance.web
# Move a resource (rename without recreating)
terraform state mv aws_instance.web aws_instance.web_server
# Import existing resource into state
terraform import aws_instance.web i-1234567890abcdef0
# Remove from state (doesn't destroy resource)
terraform state rm aws_instance.web
Variables and Outputs# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# variables.tf
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "instance_count" {
description = "Number of instances"
type = number
default = 1
}
variable "allowed_cidrs" {
description = "Allowed CIDR blocks"
type = list ( string )
default = [ "10.0.0.0/8" ]
}
variable "tags" {
description = "Common tags"
type = map ( string )
default = {}
}
Set variables via:
1
2
3
4
5
6
7
8
# Command line
terraform apply -var= "environment=prod"
# File
terraform apply -var-file= "prod.tfvars"
# Environment variable
export TF_VAR_environment = prod
1
2
3
4
5
6
7
8
# prod.tfvars
environment = "prod"
instance_count = 3
allowed_cidrs = [ "10.0.0.0/8", "192.168.1.0/24" ]
tags = {
Team = "platform"
Project = "web-app"
}
Outputs# 1
2
3
4
5
6
7
8
9
10
# outputs.tf
output "instance_ip" {
description = "Public IP of the web server"
value = aws_instance . web . public_ip
}
output "instance_ids" {
description = "IDs of all instances"
value = aws_instance . web [ * ]. id
}
1
2
terraform output instance_ip
# 54.123.45.67
Modules# Reusable infrastructure components:
m e o n d v e v p s u p c i r t l c m v o 2 m v o r o m a m e / a a u / a a u o d a g a s i r t i r t n / i i i / n i p n i p m n n n . a u . a u e . g . t b t t b t n t t f l s f l s t f f e . e . s s t s t / . f . f t t f f Creating a Module# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var . cidr_block
tags = {
Name = "${var.name}-vpc"
}
}
resource "aws_subnet" "public" {
count = length ( var . public_subnets )
vpc_id = aws_vpc . main . id
cidr_block = var . public_subnets [ count . index ]
availability_zone = var . azs [ count . index ]
}
# modules/vpc/variables.tf
variable "name" {
type = string
}
variable "cidr_block" {
type = string
default = "10.0.0.0/16"
}
variable "public_subnets" {
type = list ( string )
}
variable "azs" {
type = list ( string )
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc . main . id
}
output "public_subnet_ids" {
value = aws_subnet . public [ * ]. id
}
Using a Module# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# environments/prod/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "prod"
cidr_block = "10.0.0.0/16"
public_subnets = [ "10.0.1.0/24", "10.0.2.0/24" ]
azs = [ "us-east-1a", "us-east-1b" ]
}
module "web_servers" {
source = "../../modules/ec2"
subnet_ids = module . vpc . public_subnet_ids
count = 2
}
Workspaces# Manage multiple environments with the same code:
1
2
3
4
terraform workspace new staging
terraform workspace new prod
terraform workspace list
terraform workspace select prod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Use workspace name in configuration
locals {
environment = terraform . workspace
instance_count = {
dev = 1
staging = 2
prod = 4
}
}
resource "aws_instance" "web" {
count = local . instance_count [ local . environment ]
instance_type = local.environment == "prod" ? "t3.medium" : "t3.micro"
# ...
}
Note : Many teams prefer separate directories over workspaces for clearer separation.
Data Sources# Reference existing resources:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Look up existing VPC
data "aws_vpc" "existing" {
filter {
name = "tag:Name"
values = [ "main-vpc" ]
}
}
# Look up latest AMI
data "aws_ami" "ubuntu" {
most_recent = true
owners = [ "099720109477" ] # Canonical
filter {
name = "name"
values = [ "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*" ]
}
}
resource "aws_instance" "web" {
ami = data . aws_ami . ubuntu . id
instance_type = "t3.micro"
subnet_id = data . aws_vpc . existing . id
}
Lifecycle Rules# Control when resources are created/destroyed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
resource "aws_instance" "web" {
ami = data . aws_ami . ubuntu . id
instance_type = "t3.micro"
lifecycle {
# Create new before destroying old
create_before_destroy = true
# Prevent accidental destruction
prevent_destroy = true
# Ignore changes to these attributes
ignore_changes = [
tags [ "LastModified" ],
user_data ,
]
}
}
Team Workflows# PR-Based Workflow# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# .github/workflows/terraform.yml
name : Terraform
on :
pull_request :
paths : [ 'terraform/**' ]
push :
branches : [ main]
paths : [ 'terraform/**' ]
jobs :
plan :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : hashicorp/setup-terraform@v3
- name : Terraform Init
run : terraform init
- name : Terraform Plan
run : terraform plan -out=tfplan
- name : Comment Plan
if : github.event_name == 'pull_request'
uses : actions/github-script@v7
with :
script : |
// Post plan output as PR comment
apply :
if : github.ref == 'refs/heads/main'
needs : plan
runs-on : ubuntu-latest
steps :
- name : Terraform Apply
run : terraform apply -auto-approve tfplan
Atlantis# Self-hosted PR automation:
1
2
3
4
5
6
7
8
# atlantis.yaml
version : 3
projects :
- dir : terraform/prod
workspace : default
autoplan :
when_modified : [ "*.tf" , "../modules/**" ]
enabled : true
Comment atlantis plan on PR to see changes, atlantis apply to deploy.
Common Patterns# Count vs For Each# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Count: when resources are identical
resource "aws_instance" "web" {
count = 3
ami = data . aws_ami . ubuntu . id
tags = { Name = "web-${count.index}" }
}
# For each: when resources have distinct configurations
variable "instances" {
default = {
web = "t3.micro"
api = "t3.small"
worker = "t3.medium"
}
}
resource "aws_instance" "app" {
for_each = var . instances
ami = data . aws_ami . ubuntu . id
instance_type = each . value
tags = { Name = each . key }
}
Dynamic Blocks# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
variable "ingress_rules" {
default = [
{ port = 80, cidr = "0.0.0.0/0" },
{ port = 443, cidr = "0.0.0.0/0" },
{ port = 22, cidr = "10.0.0.0/8" },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
dynamic "ingress" {
for_each = var . ingress_rules
content {
from_port = ingress . value . port
to_port = ingress . value . port
protocol = "tcp"
cidr_blocks = [ ingress . value . cidr ]
}
}
}
Best Practices# Version pin providers - Avoid surprise breaking changesUse remote state with locking - Prevent concurrent modificationsKeep state files small - Split into multiple state files by componentNever edit state manually - Use terraform state commandsReview plans carefully - Especially destroys and replacementsUse modules for reuse - Don’t copy-paste infrastructureTerraform turns infrastructure into code. Version it, review it, automate it—just like your application.