Terraform Best Practices 2025: Code Organization, Security, and CI/CD Integration
Following Terraform best practices ensures your infrastructure code is maintainable, secure, and scalable. This guide covers project organization, naming conventions, security hardening, testing, and CI/CD integration patterns used by industry leaders.
📑 Table of Contents
- Project Structure
- Recommended Layout
- Alternative: Layered Approach
- Naming Conventions
- Resource Naming
- Variable Naming
- Code Organization
- File Organization
- Resource Ordering
- Version Constraints
- Security Best Practices
- Never Hardcode Secrets
- Encrypt Everything
- Least Privilege IAM
- Use Data Sources
- Use Lifecycle Rules
- Terraform Validation
- Testing Infrastructure
- Using Terratest (Go)
- CI/CD Integration
- GitHub Actions
- GitLab CI
- Documentation
- Use terraform-docs
- Cost Management
- Common Mistakes to Avoid
- Conclusion
Project Structure
Recommended Layout
terraform-infrastructure/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── backend.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ │ └── ...
│ └── prod/
│ └── ...
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ ├── compute/
│ │ └── ...
│ └── database/
│ └── ...
├── .gitignore
├── .terraform-version
└── README.md
Alternative: Layered Approach
infrastructure/
├── 01-network/ # VPC, subnets, routing
│ ├── main.tf
│ └── outputs.tf
├── 02-security/ # Security groups, IAM
│ └── ...
├── 03-database/ # RDS, DynamoDB
│ └── ...
├── 04-compute/ # EC2, ECS, Lambda
│ └── ...
└── 05-monitoring/ # CloudWatch, alerts
└── ...
Naming Conventions
Resource Naming
# Use lowercase with hyphens for resource names
resource "aws_instance" "web_server" { # Good
resource "aws_instance" "WebServer" { # Avoid
# Include environment and purpose in names
resource "aws_vpc" "main" {
tags = {
Name = "vpc-${var.environment}-${var.project}"
}
}
# Use consistent prefixes
# vpc-prod-myapp
# subnet-prod-public-1
# sg-prod-web
# ec2-prod-api-1
Variable Naming
# Use snake_case for variables
variable "instance_type" {} # Good
variable "instanceType" {} # Avoid
# Be descriptive
variable "web_server_instance_type" {} # Good
variable "type" {} # Too vague
# Group related variables with prefixes
variable "db_instance_class" {}
variable "db_storage_gb" {}
variable "db_multi_az" {}
Code Organization
File Organization
# main.tf - Primary resources
# variables.tf - Input variable declarations
# outputs.tf - Output declarations
# providers.tf - Provider configurations
# versions.tf - Terraform and provider versions
# data.tf - Data sources
# locals.tf - Local values
Resource Ordering
# Order resources logically
# 1. Data sources first
data "aws_ami" "ubuntu" { ... }
# 2. Core resources
resource "aws_vpc" "main" { ... }
# 3. Dependent resources
resource "aws_subnet" "public" { ... }
# 4. Security resources
resource "aws_security_group" "web" { ... }
# 5. Compute resources
resource "aws_instance" "web" { ... }
Version Constraints
# versions.tf
terraform {
required_version = ">= 1.5.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Allow 5.x updates
}
random = {
source = "hashicorp/random"
version = ">= 3.0.0"
}
}
}
# Use .terraform-version for tfenv
# .terraform-version
1.7.0
Security Best Practices
Never Hardcode Secrets
# BAD - Never do this
resource "aws_db_instance" "db" {
password = "supersecret123" # NEVER!
}
# GOOD - Use variables
variable "db_password" {
type = string
sensitive = true
}
resource "aws_db_instance" "db" {
password = var.db_password
}
# BETTER - Use AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/db/password"
}
resource "aws_db_instance" "db" {
password = data.aws_secretsmanager_secret_version.db.secret_string
}
Encrypt Everything
# S3 buckets
resource "aws_s3_bucket_server_side_encryption_configuration" "example" {
bucket = aws_s3_bucket.example.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.example.arn
}
}
}
# EBS volumes
resource "aws_instance" "example" {
root_block_device {
encrypted = true
}
}
# RDS
resource "aws_db_instance" "example" {
storage_encrypted = true
}
Least Privilege IAM
# Specific permissions, not wildcards
resource "aws_iam_policy" "s3_read" {
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:ListBucket"
]
Resource = [
aws_s3_bucket.data.arn,
"${aws_s3_bucket.data.arn}/*"
]
}
]
})
}
Use Data Sources
# Instead of hardcoding AMI IDs
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
# Instead of hardcoding AZs
data "aws_availability_zones" "available" {
state = "available"
}
# Instead of hardcoding account ID
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "example" {
bucket = "bucket-${data.aws_caller_identity.current.account_id}"
}
Use Lifecycle Rules
# Prevent accidental deletion
resource "aws_db_instance" "production" {
lifecycle {
prevent_destroy = true
}
}
# Ignore changes made outside Terraform
resource "aws_instance" "example" {
lifecycle {
ignore_changes = [
tags["LastModified"],
]
}
}
# Create before destroy for zero-downtime
resource "aws_instance" "example" {
lifecycle {
create_before_destroy = true
}
}
Terraform Validation
# Format check
terraform fmt -check -recursive
# Validate syntax
terraform validate
# Static analysis with tflint
tflint --init
tflint
# Security scanning with tfsec
tfsec .
# Security scanning with checkov
checkov -d .
Testing Infrastructure
Using Terratest (Go)
// test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVPC(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"environment": "test",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID)
}
CI/CD Integration
GitHub Actions
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Terraform Format
run: terraform fmt -check -recursive
- name: Terraform Init
run: terraform init
working-directory: environments/dev
- name: Terraform Validate
run: terraform validate
working-directory: environments/dev
- name: Terraform Plan
run: terraform plan -out=tfplan
working-directory: environments/dev
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Apply
if: github.ref == refs/heads/main && github.event_name == push
run: terraform apply -auto-approve tfplan
working-directory: environments/dev
GitLab CI
# .gitlab-ci.yml
stages:
- validate
- plan
- apply
variables:
TF_ROOT: environments/prod
validate:
stage: validate
script:
- terraform fmt -check -recursive
- terraform init
- terraform validate
plan:
stage: plan
script:
- terraform init
- terraform plan -out=tfplan
artifacts:
paths:
- tfplan
apply:
stage: apply
script:
- terraform apply -auto-approve tfplan
when: manual
only:
- main
Documentation
Use terraform-docs
# Generate documentation
terraform-docs markdown table ./modules/vpc > ./modules/vpc/README.md
# Auto-generate on commit with pre-commit hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/terraform-docs/terraform-docs
rev: v0.17.0
hooks:
- id: terraform-docs-go
args: ["markdown", "table", "--output-file", "README.md"]
Cost Management
# Use Infracost for cost estimation
infracost breakdown --path .
# In CI/CD
infracost diff --path . --compare-to infracost-base.json
Common Mistakes to Avoid
- Hardcoding values – Use variables and data sources
- Not using remote state – Essential for teams
- Ignoring state locking – Prevents corruption
- Large monolithic configs – Use modules
- Not pinning versions – Causes unexpected changes
- Skipping plan review – Always review before apply
- Manual changes – All changes through Terraform
- Not using workspaces – Separate environments properly
Conclusion
Following these best practices will help you build infrastructure that is secure, maintainable, and scalable. Start with proper project structure, enforce code quality with linting and validation, and integrate Terraform into your CI/CD pipeline for consistent deployments.
Was this article helpful?
About Ramesh Sundararamaiah
Red Hat Certified Architect
Expert in Linux system administration, DevOps automation, and cloud infrastructure. Specializing in Red Hat Enterprise Linux, CentOS, Ubuntu, Docker, Ansible, and enterprise IT solutions.