Terraform: Infrastructure as Code Best Practices
Mastering Terraform for cloud infrastructure management. Module design, state management, workspaces, and patterns for multi-environment deployments.
Terraform: Infrastructure as Code Best Practices
Terraform enables declarative infrastructure management across cloud providers. Writing maintainable, scalable Terraform configurations requires understanding module design, state management, and team collaboration patterns.
Terraform Fundamentals
Project Structure
terraform-project/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ │ └── ...
│ └── prod/
│ └── ...
├── modules/
│ ├── networking/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ ├── compute/
│ ├── database/
│ └── monitoring/
└── global/
├── iam/
└── dns/Module Design Principles
# modules/networking/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Main VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = "${var.project}-${var.environment}-vpc"
})
}
# 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 = merge(var.tags, {
Name = "${var.project}-${var.environment}-public-${var.availability_zones[count.index]}"
Type = "public"
})
}
# Private Subnets
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = var.availability_zones[count.index]
tags = merge(var.tags, {
Name = "${var.project}-${var.environment}-private-${var.availability_zones[count.index]}"
Type = "private"
})
}
# modules/networking/variables.tf
variable "project" {
description = "Project name"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
}
variable "tags" {
description = "Tags to apply to resources"
type = map(string)
default = {}
}
# modules/networking/outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of private subnets"
value = aws_subnet.private[*].id
}State Management
Remote State Configuration
# backend.tf
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "environments/prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
# Assume role for state access
role_arn = "arn:aws:iam::123456789:role/TerraformStateAccess"
}
}
# State bucket and lock table (bootstrap separately)
resource "aws_s3_bucket" "terraform_state" {
bucket = "company-terraform-state"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.terraform_state.arn
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "terraform_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}Data Sources for State References
# Reference state from another configuration
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "company-terraform-state"
key = "networking/terraform.tfstate"
region = "eu-west-1"
role_arn = "arn:aws:iam::123456789:role/TerraformStateAccess"
}
}
# Use outputs from networking state
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.medium"
subnet_id = data.terraform_remote_state.networking.outputs.private_subnet_ids[0]
vpc_security_group_ids = [
data.terraform_remote_state.networking.outputs.app_security_group_id
]
}Environment Management
Using Workspaces
# Create and switch workspaces
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select prod
terraform plan
terraform apply# Using workspace in configuration
locals {
environment = terraform.workspace
instance_count = {
dev = 1
staging = 2
prod = 3
}
instance_type = {
dev = "t3.small"
staging = "t3.medium"
prod = "t3.large"
}
}
resource "aws_instance" "app" {
count = local.instance_count[local.environment]
instance_type = local.instance_type[local.environment]
# ...
}Environment-Specific Variables
# environments/prod/terraform.tfvars
project = "myapp"
environment = "prod"
region = "eu-west-1"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
instance_type = "t3.large"
instance_count = 3
min_capacity = 2
max_capacity = 10
enable_deletion_protection = true
enable_multi_az = true
backup_retention_period = 30
tags = {
Project = "myapp"
Environment = "prod"
ManagedBy = "terraform"
CostCenter = "engineering"
}Advanced Patterns
Dynamic Blocks
variable "ingress_rules" {
description = "List of ingress rules"
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = []
}
resource "aws_security_group" "main" {
name = "${var.project}-sg"
description = "Security group for ${var.project}"
vpc_id = var.vpc_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"]
}
}For Each vs Count
# Using for_each with map (preferred for named resources)
variable "buckets" {
type = map(object({
versioning = bool
lifecycle_days = number
}))
default = {
logs = {
versioning = false
lifecycle_days = 30
}
artifacts = {
versioning = true
lifecycle_days = 90
}
backups = {
versioning = true
lifecycle_days = 365
}
}
}
resource "aws_s3_bucket" "buckets" {
for_each = var.buckets
bucket = "${var.project}-${each.key}-${var.environment}"
tags = merge(var.tags, {
Name = "${var.project}-${each.key}"
})
}
resource "aws_s3_bucket_versioning" "buckets" {
for_each = { for k, v in var.buckets : k => v if v.versioning }
bucket = aws_s3_bucket.buckets[each.key].id
versioning_configuration {
status = "Enabled"
}
}
# Referencing for_each resources
output "bucket_arns" {
value = { for k, v in aws_s3_bucket.buckets : k => v.arn }
}Custom Validation
variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
validation {
condition = tonumber(split("/", var.cidr_block)[1]) >= 16 && tonumber(split("/", var.cidr_block)[1]) <= 24
error_message = "CIDR prefix must be between /16 and /24."
}
}Testing Terraform
Terratest Example
// 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/networking",
Vars: map[string]interface{}{
"project": "test",
"environment": "test",
"vpc_cidr": "10.99.0.0/16",
"availability_zones": []string{"eu-west-1a", "eu-west-1b"},
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
publicSubnetIds := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
privateSubnetIds := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
assert.NotEmpty(t, vpcId)
assert.Len(t, publicSubnetIds, 2)
assert.Len(t, privateSubnetIds, 2)
}Pre-Commit Hooks
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.83.5
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
args:
- --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
- id: terraform_docs
args:
- --args=--config=.terraform-docs.yml
- id: terraform_checkov
args:
- --args=--quiet
- --args=--skip-check CKV_AWS_144,CKV_AWS_145
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflictCI/CD Integration
GitHub Actions Workflow
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TF_VERSION: "1.6.0"
AWS_REGION: "eu-west-1"
jobs:
validate:
name: Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Format
run: terraform fmt -check -recursive
- name: Terraform Init
run: terraform init -backend=false
working-directory: environments/prod
- name: Terraform Validate
run: terraform validate
working-directory: environments/prod
plan:
name: Plan
runs-on: ubuntu-latest
needs: validate
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform init
working-directory: environments/prod
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
working-directory: environments/prod
- name: Comment Plan
uses: actions/github-script@v7
with:
script: |
const output = `#### Terraform Plan
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\`
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
apply:
name: Apply
runs-on: ubuntu-latest
needs: validate
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform init
working-directory: environments/prod
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: environments/prodKey Takeaways
-
Module everything: Reusable modules reduce duplication and errors
-
Remote state: Always use remote state with locking for team collaboration
-
Environment isolation: Separate state files per environment
-
Validate inputs: Use variable validation to catch errors early
-
Test infrastructure: Use Terratest or similar for automated testing
-
Pre-commit hooks: Catch formatting and validation issues before commit
-
Document modules: Use terraform-docs for automatic documentation
-
Version control: Pin provider versions and use module versioning
Terraform enables consistent, reproducible infrastructure. Invest in module design and CI/CD integration to scale infrastructure management across teams.