MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Terraform: Infrastructure as Code for AWS

Learn Terraform: providers, resources, state management, modules, and AWS deployment.

TerraformAWSIaCDevOps

By MinhVo

Introduction

Infrastructure as Code (IaC) has fundamentally transformed how organizations provision and manage cloud resources. Terraform, developed by HashiCorp, stands as the de facto standard for declarative infrastructure management across multiple cloud providers. Rather than clicking through AWS consoles or writing imperative scripts, Terraform enables teams to define their entire infrastructure in human-readable configuration files that can be versioned, reviewed, and automated through CI/CD pipelines.

In the AWS ecosystem, Terraform provides a powerful abstraction layer that simplifies complex resource provisioning while maintaining the flexibility to leverage AWS-specific features. Whether you're deploying a simple S3 bucket or orchestrating a multi-region Kubernetes cluster, Terraform's declarative approach ensures consistency, repeatability, and auditability across your entire infrastructure lifecycle. This comprehensive guide explores every aspect of production Terraform usage on AWS.

Cloud Infrastructure

Understanding Terraform: Core Concepts

Terraform operates on a declarative paradigm where you describe the desired end state of your infrastructure rather than specifying step-by-step instructions to achieve it. This fundamental difference from imperative tools like AWS CloudFormation custom resources or bash provisioning scripts enables Terraform to intelligently plan changes, detect drift, and minimize human error during deployments.

At its core, Terraform uses HashiCorp Configuration Language (HCL), a domain-specific language designed for both human readability and machine processing. HCL strikes a balance between JSON's machine-friendliness and YAML's human-friendliness, providing rich type systems, expressions, and functions while remaining approachable for operations teams transitioning from console-based management.

The Terraform workflow follows a predictable three-step cycle: terraform init initializes the working directory and downloads providers, terraform plan generates an execution plan showing proposed changes, and terraform plan applies those changes to reach the desired state. This workflow integrates seamlessly into CI/CD pipelines, enabling GitOps-driven infrastructure management where every change goes through code review and automated testing before reaching production environments.

Providers: The Plugin Architecture

Providers are Terraform's abstraction layer for interacting with cloud platforms, SaaS services, and other APIs. The AWS provider alone exposes over 800 resource types and hundreds of data sources, covering everything from EC2 instances and VPCs to Lambda functions and DynamoDB tables. Each provider translates Terraform's declarative configuration into specific AWS API calls.

Provider versioning ensures that infrastructure changes don't break unexpectedly when provider updates introduce breaking changes. The required_providers block pins specific versions, while the provider lock file (terraform.lock.hcl) captures exact checksums for reproducible builds across development machines, CI/CD runners, and staging environments.

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
 
provider "aws" {
  region = var.aws_region
  
  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "terraform"
      Project     = var.project_name
    }
  }
}

Resources and Data Sources

Resources represent infrastructure objects that Terraform manages—EC2 instances, S3 buckets, IAM roles, and thousands of other AWS services. Each resource block declares a desired state, and Terraform's responsibility is to create, update, or delete the actual infrastructure to match that declaration during each apply cycle.

Data sources allow Terraform to fetch information about existing infrastructure that wasn't created by the current configuration. This read-only capability enables compositions where new infrastructure references existing resources, such as querying for the latest Amazon Linux AMI ID, retrieving VPC subnet IDs from a shared networking account, or looking up existing Route53 hosted zones.

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
 
  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }
}
 
data "aws_availability_zones" "available" {
  state = "available"
}
 
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public[0].id
  
  tags = {
    Name = "${var.project_name}-web-server"
  }
}

Architecture and Design Patterns

Effective Terraform architecture separates concerns across multiple layers: environment isolation, resource grouping, and shared modules. Enterprise deployments typically organize infrastructure into distinct workspaces or state files per environment (dev, staging, production) while sharing reusable module definitions across the entire organization.

The recommended directory structure follows a modular pattern where each AWS service or logical grouping gets its own module, and environment-specific configurations live in separate directories. This structure enables independent team ownership, parallel development, and selective deployments without risking unintended changes to unrelated infrastructure components.

infrastructure/
├── modules/
│   ├── vpc/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── eks/
│   ├── rds/
│   └── monitoring/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── terraform.tfvars
│   │   └── backend.tf
│   ├── staging/
│   └── production/
└── global/
    ├── iam/
    └── dns/

State Management Architecture

Terraform state is the critical bridge between your configuration files and the actual infrastructure running in AWS. State files map configuration resources to real-world objects, storing resource IDs, attributes, and metadata that enable Terraform to determine what changes are needed during subsequent plan and apply operations.

