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 for Beginners: Infrastructure as Code

Get started with Terraform: providers, resources, state management, and modules.

TerraformIaCDevOpsCloud

By MinhVo

Introduction

Infrastructure as Code (IaC) is one of the most transformative practices in modern software engineering. Instead of manually provisioning servers through web consoles, clicking through dozens of configuration screens, and hoping you remember every setting for the next deployment, IaC lets you define your infrastructure in files that can be versioned, reviewed, tested, and reused. Terraform, created by HashiCorp in 2014, has become the de facto standard for IaC across cloud providers.

Terraform uses a declarative language called HCL (HashiCorp Configuration Language) to describe the desired state of your infrastructure. You write what you want—a VPC with three subnets, an ECS cluster with four tasks, an RDS database with automated backups—and Terraform figures out how to create it, update it, or destroy it. This declarative approach means you do not need to write procedural scripts that execute commands in sequence. Instead, you describe the end state, and Terraform determines the sequence of operations needed to reach it.

The power of Terraform extends beyond simple resource provisioning. It manages dependencies between resources, tracks the current state of your infrastructure, plans changes before applying them, and supports modular, reusable configurations. A single Terraform configuration can manage resources across multiple cloud providers—AWS, Azure, Google Cloud, Cloudflare, GitHub, and hundreds more—through a unified workflow.

This guide covers everything from installing Terraform and writing your first configuration to advanced patterns like modules, workspaces, and production deployment strategies. We will explore the core concepts that make Terraform powerful, walk through real-world infrastructure patterns, and discuss the practices that separate hobby projects from production-grade infrastructure.

Terraform infrastructure

Understanding Terraform: Core Concepts

Providers

Providers are Terraform's interface to cloud platforms, SaaS providers, and other APIs. Each provider defines the resource types you can create and the data sources you can query:

# Configure the AWS provider
terraform {
  required_version = ">= 1.5"
  
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
 
provider "aws" {
  region = "us-east-1"
  
  default_tags {
    tags = {
      Environment = "production"
      ManagedBy   = "terraform"
      Project     = "my-app"
    }
  }
}

Terraform supports hundreds of providers. You can use multiple providers in the same configuration to manage resources across different platforms:

# Multi-provider configuration
provider "aws" {
  region = "us-east-1"
}
 
provider "aws" {
  alias  = "west"
  region = "us-west-2"
}
 
provider "cloudflare" {
  api_token = var.cloudflare_api_token
}
 
provider "github" {
  token = var.github_token
  owner = "my-org"
}

Resources

Resources are the fundamental building blocks of Terraform configurations. Each resource block describes one or more infrastructure objects:

# Create a VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  
  tags = {
    Name = "main-vpc"
  }
}
 
# Create a subnet
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "us-east-1a"
  map_public_ip_on_launch = true
  
  tags = {
    Name = "public-subnet"
  }
}
 
# Create an EC2 instance
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public.id
  
  vpc_security_group_ids = [aws_security_group.web.id]
  
  user_data = <<-EOF
    #!/bin/bash
    yum update -y
    yum install -y httpd
    systemctl start httpd
    systemctl enable httpd
  EOF
  
  tags = {
    Name = "web-server"
  }
}

State

Terraform maintains a state file that maps your configuration to real infrastructure. The state file tracks which resources exist, their current configuration, and metadata like resource IDs:

# Configure remote state storage
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

State management is one of the most critical aspects of Terraform. The state file is the source of truth for what infrastructure exists. If the state file is lost, Terraform cannot manage existing resources. If it is corrupted, Terraform may make incorrect changes. Storing state remotely (in S3, Azure Blob Storage, or Terraform Cloud) and enabling state locking (with DynamoDB or similar) are essential practices for team environments.

Terraform state management

Architecture and Design Patterns

Variables and Outputs

Variables make configurations reusable and configurable:

# variables.tf
variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}
 
variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}
 
variable "vpc_cidr" {
  description = "VPC CIDR block"
  type        = string
  default     = "10.0.0.0/16"
  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "Must be a valid CIDR block."
  }
}
 
variable "tags" {
  description = "Additional tags for all resources"
  type        = map(string)
  default     = {}
}
 
# outputs.tf
output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}
 
output "public_subnet_ids" {
  description = "Public subnet IDs"
  value       = aws_subnet.public[*].id
}
 
