Manually logging into cloud consoles to create servers, databases, and networks — this approach barely works when resources are few, but once environments grow complex, you end up unable to modify, clean up, or explain your infrastructure. Infrastructure as Code (IaC) uses code to describe infrastructure, making resource creation, modification, and destruction versionable, reviewable, and reusable. Terraform is currently the most popular IaC tool. This article covers everything from concepts to practice.

Reference: Terraform Official Documentation

I. IaC Concepts: Declarative vs Imperative

The core idea of IaC: describe the desired state of infrastructure in code files, and let a tool automatically drive the actual state toward convergence with the desired state.

In the IaC space, two fundamentally different paradigms exist:

DimensionDeclarativeImperative
Core philosophyDescribe “what” (desired state)Describe “how” (operation steps)
Representative toolsTerraform, CloudFormation, PulumiAnsible (partially), Shell scripts, AWS CLI
IdempotencyNaturally idempotent — repeated execution yields the same resultMust ensure idempotency manually
State awarenessTool tracks current state, automatically computes diffsNo state awareness, executes step by step
ReadabilityClose to configuration files, easy to understandClose to programming logic, flexible but complex

Terraform adopts the declarative paradigm. You simply declare “I need 3 EC2 instances, 1 RDS, 1 VPC,” and Terraform automatically compares the current state with the desired state, generating and executing a change plan.

The core advantage of declarative approach: idempotency. No matter how many times you run terraform apply, the final state is the same. This means you can put infrastructure code under Git version control, review changes through PRs, and achieve “code-based governance” of infrastructure.

II. Terraform Core Concepts

Terraform’s operation revolves around four core concepts:

Developer writes .tf files
┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│   Provider    │ ←──→ │   Resource   │      │    State     │
│ Cloud plugin  │      │ Resource decl│      │  State file  │
└──────────────┘      └──────────────┘      └──────────────┘
                                            ┌──────────────┐
                                            │    Module     │
                                            │ Modular reuse │
                                            └──────────────┘

2.1 Provider

A Provider is the adapter layer between Terraform and cloud provider APIs. Each cloud provider (AWS, GCP, Azure, Alibaba Cloud, etc.) has a corresponding Provider plugin through which Terraform performs CRUD operations on resources.

2.2 Resource

A Resource is the smallest unit of infrastructure description. An EC2 instance, an S3 bucket, a DNS record — each is a Resource. Every Resource has a type, name, and attribute block.

2.3 State

State is Terraform’s “memory.” It records the actual state (IDs, attributes, relationships) of all managed resources. After each terraform apply, the State file is updated for diff calculation in the next change. State is Terraform’s most critical and error-prone component — we’ll cover State management strategies in detail below.

2.4 Module

A Module is a encapsulation of a group of Resources, similar to a “function” in programming. You can package common infrastructure (like a standard VPC architecture) as a Module and reuse it across environments. HashiCorp maintains the Terraform Registry, which provides a large collection of community Modules.

III. HCL Syntax Basics

HCL (HashiCorp Configuration Language) is Terraform’s dedicated configuration language. Its design goal is “human-readable, machine-parseable.” The core syntax blocks are as follows:

3.1 Complete HCL File Structure

# ============================================
# versions.tf — Provider version constraints
# ============================================
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# ============================================
# providers.tf — Provider configuration
# ============================================
provider "aws" {
  region = "ap-northeast-1"
  default_tags {
    tags = {
      Project   = "sre-wang"
      ManagedBy = "Terraform"
    }
  }
}

# ============================================
# variables.tf — Input variables
# ============================================
variable "environment" {
  description = "Deployment environment name"
  type        = string
  default     = "staging"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev/staging/prod."
  }
}

variable "instance_count" {
  description = "Number of EC2 instances"
  type        = number
  default     = 2
}

# ============================================
# main.tf — Resource declarations
# ============================================
resource "aws_instance" "web" {
  count         = var.instance_count
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public[count.index].id

  tags = {
    Name = "web-${var.environment}-${count.index + 1}"
  }
}

# Data source: look up the latest Amazon Linux AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# ============================================
# outputs.tf — Output values
# ============================================
output "instance_public_ips" {
  description = "EC2 public IP list"
  value       = aws_instance.web[*].public_ip
}

output "rds_endpoint" {
  description = "RDS connection endpoint"
  value       = aws_db_instance.main.endpoint
  sensitive   = true
}

3.2 Key Syntax Elements

Syntax ElementPurposeExample
resourceDeclare infrastructure resourcesresource "aws_instance" "web" {}
dataQuery existing resources (read-only)data "aws_ami" "amazon_linux" {}
variableDefine input parametersvariable "environment" {}
outputExpose output values for external referenceoutput "instance_public_ips" {}
localsDefine local variableslocals { common_tags = {} }
count / for_eachBatch resource creationcount = 3
dynamicDynamically generate nested blocksdynamic "ingress" {}

HCL supports a rich set of built-in functions (concat, merge, jsonencode, etc.) and expressions (ternary operators, for expressions, splat operator [*]). See the complete list in the Terraform Functions documentation.

IV. State Management Strategies