For team environments, remote state storage in Amazon S3 with DynamoDB locking prevents concurrent modifications that could corrupt infrastructure mappings. The DynamoDB table acts as a distributed lock, ensuring that only one Terraform operation can modify state at a time—a critical safeguard when multiple team members or CI/CD pipelines might trigger simultaneous deployments against the same infrastructure.

terraform {
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "production/vpc/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

State isolation between environments prevents accidental cross-environment changes. A compromised staging deployment should never cascade into production. Teams achieve this isolation through separate state files per environment, distinct AWS accounts via AWS Organizations, or workspace-based separation depending on organizational maturity and security requirements.

Module Design Principles

Well-designed Terraform modules encapsulate complexity behind clean interfaces, following the principle of least privilege for inputs and outputs. A VPC module, for instance, might accept CIDR blocks and availability zones as inputs while exposing subnet IDs and security group references as outputs—hiding the intricate details of route tables, NAT gateways, network ACLs, and VPC endpoints.

Module versioning with semantic versioning tags enables safe upgrades across environments. Production environments pin to specific module versions while development environments might track the latest version for early testing of new features and improvements. This approach balances stability with innovation velocity across your infrastructure portfolio.

module "vpc" {
  source  = "app.terraform.io/my-org/vpc/aws"
  version = "~> 3.0"
 
  cidr_block         = "10.0.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
  enable_nat_gateway = true
  single_nat_gateway = var.environment != "production"
  
  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

Step-by-Step Implementation

Setting up a production-ready Terraform project for AWS requires careful consideration of authentication, state management, and organizational patterns. This implementation walks through creating a complete VPC with public and private subnets, NAT gateways, and security groups—the foundation for most AWS workloads.

First, establish the project structure and provider configuration. The versions.tf file centralizes version constraints, while providers.tf handles authentication configuration. Using environment variables or AWS profiles for credentials keeps sensitive information out of configuration files and enables seamless switching between accounts during development.

# Install Terraform via Homebrew
brew install terraform
 
# Or download from official releases
wget https://releases.hashicorp.com/terraform/1.9.0/terraform_1.9.0_linux_amd64.zip
unzip terraform_1.9.0_linux_amd64.zip
sudo mv terraform /usr/local/bin/
 
# Initialize project
mkdir -p infrastructure/environments/production
cd infrastructure/environments/production
terraform init

The VPC configuration demonstrates Terraform's composability through resources that reference each other. Subnets automatically associate with the VPC through the vpc_id attribute, route tables reference gateway IDs, and security groups reference VPC CIDR blocks—all managed through Terraform's implicit dependency graph that determines the correct creation order.

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true
 
  tags = {
    Name = "${var.project_name}-vpc"
  }
}
 
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
 
  tags = {
    Name = "${var.project_name}-igw"
  }
}
 
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 = {
    Name                              = "${var.project_name}-private-${var.availability_zones[count.index]}"
    "kubernetes.io/role/internal-elb" = "1"
  }
}
 
resource "aws_subnet" "public" {
  count                   = length(var.availability_zones)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index + length(var.availability_zones))
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = true
 
  tags = {
    Name                     = "${var.project_name}-public-${var.availability_zones[count.index]}"
    "kubernetes.io/role/elb" = "1"
  }
}

NAT Gateways enable private subnet resources to access the internet for software updates and external API calls while remaining inaccessible from the public internet. Each availability zone gets its own NAT Gateway for high availability, though cost-conscious teams in non-production environments might use a single NAT Gateway to reduce expenses.

resource "aws_eip" "nat" {
  count  = length(var.availability_zones)
  domain = "vpc"
 
  tags = {
    Name = "${var.project_name}-nat-eip-${count.index}"
  }
}
 
resource "aws_nat_gateway" "main" {
  count         = length(var.availability_zones)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
 
  tags = {
    Name = "${var.project_name}-nat-${count.index}"
  }
 
  depends_on = [aws_internet_gateway.main]
}
 
resource "aws_route_table" "private" {
  count  = length(var.availability_zones)
  vpc_id = aws_vpc.main.id
 
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main[count.index].id
  }
 
  tags = {
    Name = "${var.project_name}-private-rt-${count.index}"
  }
}

Security groups act as virtual firewalls controlling inbound and outbound traffic at the instance level. Unlike traditional firewalls that operate on IP addresses, security groups reference other security groups, enabling dynamic compositions where application tiers communicate through security group references rather than hardcoded IP ranges that change with every deployment.

resource "aws_security_group" "web" {
  name_prefix = "${var.project_name}-web-"
  vpc_id      = aws_vpc.main.id
 
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "HTTPS from internet"
  }
 
  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
    description     = "Application traffic from ALB"
  }
 
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow all outbound traffic"
  }
 
  lifecycle {
    create_before_destroy = true
  }
 
  tags = {
    Name = "${var.project_name}-web-sg"
  }
}

