Clicking through cloud consoles doesn’t scale. Terraform lets you define infrastructure in code, track changes in git, and deploy the same environment repeatedly.

Core Concepts

  • Provider: Plugin for a platform (AWS, GCP, Azure, etc.)
  • Resource: A thing to create (server, database, DNS record)
  • State: Terraform’s record of what exists
  • Plan: Preview of changes before applying
  • Apply: Make the changes happen

Basic Workflow

1
2
3
4
terraform init      # Download providers
terraform plan      # Preview changes
terraform apply     # Create/update resources
terraform destroy   # Tear everything down

First Configuration

 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
# main.tf

# Configure the AWS provider
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

# Create an EC2 instance
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  tags = {
    Name = "WebServer"
  }
}
1
2
3
terraform init    # Downloads AWS provider
terraform plan    # Shows: 1 to add
terraform apply   # Creates the instance

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
25
26
27
28
29
# variables.tf
variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "dev"
}

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

variable "allowed_ips" {
  description = "IPs allowed to SSH"
  type        = list(string)
  default     = ["0.0.0.0/0"]
}

# main.tf - use variables
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = var.instance_type

  tags = {
    Name        = "web-${var.environment}"
    Environment = var.environment
  }
}

Setting Variables

1
2
3
4
5
6
7
8
9
# Command line
terraform apply -var="environment=prod"

# File (terraform.tfvars)
environment   = "prod"
instance_type = "t3.small"

# Environment variables
export TF_VAR_environment="prod"

Outputs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# outputs.tf
output "instance_ip" {
  description = "Public IP of the instance"
  value       = aws_instance.web.public_ip
}

output "instance_id" {
  description = "Instance ID"
  value       = aws_instance.web.id
}

After apply:

1
2
terraform output instance_ip
# 54.123.45.67

Data Sources

Query existing resources:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Find latest Ubuntu 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"
}

Resource Dependencies

Terraform figures out order automatically:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# VPC first
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

# Then subnet (references VPC)
resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}

# Then instance (references subnet)
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public.id
}

Explicit dependency when needed:

1
2
3
4
resource "aws_instance" "web" {
  # ...
  depends_on = [aws_iam_role_policy.s3_access]
}

Modules

Reusable infrastructure components:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# modules/web-server/main.tf
variable "instance_type" {
  default = "t3.micro"
}

variable "environment" {}

resource "aws_instance" "this" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type
  
  tags = {
    Environment = var.environment
  }
}

output "public_ip" {
  value = aws_instance.this.public_ip
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# main.tf - use the module
module "web_prod" {
  source        = "./modules/web-server"
  environment   = "prod"
  instance_type = "t3.small"
}

module "web_staging" {
  source      = "./modules/web-server"
  environment = "staging"
}

output "prod_ip" {
  value = module.web_prod.public_ip
}

State Management

Terraform tracks what it created in state files.

Remote State (Required for Teams)

 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"
  }
}

State Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# List resources in state
terraform state list

# Show resource details
terraform state show aws_instance.web

# Remove from state (doesn't destroy)
terraform state rm aws_instance.web

# Import existing resource
terraform import aws_instance.web i-1234567890abcdef0

# Move resource (rename)
terraform state mv aws_instance.old aws_instance.new

Workspaces

Multiple environments with same code:

1
2
3
4
terraform workspace new staging
terraform workspace new prod
terraform workspace select staging
terraform workspace list
1
2
3
4
5
6
7
8
# Use workspace name in config
resource "aws_instance" "web" {
  instance_type = terraform.workspace == "prod" ? "t3.small" : "t3.micro"
  
  tags = {
    Environment = terraform.workspace
  }
}

Loops and Conditionals

count

1
2
3
4
5
6
7
8
9
resource "aws_instance" "web" {
  count         = 3
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  tags = {
    Name = "web-${count.index + 1}"
  }
}

for_each

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
variable "instances" {
  default = {
    web1 = "t3.micro"
    web2 = "t3.small"
    api  = "t3.medium"
  }
}

resource "aws_instance" "servers" {
  for_each      = var.instances
  ami           = data.aws_ami.ubuntu.id
  instance_type = each.value

  tags = {
    Name = each.key
  }
}

Conditionals

1
2
3
4
5
6
7
8
# Create only if condition is true
resource "aws_instance" "bastion" {
  count = var.create_bastion ? 1 : 0
  # ...
}

# Conditional value
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

Lifecycle Rules

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
resource "aws_instance" "web" {
  # ...

  lifecycle {
    # Don't destroy before creating replacement
    create_before_destroy = true
    
    # Ignore changes to tags
    ignore_changes = [tags]
    
    # Never destroy this resource
    prevent_destroy = true
  }
}

Practical Example

Complete web infrastructure:

 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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = { Name = "${var.project}-vpc" }
}

# Public subnet
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

  tags = { Name = "${var.project}-public" }
}

# Internet gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

# Route table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

# Security group
resource "aws_security_group" "web" {
  name   = "${var.project}-web-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# EC2 instance
resource "aws_instance" "web" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  tags = {
    Name        = "${var.project}-web"
    Environment = var.environment
  }
}

Best Practices

  1. Never commit state files — use remote state
  2. Use variables — no hardcoded values
  3. Format codeterraform fmt
  4. Validate before applyterraform validate
  5. Use modules — DRY infrastructure
  6. Lock provider versions — reproducible builds
  7. Review plans carefully — especially destroys

Quick Reference

1
2
3
4
5
6
7
8
9
terraform init          # Initialize
terraform plan          # Preview
terraform apply         # Deploy
terraform destroy       # Tear down
terraform fmt           # Format code
terraform validate      # Check syntax
terraform output        # Show outputs
terraform state list    # List resources
terraform import        # Import existing

Terraform turns infrastructure into code you can review, version, and collaborate on. Start small, build modules, and watch your deployments become reproducible.


Try It Yourself

Want to practice Terraform? These cloud platforms offer free credits to get started:

  • DigitalOcean — $200 free credit for 60 days. Simple, developer-friendly VPS hosting.
  • Vultr — $100 free credit. Great for global deployments.
  • AWS Free Tier — 12 months of free services including EC2, S3, and RDS.

Disclosure: Some links are affiliate links. You get free credits, I get a small commission. Win-win.