The State file is Terraform’s lifeline — it records resource IDs, attribute mappings, and dependency relationships. If State is lost or corrupted, Terraform cannot manage the resources it created.

4.1 Why Not Use Local State

By default, Terraform writes State to a local file terraform.tfstate. This causes serious problems in team collaboration:

  • Conflicts: Multiple people running apply simultaneously will overwrite each other’s State
  • Loss: Accidentally deleted local files cannot be recovered
  • Leaks: State files may contain passwords, keys, and other sensitive information

4.2 Remote Backend: S3 + DynamoDB Lock

Production environments must use a Remote Backend for State storage. The most classic approach on AWS is S3 for State file storage + DynamoDB for distributed locking:

# ============================================
# backend.tf — Remote State configuration
# ============================================
terraform {
  backend "s3" {
    bucket         = "sre-wang-terraform-state"
    key            = "infra/staging/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

The corresponding S3 bucket and DynamoDB table need to be created beforehand (this is the “bootstrap problem” — the first batch of infrastructure always needs manual creation):

# bootstrap.tf — Run once manually to create the State backend itself
resource "aws_s3_bucket" "terraform_state" {
  bucket = "sre-wang-terraform-state"

  lifecycle {
    prevent_destroy = true  # Prevent accidental State bucket deletion
  }
}

resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id
  versioning_configuration {
    status = "Enabled"  # Enable versioning for rollback support
  }
}

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 = "AES256"  # Server-side encryption
    }
  }
}

resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

How this architecture works:

terraform apply
┌─────────────────┐    Acquire lock   ┌──────────────┐
│  Terraform CLI   │ ──────────→ │   DynamoDB    │
│                  │             │  LockID write  │
└────────┬─────────┘             └──────────────┘
         │ Read/Write State
┌─────────────────┐
│   S3 Bucket      │
│ terraform.tfstate│  ← Versioning + encryption
└─────────────────┘
ComponentResponsibilityKey Configuration
S3Store State fileVersioning, server-side encryption, prevent_destroy
DynamoDBDistributed lock to prevent concurrent writesLockID as partition key
encryptEncrypt State file in transitencrypt = true
keyState file path, supports environment isolationinfra/staging/terraform.tfstate

By using different key paths, you can maintain independent State files for different environments (dev/staging/prod), achieving environment isolation.

4.3 Common State Commands

# List all resources in State
terraform state list

# Show details of a specific resource
terraform state show aws_instance.web[0]

# Remove a resource from State (without deleting the cloud resource)
terraform state rm aws_instance.web[0]

# Import an existing external resource into Terraform management
terraform import aws_instance.web i-0123456789abcdef0

# Force unlock (use with caution! Only when lock is stuck)
terraform force-unlock <LOCK_ID>

terraform force-unlock is a dangerous operation. Only use it when you are certain no other Terraform process is running. Incorrectly unlocking can corrupt the State file.

V. Module Reuse in Practice

When infrastructure exceeds dozens of resources, putting all code in one directory becomes hard to maintain. Modules package related resources into reusable units.

5.1 Directory Structure

terraform/
├── modules/
   └── vpc/
       ├── main.tf          # Resource definitions
       ├── variables.tf     # Module input parameters
       ├── outputs.tf       # Module output values
       └── versions.tf      # Version constraints
├── environments/
   ├── staging/
      ├── main.tf          # Module calls + environment-specific resources
      ├── variables.tf     # Environment variables
      └── backend.tf       # State backend configuration
   └── prod/
       ├── main.tf
       ├── variables.tf
       └── backend.tf

5.2 Module Internal Definition

# modules/vpc/variables.tf
variable "cidr" {
  type    = string
  default = "10.0.0.0/16"
}

variable "environment" {
  type = string
}

