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
Installing Terraform

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
The core Terraform workflow

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"
}
First Terraform configuration - 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
Running your first Terraform deployment

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
Remote state configuration

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}"
  })
}
Variables and outputs for reusable configurations

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
}
Complete AWS infrastructure with VPC, subnets, EC2, and ALB

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
}
Creating reusable Terraform modules

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 }}
Terraform CI/CD pipeline with plan and apply

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
Advanced Terraform tools

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:

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen February 1, 2025

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment