Terraform has become the industry standard for infrastructure as code (IaC), allowing teams to provision and manage cloud resources through declarative configuration files. However, as your infrastructure grows, maintaining Terraform code can become challenging without following proper practices.
In this guide, you'll learn practical, battle-tested best practices for organizing, writing, and managing Terraform code that scales with your infrastructure needs.
Use a Consistent Directory Structure
Organize your Terraform code with a logical directory structure to enhance maintainability:
terraform-project/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── production/
├── modules/
│ ├── networking/
│ ├── compute/
│ └── database/
└── .gitignore
This structure separates your environments from your reusable modules, allowing for clear organization and consistent deployments across environments.
Split Resources into Logical Modules
Instead of defining all resources in a single file, organize them into logical modules:
# modules/networking/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
tags = {
Name = "${var.project}-vpc"
Environment = var.environment
Terraform = "true"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.project}-public-subnet-${count.index}"
Environment = var.environment
Terraform = "true"
}
}
# Additional networking resources...
Each module should represent a logical component of your infrastructure and handle a specific concern. This approach makes your code more maintainable and reusable.
Use Variables for Configuration
Define variables for all configurable parameters to make your modules flexible:
# modules/database/variables.tf
variable "instance_class" {
description = "The instance type of the RDS instance"
type = string
default = "db.t3.micro"
}
variable "allocated_storage" {
description = "The allocated storage in gigabytes"
type = number
default = 20
}
variable "engine_version" {
description = "The engine version to use"
type = string
default = "13.7"
}
variable "database_name" {
description = "The name of the database to create"
type = string
}
variable "environment" {
description = "The deployment environment (dev, staging, prod)"
type = string
}
Always include a description, type, and (when appropriate) a default value for each variable. This documentation helps other team members understand the purpose and requirements of each parameter.
Implement Consistent Naming and Tagging Conventions
Consistent naming and tagging greatly improve resource management and organization:
# Create a standardized tagging function
locals {
common_tags = {
Project = var.project_name
Environment = var.environment
Owner = var.team
ManagedBy = "Terraform"
}
}
resource "aws_s3_bucket" "logs" {
bucket = "${var.project_name}-${var.environment}-logs"
tags = merge(local.common_tags, {
Name = "${var.project_name}-${var.environment}-logs"
Description = "Bucket for application logs"
})
}
Define a standard naming pattern for each resource type and consistently apply it throughout your infrastructure. This makes resources easily identifiable and simplifies operations and troubleshooting.
Use Data Sources to Reference External Resources
Use data sources instead of hardcoding values when referencing existing resources:
# Instead of hardcoding AMI IDs
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical's AWS account ID
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
# Other instance configuration...
}
This makes your code more flexible and easier to maintain as external resources change over time.
Keep Your Backend Configuration Consistent
Store your Terraform state in a remote backend with proper locking to enable team collaboration:
# environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "company-terraform-states"
key = "dev/terraform.tfstate"
region = "us-west-2"
encrypt = true
dynamodb_table = "terraform-state-locks"
}
}
Use a consistent pattern for state file paths across environments. For example:
dev/terraform.tfstate
staging/terraform.tfstate
production/terraform.tfstate
Version Your Providers and Modules
Always specify versions for providers and modules to ensure reproducible infrastructure:
terraform {
required_version = ">= 1.0.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.16.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 3.18.0"
}
}
}
For modules, specify versions in the source attribute:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "3.14.0"
# Module parameters...
}
This prevents unexpected changes when new provider or module versions are released.
Use Loops and Conditionals for DRY Code
Use Terraform's for_each
, count
, and conditional expressions to avoid repetitive code:
# Create multiple similar resources
resource "aws_security_group_rule" "ingress" {
for_each = {
http = { port = 80, cidr = ["0.0.0.0/0"] }
https = { port = 443, cidr = ["0.0.0.0/0"] }
ssh = { port = 22, cidr = ["10.0.0.0/8"] }
}
type = "ingress"
security_group_id = aws_security_group.web.id
from_port = each.value.port
to_port = each.value.port
protocol = "tcp"
cidr_blocks = each.value.cidr
description = "Allow ${each.key} traffic"
}
# Conditional resource creation
resource "aws_route53_record" "www" {
count = var.create_dns_record ? 1 : 0
zone_id = var.zone_id
name = "www.${var.domain_name}"
type = "A"
alias {
name = aws_cloudfront_distribution.cdn.domain_name
zone_id = aws_cloudfront_distribution.cdn.hosted_zone_id
evaluate_target_health = false
}
}
This approach makes your code more concise and easier to maintain.
Implement Automated Testing
Add automated tests to validate your Terraform code before deploying:
# testing/main.tf
module "test_vpc" {
source = "../../modules/networking"
project = "test"
environment = "dev"
vpc_cidr = "10.0.0.0/16"
# Other required variables...
}
# Output test results
output "validation" {
value = {
vpc_created = module.test_vpc.vpc_id != ""
num_subnets = length(module.test_vpc.subnet_ids)
}
}
Use tools like Terratest, kitchen-terraform, or simple shell scripts to test your configurations. Automated testing helps catch issues early and builds confidence in your infrastructure changes.
Use Workspaces Wisely
Terraform workspaces can be helpful for managing small variations, but they're not a substitute for proper environment separation:
# Better approach for managing environments
# Each environment has its own directory and state file
$ cd environments/dev
$ terraform apply
# Less ideal approach using workspaces
# All environments share module code but have different state files
$ terraform workspace select dev
$ terraform apply
For production infrastructure, prefer separate environment directories with their own state files over workspaces.
Secure Your Terraform Configuration
Always follow security best practices:
- Store sensitive values in secure variable sources:
# Use variables for sensitive values, DON'T hardcode them
variable "database_password" {
description = "Password for database access"
type = string
sensitive = true
}
# Reference from environment variables or secure input
# TF_VAR_database_password="secure-password" terraform apply
Use IAM roles with least privilege principles for Terraform execution
Implement appropriate security groups and network ACLs:
resource "aws_security_group" "database" {
name = "${var.project}-${var.environment}-db-sg"
description = "Security group for database instances"
vpc_id = var.vpc_id
# Only allow access from application servers on the database port
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.app_security_group_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Document Your Infrastructure
Add meaningful comments and documentation to your Terraform code:
# modules/database/README.md
# Database Module
This module provisions an RDS PostgreSQL database with appropriate security groups
and backup configurations.
Usage
module "database" {
source = "../modules/database"
project = "ecommerce"
environment = "production"
instance_class = "db.r5.large"
# See variables.tf for all available options
}
Inputs
Name | Description | Type | Default | Required |
---|---|---|---|---|
instance_class | The RDS instance type | string |
"db.t3.micro" |
no |
allocated_storage | The allocated storage in GB | number |
20 |
no |
... | ... | ... | ... | ... |
Outputs
Name | Description |
---|---|
db_instance_endpoint | The connection endpoint for the database |
db_instance_id | The RDS instance ID |
Good documentation helps team members understand how to use your modules and reduces the learning curve.
Implement a CI/CD Pipeline
Automate your Terraform workflow with CI/CD:
- Validate syntax and format on pull requests
- Run
terraform plan
to check for potential changes - Apply changes automatically (after approval)
Example GitHub Actions workflow:
name: 'Terraform CI/CD'
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.2.3
- name: Terraform Format
id: fmt
run: terraform fmt -check -recursive
- name: Terraform Init
id: init
run: |
cd environments/dev
terraform init
- name: Terraform Validate
id: validate
run: |
cd environments/dev
terraform validate -no-color
- name: Terraform Plan
id: plan
if: github.event_name == 'pull_request'
run: |
cd environments/dev
terraform plan -no-color
continue-on-error: true
# Add approval and apply steps for production environments
This helps ensure that your Terraform changes are properly reviewed and tested before deployment.
Conclusion
Following these best practices will help you create Terraform code that is maintainable, scalable, and secure. Remember that infrastructure as code is not just about automating deployments, it's about creating infrastructure that can evolve with your needs while maintaining reliability and security.
Start by implementing these practices incrementally in your existing projects. Focus first on modularization, consistent naming, and proper state management, then gradually adopt the more advanced practices like automated testing and CI/CD integration.
Happy infrastructure building!
Top comments (2)
Super practical guide! Moving to a strict modular structure made my Terraform projects so much easier to update across environments.
Thank you!