output "web_server_ip" {
  description = "Web server public IP"
  value       = aws_instance.web.public_ip
}
 
output "database_endpoint" {
  description = "RDS database endpoint"
  value       = aws_db_instance.main.endpoint
  sensitive   = true
}

Data Sources

Data sources query existing infrastructure or external data:

# Look up the latest Amazon Linux AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
  
  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}
 
# Look up available AZs
data "aws_availability_zones" "available" {
  state = "available"
}
 
# Look up an existing VPC
data "aws_vpc" "existing" {
  tags = {
    Name = "shared-vpc"
  }
}
 
# Use the data source in a resource
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public[0].id
}

Step-by-Step Implementation

Setting Up a Basic VPC

Create a complete VPC with public and private subnets:

# main.tf
terraform {
  required_version = ">= 1.5"
  
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "vpc/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}
 
provider "aws" {
  region = var.aws_region
}
 
# VPC
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true
  
  tags = merge(var.tags, {
    Name = "${var.environment}-vpc"
  })
}
 
# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  
  tags = merge(var.tags, {
    Name = "${var.environment}-igw"
  })
}
 
# Public Subnets
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       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true
  
  tags = merge(var.tags, {
    Name = "${var.environment}-public-${count.index + 1}"
    Tier = "public"
  })
}
 
# Private Subnets
resource "aws_subnet" "private" {
  count = length(var.private_subnet_cidrs)
  
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = data.aws_availability_zones.available.names[count.index]
  
  tags = merge(var.tags, {
    Name = "${var.environment}-private-${count.index + 1}"
    Tier = "private"
  })
}
 
# NAT Gateway (one per AZ for high availability)
resource "aws_eip" "nat" {
  count  = length(var.public_subnet_cidrs)
  domain = "vpc"
  
  tags = merge(var.tags, {
    Name = "${var.environment}-nat-eip-${count.index + 1}"
  })
}
 
resource "aws_nat_gateway" "main" {
  count = length(var.public_subnet_cidrs)
  
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
  
  tags = merge(var.tags, {
    Name = "${var.environment}-nat-${count.index + 1}"
  })
  
  depends_on = [aws_internet_gateway.main]
}
 
# Route Tables
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
  
  tags = merge(var.tags, {
    Name = "${var.environment}-public-rt"
  })
}
 
resource "aws_route_table" "private" {
  count  = length(var.private_subnet_cidrs)
  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 = merge(var.tags, {
    Name = "${var.environment}-private-rt-${count.index + 1}"
  })
}
 
resource "aws_route_table_association" "public" {
  count = length(var.public_subnet_cidrs)
  
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}
 
resource "aws_route_table_association" "private" {
  count = length(var.private_subnet_cidrs)
  
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}

Deploying an Application Stack

Create a complete application with ECS, RDS, and ALB:

# ecs.tf
resource "aws_ecs_cluster" "main" {
  name = "${var.environment}-cluster"
  
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}
 
resource "aws_ecs_task_definition" "app" {
  family                   = "${var.environment}-app"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn
  
  container_definitions = jsonencode([
    {
      name  = "app"
      image = "${var.ecr_repository_url}:latest"
      portMappings = [
        {
          containerPort = 3000
          hostPort      = 3000
          protocol      = "tcp"
        }
      ]
      environment = [
        { name = "NODE_ENV", value = var.environment },
        { name = "DATABASE_URL", value = "postgres://${var.db_username}:${var.db_password}@${aws_db_instance.main.endpoint}/${var.db_name}" },
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = "/ecs/${var.environment}-app"
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
        }
      }
    }
  ])
}
 
resource "aws_ecs_service" "app" {
  name            = "${var.environment}-app"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = var.app_count
  launch_type     = "FARGATE"
  
  network_configuration {
    security_groups  = [aws_security_group.ecs.id]
    subnets          = aws_subnet.private[*].id
    assign_public_ip = false
  }
  
  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 3000
  }
}
 
# RDS Database
resource "aws_db_subnet_group" "main" {
  name       = "${var.environment}-db-subnet"
  subnet_ids = aws_subnet.private[*].id
}
 