Real-World Use Cases

Use Case 1: Multi-Account Landing Zone

Enterprise organizations managing dozens of AWS accounts leverage Terraform to provision and maintain account baselines. Each new account automatically receives VPC configurations, IAM roles, CloudTrail logging, AWS Config rules, and guardrails that enforce security policies. This automation reduces account provisioning from weeks to minutes while ensuring compliance across the entire organization.

Terraform workspaces or Terragrunt configurations enable managing hundreds of accounts with shared modules but account-specific parameters. The for_each meta-argument creates resources conditionally based on account-specific variables, allowing the same configuration to deploy different resource sets for development accounts versus production accounts with hardened security configurations.

Use Case 2: EKS Cluster Lifecycle Management

Kubernetes cluster management exemplifies Terraform's strength in orchestrating complex, multi-resource deployments. An EKS cluster requires VPC subnets with specific tags, IAM roles with precise policy attachments, managed node groups with scaling configurations, and add-ons like CoreDNS, kube-proxy, and the VPC CNI plugin. Terraform manages these interdependencies automatically.

The AWS EKS module encapsulates hundreds of lines of configuration behind a clean interface, enabling teams to deploy production-grade clusters with sensible defaults while retaining full customization capability. Module updates incorporate AWS best practices and new features like EKS Pod Identity and auto-mode, keeping infrastructure current without manual intervention.

Use Case 3: Disaster Recovery Automation

Terraform enables infrastructure replication across regions for disaster recovery scenarios. By parameterizing region-specific values, teams can maintain warm standby environments that mirror production configurations. During failover events, Terraform promotes standby resources by updating DNS records, scaling capacity, and activating monitoring configurations through automated runbooks.

Best Practices for Production

  1. Enable State Encryption: Always encrypt state files at rest and in transit. S3 bucket encryption with AWS KMS customer-managed keys ensures sensitive values like database passwords and API keys remain protected even if storage is compromised.

  2. Implement Remote State Locking: DynamoDB locking prevents concurrent modifications that corrupt state. Configure lock timeouts appropriately—too short causes false failures during long-running operations, too long delays error detection when operations hang.

  3. Use terraform plan in CI/CD: Generate plan outputs during pull requests and require human approval before applying changes to production. This workflow catches unintended modifications before they impact live infrastructure serving real users.

  4. Tag Everything Consistently: Implement mandatory tags for cost allocation, ownership tracking, and automated cleanup. Use the provider's default_tags configuration to apply organization-wide tags automatically to every resource.

  5. Separate Concerns with Workspaces or Directories: Isolate environments, regions, and service tiers into separate state files. Blast radius reduction prevents a misconfigured staging deployment from corrupting production infrastructure.

  6. Version Pin Everything: Pin provider versions, module versions, and Terraform CLI versions using the lock file. This ensures reproducible builds across development machines and CI/CD environments regardless of when init runs.

  7. Implement Drift Detection: Schedule regular terraform plan executions that compare actual infrastructure state against configuration. Alert on drift to detect manual console changes that bypass infrastructure-as-code workflows and governance.

  8. Use prevent_destroy for Critical Resources: Lifecycle meta-arguments protect databases, production VPCs, and other resources that should never be accidentally deleted during refactoring operations or module upgrades.

Common Pitfalls and Solutions

PitfallImpactSolution
Storing secrets in state filesCredential exposure through state storageUse AWS Secrets Manager references or SSM Parameter Store with sensitive variable markers and restricted state access
Large monolithic state filesSlow plans, high blast radius, merge conflictsSplit into smaller state files per service or environment using remote state data sources for cross-stack references
Circular resource dependenciesTerraform cannot determine creation orderRefactor using depends_on explicitly or restructure resource relationships to eliminate cycles
Using -target in productionPartial state corruption, drift from actual infrastructureReserve -target for debugging only; never use in production CI/CD pipelines
Ignoring provider version constraintsUnexpected breaking changes from provider updatesPin major versions with ~> operator and test upgrades thoroughly in staging environments first
Hardcoded values across environmentsConfiguration drift, maintenance burden across dozens of environmentsUse variables, tfvars files, and workspaces for all environment-specific values

Performance Optimization

Terraform performance degrades as infrastructure grows. Large configurations with thousands of resources can experience slow plan and apply operations exceeding several minutes. The -parallelism flag controls concurrent resource operations—increasing it speeds up independent resource creation but risks hitting AWS API rate limits.

State file optimization through splitting and selective imports reduces plan execution time significantly. Instead of loading 5,000 resources into memory for a 10-resource change, smaller state files enable targeted operations that complete in seconds. The terraform state mv and terraform state rm commands facilitate state splitting without destroying live resources.

