Skip to main content
Back to Blog
23 August 202516 min read

Terraform: Infrastructure as Code Best Practices

TerraformIaCDevOpsCloudAWS

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-conflict

CI/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/prod

Key Takeaways

  1. Module everything: Reusable modules reduce duplication and errors

  2. Remote state: Always use remote state with locking for team collaboration

  3. Environment isolation: Separate state files per environment

  4. Validate inputs: Use variable validation to catch errors early

  5. Test infrastructure: Use Terratest or similar for automated testing

  6. Pre-commit hooks: Catch formatting and validation issues before commit

  7. Document modules: Use terraform-docs for automatic documentation

  8. 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.

Share this article