resource "aws_db_instance" "main" {
  identifier = "${var.environment}-db"
  
  engine         = "postgres"
  engine_version = "15"
  instance_class = "db.t3.micro"
  
  allocated_storage     = 20
  max_allocated_storage = 100
  storage_encrypted     = true
  
  db_name  = var.db_name
  username = var.db_username
  password = var.db_password
  
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.database.id]
  
  backup_retention_period = 7
  multi_az               = var.environment == "prod"
  skip_final_snapshot    = var.environment != "prod"
  
  tags = merge(var.tags, {
    Name = "${var.environment}-database"
  })
}
 
# Application Load Balancer
resource "aws_lb" "main" {
  name               = "${var.environment}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.public[*].id
  
  tags = merge(var.tags, {
    Name = "${var.environment}-alb"
  })
}
 
resource "aws_lb_target_group" "app" {
  name        = "${var.environment}-app-tg"
  port        = 3000
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main.id
  target_type = "ip"
  
  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 5
    timeout             = 5
    interval            = 30
    matcher             = "200"
  }
}
 
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = var.certificate_arn
  
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

Terraform implementation

Real-World Use Cases and Case Studies

Use Case 1: Multi-Environment Infrastructure

Organizations that maintain development, staging, and production environments benefit from Terraform's workspace and variable system. A single configuration with environment-specific variable files (.tfvars) provisions identical infrastructure with different sizing. The terraform.workspace interpolation allows environment-specific logic without duplicating code.

Use Case 2: Disaster Recovery

Terraform's declarative approach makes disaster recovery straightforward. If a region goes down, you can apply the same configuration to a different region with updated variables. The infrastructure is recreated identically because the configuration is the source of truth.

Use Case 3: Compliance and Auditing

Organizations with compliance requirements use Terraform to enforce security policies. Sentinel policies (HashiCorp's policy-as-code framework) prevent resources from being created without encryption, public access restrictions, or required tags. The state file provides an audit trail of every change.

Use Case 4: Multi-Cloud Deployments

Enterprises that use multiple cloud providers benefit from Terraform's provider abstraction. A single configuration can manage resources on AWS, Azure, and Google Cloud, with a unified workflow for planning, applying, and destroying infrastructure.

Best Practices for Production

  1. Use remote state with locking: Store state in S3, Azure Blob Storage, or Terraform Cloud. Enable state locking with DynamoDB or equivalent to prevent concurrent modifications.

  2. Use modules for reusable components: Create modules for common patterns (VPC, ECS, RDS) and version them. This ensures consistency across environments and teams.

  3. Use workspaces or directory separation for environments: Separate environments using Terraform workspaces or directory structures. Each environment should have its own state file.

  4. Plan before applying: Always run terraform plan before terraform apply. Review the plan output to ensure it matches your expectations. In CI/CD pipelines, save the plan output and require approval before applying.

  5. Use .tfvars files for environment-specific values: Keep environment-specific values in .tfvars files (e.g., prod.tfvars, staging.tfvars). This separates configuration from code.

  6. Tag everything: Use default_tags on the provider to tag all resources with environment, project, and owner information. This simplifies cost allocation and resource management.

  7. Use terraform fmt and terraform validate: Format your code consistently with terraform fmt and validate it with terraform validate before committing. Use pre-commit hooks to automate this.

  8. Pin provider versions: Use version constraints (~> 5.0) to prevent unexpected changes from provider updates. Test provider upgrades in a non-production environment first.

Common Pitfalls and Solutions

PitfallImpactSolution
Storing state locallyLost state, no collaborationUse remote backend (S3, Terraform Cloud)
Not locking stateConcurrent modifications corrupt stateEnable DynamoDB locking
Hardcoding secretsSecurity risk, committed to VCSUse variables with sensitive = true, store in secrets manager
Not using modulesDuplicated code, inconsistent infrastructureExtract common patterns into modules
Ignoring terraform planUnintended changes appliedAlways review plan before applying
Not pinning versionsBreaking changes from provider updatesUse version constraints
Manually modifying resourcesState drift, inconsistent infrastructureUse terraform import or terraform refresh
Large monolithic configsSlow plans, merge conflictsSplit into modules and separate state files

Performance Optimization

# Use -parallelism to speed up operations
# terraform apply -parallelism=10
 
# Use targeted operations for large infrastructures
# terraform apply -target=aws_instance.web
 
# Use data sources efficiently
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
  
  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}
 