variable "availability_zones" {
  type    = list(string)
  default = ["ap-northeast-1a", "ap-northeast-1c"]
}
# modules/vpc/main.tf
resource "aws_vpc" "main" {
  cidr_block           = var.cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "vpc-${var.environment}"
    Environment = var.environment
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr, 8, count.index)
  availability_zone = var.availability_zones[count.index]

  map_public_ip_on_launch = true

  tags = {
    Name = "subnet-public-${var.environment}-${count.index + 1}"
  }
}
# modules/vpc/outputs.tf
output "vpc_id" {
  value = aws_vpc.main.id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

output "igw_id" {
  value = aws_internet_gateway.main.id
}

5.3 Calling a Module

# environments/staging/main.tf
module "vpc" {
  source             = "../../modules/vpc"
  cidr               = "10.1.0.0/16"
  environment        = "staging"
  availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
}

With Modules, adding a new environment is as simple as copying the directory and modifying variables — no need to rewrite VPC code. This dramatically reduces the risk of configuration inconsistency across environments.

VI. Practice: Complete AWS VPC + EC2 + RDS Deployment

Let’s tie all the concepts together with a complete example — creating a web application infrastructure with VPC, EC2, and RDS on AWS.

# ============================================
# versions.tf
# ============================================
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket         = "sre-wang-terraform-state"
    key            = "infra/webapp/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

# ============================================
# variables.tf
# ============================================
variable "environment" {
  type    = string
  default = "staging"
}

variable "db_password" {
  type      = string
  sensitive = true
}

# ============================================
# main.tf — VPC Network
# ============================================
module "vpc" {
  source      = "../../modules/vpc"
  cidr        = "10.2.0.0/16"
  environment = var.environment
}

# Security group: allow web traffic
resource "aws_security_group" "web" {
  name        = "sg-web-${var.environment}"
  vpc_id      = module.vpc.vpc_id
  description = "Web tier security group"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]  # Internal SSH only
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# Security group: database (allow access from web SG only)
resource "aws_security_group" "db" {
  name        = "sg-db-${var.environment}"
  vpc_id      = module.vpc.vpc_id
  description = "Database security group"

  ingress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# ============================================
# main.tf — EC2 Web Servers
# ============================================
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

resource "aws_instance" "web" {
  count                       = 2
  ami                         = data.aws_ami.amazon_linux.id
  instance_type               = "t3.micro"
  subnet_id                   = module.vpc.public_subnet_ids[count.index]
  vpc_security_group_ids      = [aws_security_group.web.id]
  associate_public_ip_address = true

  user_data = <<-EOF
              #!/bin/bash
              yum install -y httpd
              systemctl start httpd
              systemctl enable httpd
              echo "<h1>SRE.Wang Web Server ${count.index + 1}</h1>" > /var/www/html/index.html
              EOF

  tags = {
    Name = "web-${var.environment}-${count.index + 1}"
  }
}

# ============================================
# main.tf — RDS Database
# ============================================
resource "aws_db_subnet_group" "main" {
  name       = "db-subnet-group-${var.environment}"
  subnet_ids = module.vpc.public_subnet_ids
}

resource "aws_db_instance" "main" {
  engine                  = "mysql"
  engine_version          = "8.0"
  instance_class          = "db.t3.micro"
  allocated_storage       = 20
  storage_type            = "gp3"
  db_name                 = "webapp"
  username                = "admin"
  password                = var.db_password
  db_subnet_group_name    = aws_db_subnet_group.main.name
  vpc_security_group_ids  = [aws_security_group.db.id]
  skip_final_snapshot     = false
  backup_retention_period = 7

  tags = {
    Name = "rds-${var.environment}"
  }
}

# ============================================
# outputs.tf
# ============================================
output "web_public_ips" {
  description = "Web server public IPs"
  value       = aws_instance.web[*].public_ip
}

output "rds_endpoint" {
  description = "RDS connection endpoint"
  value       = aws_db_instance.main.endpoint
  sensitive   = true
}

Execution Flow

# 1. Initialize: download Provider plugins, configure backend
terraform init

# 2. Format check
terraform fmt -check -recursive

# 3. Static validation
terraform validate

# 4. Generate change plan
terraform plan -out=tfplan

# 5. Apply changes
terraform apply tfplan

# 6. Destroy resources (cleanup after testing)
terraform destroy
# terraform plan output example
Plan: 9 to add, 0 to change, 0 to destroy.

  # aws_instance.web[0] will be created
  + resource "aws_instance" "web" {
      + ami           = "ami-0d52744d6551d851e"
      + instance_type = "t3.micro"
      ...
    }

  # aws_db_instance.main will be created
  + resource "aws_db_instance" "main" {
      + engine         = "mysql"
      + instance_class = "db.t3.micro"
      ...
    }

terraform plan is one of Terraform’s most valuable capabilities — it shows the complete change plan before execution (+ create, ~ modify, - destroy), allowing you to review changes before committing and avoid surprises.

VII. Best Practices Summary

DimensionKey Points
State managementMust use remote backend + encryption + versioning; split State files by environment
Module designSingle responsibility, input parameter validation, clear outputs; prefer reusing Registry community Modules
Variable securityMark sensitive values with sensitive = true; inject via environment variables or Secrets Manager
Version controlLock Terraform version with required_version; lock Provider versions with required_providers
Code organizationManage in separate files: versions.tf / variables.tf / main.tf / outputs.tf
CI integrationRun terraform fmt -check + validate + plan in CI; apply after PR review
Prevent accidental deletionAdd lifecycle { prevent_destroy = true } to critical resources
Tagging strategyUse Provider-level default_tags for unified tagging, enabling cost allocation and resource tracking

Terraform’s learning curve is primarily in HCL syntax and State management. Once you understand the core concept of “declarative + state convergence,” combined with Module reuse and remote State, you can build a maintainable, auditable infrastructure codebase. We recommend starting with the VPC + EC2 + RDS example in this article and gradually migrating your manually managed infrastructure to IaC.

Further Reading:

References & Acknowledgments

This article referenced the following materials during writing. We thank the original authors for their contributions:

  1. Terraform Official Documentation — HashiCorp, referenced for Terraform Official Documentation
  2. Terraform Registry — Registry, referenced for Terraform Registry
  3. Terraform Functions documentation — HashiCorp, referenced for Terraform Functions documentation
  4. Terraform Best Practices — HashiCorp, referenced for Terraform Best Practices