# Increase parallelism for large deployments
terraform apply -parallelism=20
 
# Target specific resources for faster plans during development
terraform plan -target=module.vpc
 
# Use refresh-only mode to sync state without making changes
terraform apply -refresh-only
 
# Generate a plan file for deferred apply
terraform plan -out=plan.tfplan
terraform apply plan.tfplan

Provider caching reduces initialization time in CI/CD environments. Configure the plugin cache directory to avoid re-downloading providers on every pipeline run, which can save minutes per execution:

export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"

Comparison with Alternatives

FeatureTerraformAWS CloudFormationPulumi
Multi-cloudYes (1000+ providers)AWS onlyYes (all major clouds)
LanguageHCL (declarative)JSON/YAML (declarative)TypeScript, Python, Go, C#
State ManagementLocal/Remote state filesAWS-managed automaticallyPulumi Cloud or self-hosted
Learning CurveModerateLow for AWS-only teamsHigh (requires programming)
Community ModulesExtensive (Terraform Registry)Limited (StackSets, CDK)Growing (Pulumi Registry)
Drift DetectionManual plan comparisonAutomatic with CloudFormationAutomatic with refresh
CostFree OSS + paid Cloud tiersFree (included in AWS)Free tier + paid features
RollbackManual state manipulationAutomatic rollback on failureManual via state management

Advanced Patterns

Advanced Terraform patterns address enterprise requirements for multi-region deployments, conditional resource creation, and dynamic configurations. The for_each and dynamic blocks enable generic configurations that adapt to variable inputs, reducing code duplication while maintaining full readability.

variable "environments" {
  type = map(object({
    instance_type = string
    min_size      = number
    max_size      = number
    enable_cdn    = bool
  }))
}
 
module "application" {
  source   = "./modules/application"
  for_each = var.environments
 
  environment   = each.key
  instance_type = each.value.instance_type
  min_size      = each.value.min_size
  max_size      = each.value.max_size
}

Sentinel policies enforce organizational guardrails without manual review. Policies written in Sentinel's policy-as-code language can prevent deployments that violate security requirements, such as blocking public S3 buckets, requiring encryption at rest, or enforcing mandatory tagging standards across every resource.

Testing Strategies

Terraform testing has evolved significantly with the introduction of native test blocks in Terraform 1.6+. The built-in testing framework validates module behavior by creating real infrastructure in isolated test environments, verifying expected outputs, and cleaning up resources automatically after test completion.

# tests/vpc.tftest.hcl
run "validate_vpc_cidr" {
  command = plan
 
  variables {
    vpc_cidr = "10.0.0.0/16"
  }
 
  assert {
    condition     = aws_vpc.main.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR block mismatch"
  }
}

Integration testing with Terratest provides more comprehensive validation by deploying actual infrastructure and running assertions against live resources. While slower than plan-time tests, integration tests catch configuration errors that static analysis cannot detect, such as missing IAM permissions or resource naming conflicts with existing infrastructure.

Future Outlook

Terraform's roadmap emphasizes improved developer experience through better IDE integration, faster execution through provider-level optimizations, and enhanced collaboration features in Terraform Cloud and Enterprise. The introduction of Terraform Stacks addresses multi-component deployments that span multiple state files, enabling coordinated infrastructure changes across service boundaries.

The OpenTofu fork, maintained by the Linux Foundation under the Linux Foundation, ensures Terraform's core capabilities remain open source. This community-driven alternative maintains compatibility while introducing features like client-side state encryption and improved testing frameworks that address long-standing community requests from enterprise users.

Conclusion

Terraform's declarative approach to infrastructure management has earned its position as the industry standard for cloud resource provisioning. Its provider ecosystem, modular architecture, and robust state management enable organizations to manage infrastructure at scale with confidence and consistency that manual processes cannot match.

Key takeaways for production Terraform adoption:

  1. Start with state management — remote state with DynamoDB locking is non-negotiable for team environments
  2. Invest in modules early — reusable modules pay dividends as infrastructure complexity grows
  3. Implement CI/CD pipelines — automated plan and apply workflows prevent configuration drift and human error
  4. Enforce guardrails with policies — Sentinel or OPA policies catch violations before deployment reaches production
  5. Test infrastructure like code — native tests and integration testing validate configurations before they affect users

The journey from console-clicking to infrastructure-as-code requires organizational commitment, but the rewards—repeatability, auditability, velocity, and disaster recovery capability—transform how teams deliver infrastructure. Start with a small, well-scoped module, establish state management practices, and expand systematically as your team's confidence grows.

For deeper exploration, consult the Terraform documentation, the AWS Provider registry, and the Terraform Best Practices community guide maintained by Anton Babenko.