Infrastructure as Code: Terraform Best Practices for 2024
Infrastructure as Code (IaC) has become the cornerstone of modern DevOps practices. Terraform, with its declarative syntax and multi-cloud support, is the most popular IaC tool. Let’s explore best practices that will make your Terraform code maintainable, secure, and scalable.
Project Structure
A well-organized Terraform project is crucial for long-term maintainability. Here’s a recommended structure:
terraform/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── production/
├── modules/
│ ├── networking/
│ ├── compute/
│ └── database/
└── global/
└── state-backend/
Remote State Management
Never store your Terraform state locally in production. Use remote backends for collaboration and state locking.
S3 Backend Configuration
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
# Enable versioning for state recovery
versioning = true
}
}
State Locking with DynamoDB
Create a DynamoDB table for state locking:
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "Terraform State Locks"
Environment = "global"
}
}
Writing Reusable Modules
Modules are the building blocks of scalable Terraform configurations.
Module Example: VPC
# modules/networking/vpc/main.tf
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)
}
locals {
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(
local.common_tags,
{
Name = "${var.environment}-vpc"
}
)
}
resource "aws_subnet" "private" {
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]
tags = merge(
local.common_tags,
{
Name = "${var.environment}-private-${var.availability_zones[count.index]}"
Type = "private"
}
)
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "IDs of private subnets"
value = aws_subnet.private[*].id
}
Using the Module
# environments/production/main.tf
module "vpc" {
source = "../../modules/networking/vpc"
environment = "production"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
Security Best Practices
1. Never Hardcode Secrets
# ❌ Bad
resource "aws_db_instance" "main" {
password = "SuperSecret123!"
}
# ✅ Good - Use AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "production/db/password"
}
resource "aws_db_instance" "main" {
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
2. Use Variables and .tfvars Files
# variables.tf
variable "db_password" {
description = "Database password"
type = string
sensitive = true
}
# terraform.tfvars (add to .gitignore!)
db_password = "actual-secret-here"
3. Implement Least Privilege
resource "aws_iam_role_policy" "app" {
name = "app-policy"
role = aws_iam_role.app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject"
]
Resource = "${aws_s3_bucket.app.arn}/*"
}
]
})
}
Terraform Workflow Best Practices
1. Use Workspaces for Environments
terraform workspace new production
terraform workspace new staging
terraform workspace select production
2. Always Run Plan Before Apply
# Generate and save a plan
terraform plan -out=tfplan
# Review the plan
terraform show tfplan
# Apply the exact plan
terraform apply tfplan
3. Use Pre-commit Hooks
Install terraform-docs and tflint:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.83.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
- id: terraform_docs
4. Implement CI/CD
Example GitHub Actions workflow:
name: Terraform
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- 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
Advanced Patterns
Dynamic Blocks
variable "ingress_rules" {
type = list(object({
port = number
cidr_blocks = list(string)
}))
}
resource "aws_security_group" "main" {
name = "dynamic-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
}
}
}
Conditional Resources
variable "create_database" {
type = bool
default = true
}
resource "aws_db_instance" "main" {
count = var.create_database ? 1 : 0
# resource configuration
}
Conclusion
Following these Terraform best practices will help you:
- Maintain: Well-structured, modular code is easier to update
- Scale: Reusable modules enable rapid infrastructure growth
- Secure: Proper secret management and least privilege principles
- Collaborate: Remote state and locking enable team workflows
- Audit: Version control and state history provide complete audit trails
Remember: Infrastructure as Code is not just about automation—it’s about bringing software engineering practices to infrastructure management. Treat your Terraform code with the same care you’d give to application code.
Start implementing these practices incrementally, and you’ll build a robust, maintainable infrastructure foundation for your organization.