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

Input Variables

 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:

meondvevpsupcirtlcmvo2mvoromame/aau/aauodagasirtirtn/iii/nipnipmnnn.au.aue.g.tbttbtnttflsflstffe.e.sstst/.f.fttff

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

  1. Version pin providers - Avoid surprise breaking changes
  2. Use remote state with locking - Prevent concurrent modifications
  3. Keep state files small - Split into multiple state files by component
  4. Never edit state manually - Use terraform state commands
  5. Review plans carefully - Especially destroys and replacements
  6. Use modules for reuse - Don’t copy-paste infrastructure

Terraform turns infrastructure into code. Version it, review it, automate it—just like your application.