Clicking buttons in a cloud console is error-prone and impossible to reproduce. Infrastructure as Code (IaC) lets you define your entire cloud infrastructure—servers, databases, networks, load balancers—in configuration files that can be versioned, reviewed, and automated. Terraform is the standard for IaC, working across AWS, Azure, GCP, and hundreds of other providers. In this guide, you'll learn to provision production-ready infrastructure with Terraform.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Understand Infrastructure as Code concepts and benefits
- Write Terraform configuration files (HCL)
- Manage Terraform state (local and remote)
- Create reusable modules
- Provision AWS resources (EC2, S3, VPC)
- Use variables and outputs for flexible configurations
- Implement CI/CD for Terraform
2. Why Infrastructure as Code?
Real-world scenario without IaC: Your team manually creates 50 AWS resources through the console over 3 months. A developer accidentally deletes a production database. No one knows exactly what existed, how it was configured, or how to recreate it. Recovery takes 3 days.
With Terraform: Every resource is defined in code. Version control shows exactly who changed what and when. Recreating the entire infrastructure takes one command: terraform apply. Recovery takes 10 minutes.
Key benefits:
- ✅ Version control for infrastructure (git history)
- ✅ Code reviews for changes (pull requests)
- ✅ Reproducible environments (dev = staging = prod)
- ✅ Drift detection (know when manual changes happen)
- ✅ Disaster recovery (recreate everything from code)
3. Core Concepts
Installing Terraform
# Ubuntu/Debian
wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# macOS
brew install terraform
# Windows (choco)
choco install terraform
# Verify installation
terraform --version
# Enable tab completion
echo 'complete -C /usr/bin/terraform terraform' >> ~/.bashrc
source ~/.bashrc
Terraform Workflow
# 1. Write configuration files (*.tf)
# 2. Initialize working directory
terraform init
# 3. Preview changes
terraform plan
# 4. Apply changes
terraform apply
# 5. Destroy infrastructure
terraform destroy
Your First Terraform Configuration
# main.tf
# Provider configuration
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Configure AWS provider
provider "aws" {
region = "us-east-1"
}
# Create an S3 bucket
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-unique-bucket-name-${random_string.suffix.result}"
tags = {
Name = "My Bucket"
Environment = "Dev"
ManagedBy = "Terraform"
}
}
# Generate random suffix for unique bucket name
resource "random_string" "suffix" {
length = 8
special = false
upper = false
}
# Output the bucket name
output "bucket_name" {
value = aws_s3_bucket.my_bucket.bucket
description = "The name of the created S3 bucket"
}
# Set AWS credentials (never hardcode!)
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_DEFAULT_REGION="us-east-1"
# Or use AWS CLI profiles
aws configure --profile myprofile
# Initialize Terraform
terraform init
# Format files automatically
terraform fmt
# Validate configuration
terraform validate
# Plan and apply
terraform plan
terraform apply -auto-approve
# Check what was created
terraform state list
# Destroy when done
terraform destroy -auto-approve
Terraform State
# backend.tf - Remote state storage
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "project/dev/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks" # State locking
}
}
# State commands
# terraform state list # List all resources
# terraform state show aws_s3_bucket.bucket # Show specific resource
# terraform state mv aws_s3_bucket.old aws_s3_bucket.new # Move resource
# terraform state rm aws_s3_bucket.to_remove # Remove from state
# terraform import aws_s3_bucket.existing bucket-name # Import existing resource
Variables and Outputs
# variables.tf
variable "environment" {
description = "Environment name"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
}
variable "tags" {
description = "Common tags for all resources"
type = map(string)
default = {
Environment = "dev"
ManagedBy = "Terraform"
Project = "DevOps Demo"
}
}
# outputs.tf
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = aws_subnet.public[*].id
}
output "app_url" {
description = "Application URL"
value = "http://${aws_lb.app.dns_name}"
}
# terraform.tfvars (for specific values, never commit to git!)
# environment = "production"
# instance_type = "t3.large"
# Use variables
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
tags = merge(var.tags, {
Name = "web-${var.environment}"
})
}
Complete AWS Infrastructure Example
# Complete VPC with public and private subnets
# Data source for latest AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
# VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = "vpc-${var.environment}"
})
}
# Internet Gateway
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = merge(var.tags, {
Name = "igw-${var.environment}"
})
}
# Public subnets
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "public-${var.environment}-${count.index + 1}"
Type = "Public"
})
}
# Private subnets
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = merge(var.tags, {
Name = "private-${var.environment}-${count.index + 1}"
Type = "Private"
})
}
# Route table for public subnets
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = merge(var.tags, {
Name = "rt-public-${var.environment}"
})
}
# Associate public subnets with route table
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Security group for web servers
resource "aws_security_group" "web" {
name = "web-sg-${var.environment}"
description = "Allow HTTP and SSH traffic"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSH from anywhere"
from_port = 22
to_port = 22
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"]
}
tags = merge(var.tags, {
Name = "web-sg-${var.environment}"
})
}
# EC2 instance with user data
resource "aws_instance" "web" {
count = var.instance_count
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[count.index % 2].id
vpc_security_group_ids = [aws_security_group.web.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Terraform! Instance ${count.index + 1}</h1>" > /var/www/html/index.html
EOF
tags = merge(var.tags, {
Name = "web-${var.environment}-${count.index + 1}"
})
}
# Application Load Balancer
resource "aws_lb" "app" {
name = "alb-${var.environment}"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.web.id]
subnets = aws_subnet.public[*].id
tags = merge(var.tags, {
Name = "alb-${var.environment}"
})
}
# Target group
resource "aws_lb_target_group" "web" {
name = "tg-web-${var.environment}"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
healthy_threshold = 2
unhealthy_threshold = 2
timeout = 5
interval = 30
path = "/"
}
tags = merge(var.tags, {
Name = "tg-web-${var.environment}"
})
}
# Attach instances to target group
resource "aws_lb_target_group_attachment" "web" {
count = var.instance_count
target_group_arn = aws_lb_target_group.web.arn
target_id = aws_instance.web[count.index].id
port = 80
}
# Listener
resource "aws_lb_listener" "web" {
load_balancer_arn = aws_lb.app.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}
# Outputs
output "load_balancer_dns" {
description = "Application Load Balancer DNS name"
value = aws_lb.app.dns_name
}
output "instance_ips" {
description = "Public IP addresses of web instances"
value = aws_instance.web[*].public_ip
}
Creating Reusable Modules
# modules/vpc/main.tf
variable "vpc_cidr" {}
variable "environment" {}
variable "tags" {}
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = "vpc-${var.environment}"
})
}
output "vpc_id" {
value = aws_vpc.this.id
}
# modules/ec2/main.tf
variable "instance_type" { default = "t3.micro" }
variable "subnet_id" {}
variable "security_group_ids" {}
variable "environment" {}
resource "aws_instance" "this" {
count = var.instance_count
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = var.security_group_ids
tags = {
Name = "${var.environment}-instance-${count.index + 1}"
}
}
# main.tf using modules
module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = var.environment
tags = var.tags
}
module "web_instances" {
source = "./modules/ec2"
instance_type = var.instance_type
subnet_id = module.vpc.public_subnet_ids[0]
security_group_ids = [aws_security_group.web.id]
environment = var.environment
instance_count = var.instance_count
}
4. Terraform in CI/CD
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
pull_request:
paths:
- 'terraform/**'
push:
branches: [main]
paths:
- 'terraform/**'
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./terraform
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.6.0
- name: Terraform Format
run: terraform fmt -check
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan -out=tfplan
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Upload Plan
uses: actions/upload-artifact@v4
with:
name: tfplan
path: terraform/tfplan
terraform-apply:
needs: terraform
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- name: Download Plan
uses: actions/download-artifact@v4
with:
name: tfplan
path: terraform/
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Terraform Apply
run: terraform apply tfplan
working-directory: ./terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
5. Best Practices
# Security scanning with tfsec
tfsec .
# Compliance checking with checkov
checkov -d .
# Cost estimation
infracost breakdown --path .
# Generate documentation
terraform-docs markdown . > README.md
# Workspace management
terraform workspace new dev
terraform workspace select dev
terraform workspace list
6. Common Errors & Solutions
7. Summary Checklist
8. Next Steps
Advanced Terraform topics to learn next:
- Terragrunt: Keep your Terraform code DRY across environments
- Terraform Cloud: Remote operations and team collaboration
- CDKTF: Define infrastructure with TypeScript/Python
- Cross-Cloud: Deploy across AWS, Azure, and GCP simultaneously
Practice resources:
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment