Terraform Examples
Real-world examples of refactoring Terraform code to improve maintainability, reusability, and adherence to best practices.
Extract Reusable Module¶
Problem: Repeated resource definitions across environments¶
Before (separate files for each environment, lots of duplication):
## environments/dev/main.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "dev-vpc"
Environment = "dev"
ManagedBy = "Terraform"
}
}
resource "aws_subnet" "private" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "dev-private-${count.index + 1}"
Environment = "dev"
Type = "private"
}
}
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "dev-public-${count.index + 1}"
Environment = "dev"
Type = "public"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "dev-igw"
Environment = "dev"
}
}
## ... 100+ more lines of route tables, NAT gateways, etc.
## Same code repeated in staging/main.tf and production/main.tf
After (reusable module):
## modules/vpc/main.tf
locals {
common_tags = merge(
var.tags,
{
Environment = var.environment
ManagedBy = "Terraform"
}
)
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = var.enable_dns_hostnames
enable_dns_support = var.enable_dns_support
tags = merge(
local.common_tags,
{
Name = "${var.name}-vpc"
}
)
}
resource "aws_subnet" "private" {
for_each = var.private_subnet_cidrs
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
tags = merge(
local.common_tags,
{
Name = "${var.name}-private-${each.key}"
Type = "private"
}
)
}
resource "aws_subnet" "public" {
for_each = var.public_subnet_cidrs
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
map_public_ip_on_launch = var.map_public_ip_on_launch
tags = merge(
local.common_tags,
{
Name = "${var.name}-public-${each.key}"
Type = "public"
}
)
}
## modules/vpc/variables.tf
variable "name" {
description = "Name prefix for VPC resources"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
}
variable "private_subnet_cidrs" {
description = "Map of AZ to CIDR for private subnets"
type = map(string)
}
variable "public_subnet_cidrs" {
description = "Map of AZ to CIDR for public subnets"
type = map(string)
}
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 "map_public_ip_on_launch" {
description = "Map public IP on launch for public subnets"
type = bool
default = true
}
variable "tags" {
description = "Additional tags for resources"
type = map(string)
default = {}
}
## environments/dev/main.tf (now much simpler)
module "vpc" {
source = "../../modules/vpc"
name = "dev"
environment = "dev"
vpc_cidr = "10.0.0.0/16"
private_subnet_cidrs = {
"us-east-1a" = "10.0.1.0/24"
"us-east-1b" = "10.0.2.0/24"
"us-east-1c" = "10.0.3.0/24"
}
public_subnet_cidrs = {
"us-east-1a" = "10.0.10.0/24"
"us-east-1b" = "10.0.11.0/24"
"us-east-1c" = "10.0.12.0/24"
}
tags = {
Project = "my-project"
}
}
Improvements:
- ✅ DRY: VPC code defined once, reused across environments
- ✅ Maintainability: Bug fixes and updates in one place
- ✅ Consistency: All environments use same battle-tested module
- ✅ Reduced lines: ~400 lines → ~50 lines per environment
- ✅ Testable: Module can be tested independently
Use for_each Instead of count¶
Problem: Using count for dynamic resources causes recreation on reordering¶
Before (using count):
variable "users" {
description = "List of IAM users to create"
type = list(string)
default = ["alice", "bob", "charlie"]
}
resource "aws_iam_user" "users" {
count = length(var.users)
name = var.users[count.index]
tags = {
Environment = "production"
}
}
resource "aws_iam_access_key" "users" {
count = length(var.users)
user = aws_iam_user.users[count.index].name
}
## Problem: If you remove "bob" from the list:
## variable "users" {
## default = ["alice", "charlie"] # bob removed
## }
## Terraform will:
## 1. Destroy users[1] (bob) - GOOD
## 2. Destroy users[2] (charlie) - BAD!
## 3. Recreate users[1] (charlie) - BAD!
## Charlie's access keys get destroyed and recreated!
After (using for_each):
variable "users" {
description = "Set of IAM users to create"
type = set(string)
default = ["alice", "bob", "charlie"]
}
resource "aws_iam_user" "users" {
for_each = var.users
name = each.key
tags = {
Environment = "production"
}
}
resource "aws_iam_access_key" "users" {
for_each = var.users
user = aws_iam_user.users[each.key].name
}
## Now if you remove "bob" from the set:
## variable "users" {
## default = ["alice", "charlie"] # bob removed
## }
## Terraform will:
## 1. Destroy users["bob"] - GOOD
## Charlie is untouched because he's keyed by name, not index!
Even Better (using map for additional attributes):
variable "users" {
description = "Map of IAM users with their attributes"
type = map(object({
path = string
groups = list(string)
}))
default = {
alice = {
path = "/developers/"
groups = ["developers", "admins"]
}
bob = {
path = "/contractors/"
groups = ["developers"]
}
charlie = {
path = "/developers/"
groups = ["developers"]
}
}
}
resource "aws_iam_user" "users" {
for_each = var.users
name = each.key
path = each.value.path
tags = {
Environment = "production"
UserType = split("/", each.value.path)[1]
}
}
resource "aws_iam_user_group_membership" "users" {
for_each = var.users
user = aws_iam_user.users[each.key].name
groups = each.value.groups
}
Improvements:
- ✅ Stable resource addresses (keyed by name, not index)
- ✅ No unnecessary resource recreation
- ✅ Safer operations when adding/removing items
- ✅ More readable state (users["alice"] vs users[0])
- ✅ Can associate additional attributes per item
Apply Locals for DRY¶
Problem: Repeated expressions and hard-coded values¶
Before:
resource "aws_instance" "web" {
count = 3
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
tags = {
Name = "web-server-${count.index + 1}"
Environment = "production"
Project = "my-project"
CostCenter = "engineering"
ManagedBy = "Terraform"
Owner = "platform-team@example.com"
}
}
resource "aws_instance" "api" {
count = 2
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.large"
tags = {
Name = "api-server-${count.index + 1}"
Environment = "production"
Project = "my-project"
CostCenter = "engineering"
ManagedBy = "Terraform"
Owner = "platform-team@example.com"
}
}
resource "aws_db_instance" "main" {
identifier = "production-my-project-db"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 100
tags = {
Name = "main-database"
Environment = "production"
Project = "my-project"
CostCenter = "engineering"
ManagedBy = "Terraform"
Owner = "platform-team@example.com"
}
}
After:
locals {
# Environment configuration
environment = "production"
project = "my-project"
# Common naming prefix
name_prefix = "${local.environment}-${local.project}"
# AMI selection based on environment
amis = {
production = "ami-0c55b159cbfafe1f0"
staging = "ami-0abcdef123456789"
dev = "ami-0fedcba987654321"
}
selected_ami = local.amis[local.environment]
# Common tags applied to all resources
common_tags = {
Environment = local.environment
Project = local.project
CostCenter = "engineering"
ManagedBy = "Terraform"
Owner = "platform-team@example.com"
}
# Instance type selection based on environment
instance_types = {
production = {
web = "t3.medium"
api = "t3.large"
}
staging = {
web = "t3.small"
api = "t3.medium"
}
dev = {
web = "t3.micro"
api = "t3.small"
}
}
}
resource "aws_instance" "web" {
count = 3
ami = local.selected_ami
instance_type = local.instance_types[local.environment].web
tags = merge(
local.common_tags,
{
Name = "${local.name_prefix}-web-${count.index + 1}"
Role = "web-server"
}
)
}
resource "aws_instance" "api" {
count = 2
ami = local.selected_ami
instance_type = local.instance_types[local.environment].api
tags = merge(
local.common_tags,
{
Name = "${local.name_prefix}-api-${count.index + 1}"
Role = "api-server"
}
)
}
resource "aws_db_instance" "main" {
identifier = "${local.name_prefix}-db"
engine = "postgres"
instance_class = "db.${local.instance_types[local.environment].api}"
allocated_storage = 100
tags = merge(
local.common_tags,
{
Name = "main-database"
Role = "database"
}
)
}
Improvements:
- ✅ Single source of truth for repeated values
- ✅ Consistent naming across resources
- ✅ Easy to change environment-specific values
- ✅ Centralized tag management
- ✅ Reduced risk of typos and inconsistencies
Simplify Variable Structures¶
Problem: Complex, nested variable structures that are hard to use¶
Before:
variable "config" {
description = "Application configuration"
type = object({
app = object({
name = string
version = string
env = object({
vars = list(object({
key = string
value = string
}))
})
})
infra = object({
vpc = object({
cidr = string
azs = list(string)
})
compute = object({
instance_type = string
count = number
})
})
})
}
## Usage (very verbose and error-prone)
resource "aws_instance" "app" {
count = var.config.infra.compute.count
instance_type = var.config.infra.compute.instance_type
# Hard to work with nested env vars
user_data = templatefile("${path.module}/user-data.sh", {
app_name = var.config.app.name
env_vars = {
for env in var.config.app.env.vars :
env.key => env.value
}
})
tags = {
Name = "${var.config.app.name}-${count.index}"
Version = var.config.app.version
}
}
After:
## Flatten and simplify variable structure
variable "app_name" {
description = "Application name"
type = string
}
variable "app_version" {
description = "Application version"
type = string
}
variable "environment_variables" {
description = "Application environment variables"
type = map(string)
default = {}
}
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.medium"
}
variable "instance_count" {
description = "Number of instances to create"
type = number
default = 1
validation {
condition = var.instance_count > 0 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}
## Usage (much simpler and clearer)
resource "aws_instance" "app" {
count = var.instance_count
instance_type = var.instance_type
user_data = templatefile("${path.module}/user-data.sh", {
app_name = var.app_name
env_vars = var.environment_variables
})
tags = {
Name = "${var.app_name}-${count.index}"
Version = var.app_version
}
}
## terraform.tfvars (easier to read and write)
app_name = "my-application"
app_version = "1.2.3"
environment_variables = {
LOG_LEVEL = "info"
DATABASE_URL = "postgres://..."
API_KEY = "secret-key"
}
vpc_cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
instance_type = "t3.large"
instance_count = 3
Improvements:
- ✅ Flattened structure is easier to understand
- ✅ Each variable has a clear, single purpose
- ✅ Better IDE autocomplete support
- ✅ Easier to document with clear descriptions
- ✅ Validation rules can be applied per variable
- ✅ Simpler to override specific values
Improve Resource Naming¶
Problem: Inconsistent and unclear resource naming¶
Before:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_vpc" "vpc2" {
cidr_block = "172.16.0.0/16"
}
resource "aws_subnet" "subnet1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
resource "aws_subnet" "pub_sub" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.10.0/24"
}
resource "aws_instance" "server" {
ami = "ami-12345"
instance_type = "t3.medium"
subnet_id = aws_subnet.subnet1.id
}
resource "aws_instance" "web_server" {
ami = "ami-12345"
instance_type = "t3.medium"
subnet_id = aws_subnet.pub_sub.id
}
resource "aws_security_group" "sg" {
vpc_id = aws_vpc.main.id
}
## References are unclear:
## - What is "main" vs "vpc2"?
## - What's the difference between "subnet1" and "pub_sub"?
## - What does "server" do vs "web_server"?
After:
## Use descriptive, consistent naming patterns
## VPCs: describe purpose
resource "aws_vpc" "application" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "application-vpc"
}
}
resource "aws_vpc" "management" {
cidr_block = "172.16.0.0/16"
tags = {
Name = "management-vpc"
}
}
## Subnets: include type and purpose
resource "aws_subnet" "application_private" {
for_each = toset(["us-east-1a", "us-east-1b", "us-east-1c"])
vpc_id = aws_vpc.application.id
cidr_block = cidrsubnet(
aws_vpc.application.cidr_block,
8,
index(["us-east-1a", "us-east-1b", "us-east-1c"], each.key)
)
availability_zone = each.key
tags = {
Name = "application-private-${each.key}"
Type = "private"
}
}
resource "aws_subnet" "application_public" {
for_each = toset(["us-east-1a", "us-east-1b", "us-east-1c"])
vpc_id = aws_vpc.application.id
cidr_block = cidrsubnet(
aws_vpc.application.cidr_block,
8,
index(["us-east-1a", "us-east-1b", "us-east-1c"], each.key) + 10
)
availability_zone = each.key
map_public_ip_on_launch = true
tags = {
Name = "application-public-${each.key}"
Type = "public"
}
}
## Instances: describe role and tier
resource "aws_instance" "api_backend" {
count = 2
ami = data.aws_ami.amazon_linux_2.id
instance_type = "t3.medium"
subnet_id = aws_subnet.application_private["us-east-1a"].id
vpc_security_group_ids = [
aws_security_group.api_backend.id
]
tags = {
Name = "api-backend-${count.index + 1}"
Role = "backend"
Tier = "api"
}
}
resource "aws_instance" "web_frontend" {
count = 3
ami = data.aws_ami.amazon_linux_2.id
instance_type = "t3.medium"
subnet_id = aws_subnet.application_public["us-east-1a"].id
vpc_security_group_ids = [
aws_security_group.web_frontend.id
]
tags = {
Name = "web-frontend-${count.index + 1}"
Role = "frontend"
Tier = "web"
}
}
## Security groups: describe what they protect
resource "aws_security_group" "api_backend" {
name_prefix = "api-backend-"
description = "Security group for API backend instances"
vpc_id = aws_vpc.application.id
tags = {
Name = "api-backend-sg"
}
}
resource "aws_security_group" "web_frontend" {
name_prefix = "web-frontend-"
description = "Security group for web frontend instances"
vpc_id = aws_vpc.application.id
tags = {
Name = "web-frontend-sg"
}
}
## Now references are clear:
## - aws_vpc.application vs aws_vpc.management (purpose-based)
## - aws_subnet.application_private vs application_public (type-based)
## - aws_instance.api_backend vs web_frontend (role-based)
## - aws_security_group.api_backend (matches protected resource)
Naming Conventions Applied:
- VPCs: Use purpose (application, management, data)
- Subnets: Include type and purpose (application_private, application_public)
- Instances: Describe tier and role (api_backend, web_frontend, database_primary)
- Security Groups: Match the resource they protect
- Load Balancers: Include tier (application_alb, internal_nlb)
- Use this as resource name: Prefer when there's only one of a resource type
Improvements:
- ✅ Self-documenting infrastructure
- ✅ Easy to understand resource relationships
- ✅ Consistent naming patterns across the codebase
- ✅ Clear intent of each resource
- ✅ Easier to search and find resources
- ✅ Better for team collaboration
Resources¶
Tools¶
- terraform fmt: Format Terraform files
- tflint: Terraform linter
- terraform-docs: Generate documentation
- terrascan: Security scanning
- checkov: Policy as code scanning