# Cache frequently used data
locals {
  common_tags = merge(var.tags, {
    Environment = var.environment
    ManagedBy   = "terraform"
    Project     = var.project_name
  })
  
  azs = data.aws_availability_zones.available.names
}

Comparison with Alternatives

FeatureTerraformCloudFormationPulumiAWS CDKAnsible
LanguageHCLJSON/YAMLGo, TS, PythonTypeScriptYAML
Multi-CloudYesAWS onlyYesAWS onlyYes
State ManagementBuilt-inAWS managedBuilt-inAWS managedAgent-based
Plan/ApplyYesChange SetsYesYesDry Run
Module EcosystemLarge (Registry)LargeGrowingLargeLarge
Learning CurveLowLowMediumMediumLow
Immutable InfraYesYesYesYesNo
CommunityLargestLargeGrowingLargeLargest

Advanced Patterns and Techniques

Module Composition

# modules/vpc/main.tf
variable "environment" { type = string }
variable "vpc_cidr" { type = string }
variable "azs" { type = list(string) }
variable "public_subnet_cidrs" { type = list(string) }
variable "private_subnet_cidrs" { type = list(string) }
 
output "vpc_id" { value = aws_vpc.main.id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
 
# Usage
module "vpc" {
  source = "./modules/vpc"
  
  environment          = "prod"
  vpc_cidr             = "10.0.0.0/16"
  azs                  = ["us-east-1a", "us-east-1b", "us-east-1c"]
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
}
 
module "app" {
  source = "./modules/ecs-app"
  
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
  # ...
}

Dynamic Blocks

resource "aws_security_group" "app" {
  name   = "${var.environment}-app-sg"
  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"]
  }
}

Testing Strategies

# Validate configuration
terraform validate
 
# Format check
terraform fmt -check -recursive
 
# Use tftest for unit testing
# tests/vpc_test.go
package test
 
import (
  "testing"
  "github.com/gruntwork-io/terratest/modules/terraform"
)
 
func TestVpcModule(t *testing.T) {
  terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
    TerraformDir: "../modules/vpc",
    Vars: map[string]interface{}{
      "environment": "test",
      "vpc_cidr":    "10.0.0.0/16",
    },
  })
  
  defer terraform.Destroy(t, terraformOptions)
  terraform.InitAndApply(t, terraformOptions)
  
  vpcId := terraform.Output(t, terraformOptions, "vpc_id")
  assert.NotEmpty(t, vpcId)
}

Future Outlook

Terraform continues to evolve with improved language features, better testing tools, and tighter integration with cloud providers. The introduction of Terraform Cloud and Terraform Enterprise provides team collaboration features, policy enforcement, and private module registries.

The recent license change from MPL to BSL has spurred the development of OpenTofu, an open-source fork backed by the Linux Foundation. This ensures that the IaC ecosystem remains open and competitive.

The broader trend toward GitOps and platform engineering creates new opportunities for Terraform. As organizations build internal developer platforms, Terraform becomes the foundation for provisioning the infrastructure that platforms run on.

Conclusion

Terraform is the most widely adopted Infrastructure as Code tool for good reason:

  1. Declarative configuration eliminates imperative complexity: You describe what you want, not how to create it. Terraform handles dependency resolution, ordering, and error recovery automatically.

  2. The plan-and-apply workflow prevents surprises: Before making any changes, Terraform shows you exactly what it will create, modify, or destroy. This predictability is essential for production infrastructure.

  3. State management tracks real-world resources: The state file maps your configuration to actual infrastructure, enabling Terraform to manage resources it did not create and to detect drift from manual changes.

  4. The provider ecosystem covers everything: With hundreds of providers, Terraform can manage resources on any cloud platform, SaaS service, or API. A single tool for your entire infrastructure.

  5. Modules enable reusable, tested infrastructure: Extract common patterns into modules, version them, and share them across teams. This reduces duplication and ensures consistency.

  6. The learning curve is gentle: HCL is readable, the workflow is simple (init, plan, apply), and the documentation is excellent. Developers can be productive within hours.

If you are managing cloud infrastructure manually, adopting Terraform is one of the highest-impact improvements you can make. The time invested in writing configurations pays dividends in consistency, repeatability, and confidence in your infrastructure changes.