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.

Project Structure

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

  1. Hardcoding values – Use variables and data sources
  2. Not using remote state – Essential for teams
  3. Ignoring state locking – Prevents corruption
  4. Large monolithic configs – Use modules
  5. Not pinning versions – Causes unexpected changes
  6. Skipping plan review – Always review before apply
  7. Manual changes – All changes through Terraform
  8. 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?

R

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.

🐧 Stay Updated with Linux Tips

Get the latest tutorials, news, and guides delivered to your inbox weekly.

Add Comment