Terraform has become the de facto standard for Infrastructure as Code (IaC), enabling teams to provision and manage cloud resources declaratively across multiple providers. In this comprehensive guide, we’ll explore Terraform from basics to advanced patterns used in production environments.
Why Terraform?
Terraform stands out in the IaC landscape:
- Multi-Cloud Support: AWS, Azure, GCP, and 2000+ providers
- Declarative Syntax: Describe desired state, Terraform handles the rest
- State Management: Tracks infrastructure changes and dependencies
- Plan Before Apply: Preview changes before executing them
- Modular Design: Reusable modules for consistent infrastructure
- Large Community: Extensive registry and community support
- Open Source: Free and vendor-neutral
Installation & Setup
# Install Terraform on Linux
wget https://releases.hashicorp.com/terraform/1.6.0/terraform_1.6.0_linux_amd64.zip
unzip terraform_1.6.0_linux_amd64.zip
sudo mv terraform /usr/local/bin/
# Verify installation
terraform version
# Install on macOS
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Install via package manager (Ubuntu/Debian)
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /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
Getting Started: First Configuration
Let’s create a simple AWS infrastructure:
# main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = var.project_name
}
}
}
# VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# Public Subnets
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${var.availability_zones[count.index]}"
Type = "public"
}
}
# 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
}
tags = {
Name = "${var.project_name}-public-rt"
}
}
# Route Table Association
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Security Group
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Security group for web servers"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
}
}
# EC2 Instance
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux_2.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[0].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</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "${var.project_name}-web-server"
}
}
# Data source for latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux_2" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
Variables File
# variables.tf
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Environment name"
type = string
default = "development"
}
variable "project_name" {
description = "Project name"
type = string
default = "myapp"
}
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
Outputs File
# outputs.tf
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "Public subnet IDs"
value = aws_subnet.public[*].id
}
output "web_server_public_ip" {
description = "Public IP of web server"
value = aws_instance.web.public_ip
}
output "web_server_url" {
description = "URL to access web server"
value = "http://${aws_instance.web.public_ip}"
}
Running Terraform
# Initialize Terraform
terraform init
# Format code
terraform fmt -recursive
# Validate configuration
terraform validate
# Plan changes
terraform plan -out=tfplan
# Apply changes
terraform apply tfplan
# Show current state
terraform show
# Destroy infrastructure
terraform destroy
State Management: Remote Backend
Never store state files in version control! Use remote backends:
AWS S3 Backend
# backend.tf
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
# Optional: use role assumption
role_arn = "arn:aws:iam::ACCOUNT_ID:role/TerraformStateRole"
}
}
Create S3 bucket and DynamoDB table:
# Create S3 bucket for state
aws s3 mb s3://mycompany-terraform-state --region us-east-1
aws s3api put-bucket-versioning \
--bucket mycompany-terraform-state \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket mycompany-terraform-state \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}]
}'
# Create DynamoDB table for state locking
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
Azure Backend
# backend.tf
terraform {
backend "azurerm" {
resource_group_name = "terraform-state-rg"
storage_account_name = "tfstatestorage"
container_name = "tfstate"
key = "production.terraform.tfstate"
# Use managed identity for authentication
use_msi = true
}
}
Terraform Modules: Reusable Infrastructure
Module Structure
modules/
└── vpc/
├── main.tf
├── variables.tf
├── outputs.tf
└── README.md
VPC Module Example
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = var.enable_dns_hostnames
enable_dns_support = var.enable_dns_support
tags = merge(
var.tags,
{
Name = var.name
}
)
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = merge(
var.tags,
{
Name = "${var.name}-public-${count.index + 1}"
Type = "public"
}
)
}
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = merge(
var.tags,
{
Name = "${var.name}-private-${count.index + 1}"
Type = "private"
}
)
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(
var.tags,
{
Name = "${var.name}-igw"
}
)
}
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0
domain = "vpc"
tags = merge(
var.tags,
{
Name = "${var.name}-nat-eip-${count.index + 1}"
}
)
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(
var.tags,
{
Name = "${var.name}-nat-${count.index + 1}"
}
)
depends_on = [aws_internet_gateway.this]
}
# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for VPC resources"
type = string
}
variable "cidr_block" {
description = "CIDR block for VPC"
type = string
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
}
variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets"
type = list(string)
}
variable "enable_nat_gateway" {
description = "Enable NAT gateway for private subnets"
type = bool
default = true
}
variable "enable_dns_hostnames" {
description = "Enable DNS hostnames in VPC"
type = bool
default = true
}
variable "enable_dns_support" {
description = "Enable DNS support in VPC"
type = bool
default = true
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.this.id
}
output "vpc_cidr" {
description = "VPC CIDR block"
value = aws_vpc.this.cidr_block
}
output "public_subnet_ids" {
description = "Public subnet IDs"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "Private subnet IDs"
value = aws_subnet.private[*].id
}
output "nat_gateway_ids" {
description = "NAT gateway IDs"
value = aws_nat_gateway.this[*].id
}
Using the Module
# main.tf
module "vpc" {
source = "./modules/vpc"
name = "production"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
enable_nat_gateway = true
tags = {
Environment = "production"
ManagedBy = "Terraform"
}
}
# Use module outputs
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux_2.id
instance_type = "t3.micro"
subnet_id = module.vpc.private_subnet_ids[0]
tags = {
Name = "app-server"
}
}
Advanced Patterns
Workspaces for Multiple Environments
# Create workspaces
terraform workspace new development
terraform workspace new staging
terraform workspace new production
# List workspaces
terraform workspace list
# Switch workspace
terraform workspace select production
# Show current workspace
terraform workspace show
Use workspace in configuration:
locals {
environment = terraform.workspace
instance_counts = {
development = 1
staging = 2
production = 5
}
instance_count = local.instance_counts[local.environment]
}
resource "aws_instance" "app" {
count = local.instance_count
ami = data.aws_ami.amazon_linux_2.id
instance_type = local.environment == "production" ? "t3.large" : "t3.micro"
tags = {
Name = "app-${local.environment}-${count.index + 1}"
Environment = local.environment
}
}
Dynamic Blocks
variable "ingress_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = [
{
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP"
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS"
}
]
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Conditional Resources
variable "create_bastion" {
description = "Create bastion host"
type = bool
default = false
}
resource "aws_instance" "bastion" {
count = var.create_bastion ? 1 : 0
ami = data.aws_ami.amazon_linux_2.id
instance_type = "t3.micro"
subnet_id = aws_subnet.public[0].id
tags = {
Name = "bastion-host"
}
}
For Expressions
locals {
# Create map of subnet IDs by AZ
subnets_by_az = {
for subnet in aws_subnet.private :
subnet.availability_zone => subnet.id...
}
# Transform list
uppercase_tags = [
for tag in var.tags : upper(tag)
]
# Filter and transform
public_subnet_names = [
for subnet in aws_subnet.public :
subnet.tags.Name
if subnet.map_public_ip_on_launch
]
}
CI/CD Integration
GitHub Actions
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TF_VERSION: 1.6.0
AWS_REGION: us-east-1
jobs:
terraform:
name: Terraform Plan and Apply
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: $
aws-region: $
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: $
- name: Terraform Format
id: fmt
run: terraform fmt -check -recursive
continue-on-error: true
- name: Terraform Init
id: init
run: terraform init
- name: Terraform Validate
id: validate
run: terraform validate -no-color
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- name: Comment PR
uses: actions/github-script@v6
if: github.event_name == 'pull_request'
with:
script: |
const output = `#### Terraform Format and Style 🖌\`$\`
#### Terraform Initialization ⚙️\`$\`
#### Terraform Validation 🤖\`$\`
#### Terraform Plan 📖\`$\`
<details><summary>Show Plan</summary>
\`\`\`terraform
$
\`\`\`
</details>
*Pusher: @$, Action: \`$\`*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve tfplan
Azure DevOps Pipeline
# azure-pipelines.yml
trigger:
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
TF_VERSION: '1.6.0'
ARM_CLIENT_ID: $(clientId)
ARM_CLIENT_SECRET: $(clientSecret)
ARM_SUBSCRIPTION_ID: $(subscriptionId)
ARM_TENANT_ID: $(tenantId)
stages:
- stage: Validate
displayName: 'Validate Terraform'
jobs:
- job: Validate
steps:
- task: TerraformInstaller@0
inputs:
terraformVersion: $(TF_VERSION)
- task: TerraformTaskV4@4
displayName: 'Terraform Init'
inputs:
provider: 'azurerm'
command: 'init'
backendServiceArm: 'Azure-Service-Connection'
backendAzureRmResourceGroupName: 'terraform-state-rg'
backendAzureRmStorageAccountName: 'tfstatestorage'
backendAzureRmContainerName: 'tfstate'
backendAzureRmKey: 'terraform.tfstate'
- task: TerraformTaskV4@4
displayName: 'Terraform Validate'
inputs:
provider: 'azurerm'
command: 'validate'
- task: TerraformTaskV4@4
displayName: 'Terraform Plan'
inputs:
provider: 'azurerm'
command: 'plan'
commandOptions: '-out=tfplan'
environmentServiceNameAzureRM: 'Azure-Service-Connection'
- stage: Apply
displayName: 'Apply Terraform'
dependsOn: Validate
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: Apply
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- task: TerraformTaskV4@4
displayName: 'Terraform Apply'
inputs:
provider: 'azurerm'
command: 'apply'
commandOptions: 'tfplan'
environmentServiceNameAzureRM: 'Azure-Service-Connection'
Testing Terraform
Using Terratest (Go)
// test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVPCModule(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"name": "test-vpc",
"cidr_block": "10.0.0.0/16",
"availability_zones": []string{"us-east-1a", "us-east-1b"},
"public_subnet_cidrs": []string{"10.0.1.0/24", "10.0.2.0/24"},
"private_subnet_cidrs": []string{"10.0.11.0/24", "10.0.12.0/24"},
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
publicSubnetIds := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
assert.Equal(t, 2, len(publicSubnetIds))
}
Best Practices
✅ Use Remote State: Never commit terraform.tfstate
to version control
✅ Enable State Locking: Prevent concurrent modifications
✅ Use Modules: Create reusable, tested infrastructure components
✅ Version Everything: Pin provider versions and module versions
✅ Separate Environments: Use workspaces or separate state files
✅ Use Variables: Never hardcode values
✅ Document Modules: Include README with examples
✅ Run terraform fmt: Keep code formatted consistently
✅ Use Pre-commit Hooks: Validate before committing
✅ Implement CI/CD: Automate plan and apply
✅ Use Data Sources: Reference existing infrastructure
✅ Tag Resources: Apply consistent tagging for cost tracking
Troubleshooting
# Enable debug logging
export TF_LOG=DEBUG
export TF_LOG_PATH=./terraform.log
# Show detailed state
terraform show -json
# List resources in state
terraform state list
# Show specific resource
terraform state show aws_instance.web
# Remove resource from state (without destroying)
terraform state rm aws_instance.web
# Import existing resource
terraform import aws_instance.web i-1234567890abcdef0
# Refresh state
terraform refresh
# Unlock state (if locked by error)
terraform force-unlock LOCK_ID
# Replace resource (taint alternative in Terraform 1.5+)
terraform apply -replace="aws_instance.web"
Conclusion
Terraform provides a powerful, declarative approach to infrastructure management. By following these best practices—using modules, remote state, proper CI/CD integration, and thorough testing—you’ll build maintainable, scalable infrastructure that can evolve with your organization’s needs.
Resources
Have questions about Terraform? Let’s discuss in the comments!