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:
| Dimension | Declarative | Imperative |
|---|---|---|
| Core philosophy | Describe “what” (desired state) | Describe “how” (operation steps) |
| Representative tools | Terraform, CloudFormation, Pulumi | Ansible (partially), Shell scripts, AWS CLI |
| Idempotency | Naturally idempotent — repeated execution yields the same result | Must ensure idempotency manually |
| State awareness | Tool tracks current state, automatically computes diffs | No state awareness, executes step by step |
| Readability | Close to configuration files, easy to understand | Close 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 Element | Purpose | Example |
|---|---|---|
resource | Declare infrastructure resources | resource "aws_instance" "web" {} |
data | Query existing resources (read-only) | data "aws_ami" "amazon_linux" {} |
variable | Define input parameters | variable "environment" {} |
output | Expose output values for external reference | output "instance_public_ips" {} |
locals | Define local variables | locals { common_tags = {} } |
count / for_each | Batch resource creation | count = 3 |
dynamic | Dynamically generate nested blocks | dynamic "ingress" {} |
HCL supports a rich set of built-in functions (
concat,merge,jsonencode, etc.) and expressions (ternary operators,forexpressions, 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
applysimultaneously 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
└─────────────────┘
| Component | Responsibility | Key Configuration |
|---|---|---|
| S3 | Store State file | Versioning, server-side encryption, prevent_destroy |
| DynamoDB | Distributed lock to prevent concurrent writes | LockID as partition key |
| encrypt | Encrypt State file in transit | encrypt = true |
| key | State file path, supports environment isolation | infra/staging/terraform.tfstate |
By using different
keypaths, 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-unlockis 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 planis 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
| Dimension | Key Points |
|---|---|
| State management | Must use remote backend + encryption + versioning; split State files by environment |
| Module design | Single responsibility, input parameter validation, clear outputs; prefer reusing Registry community Modules |
| Variable security | Mark sensitive values with sensitive = true; inject via environment variables or Secrets Manager |
| Version control | Lock Terraform version with required_version; lock Provider versions with required_providers |
| Code organization | Manage in separate files: versions.tf / variables.tf / main.tf / outputs.tf |
| CI integration | Run terraform fmt -check + validate + plan in CI; apply after PR review |
| Prevent accidental deletion | Add lifecycle { prevent_destroy = true } to critical resources |
| Tagging strategy | Use 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:
- Terraform Official Documentation — HashiCorp, referenced for Terraform Official Documentation
- Terraform Registry — Registry, referenced for Terraform Registry
- Terraform Functions documentation — HashiCorp, referenced for Terraform Functions documentation
- Terraform Best Practices — HashiCorp, referenced for Terraform Best Practices