AWS Infrastructure as Code with Terraform: A Practical Guide
Sabin Shrestha
Full-Stack Developer
Infrastructure as Code (IaC) has revolutionized how we manage cloud resources. Instead of clicking through AWS Console, we define infrastructure in declarative configuration files that can be versioned, reviewed, and reused.
Why Terraform?
Terraform is a powerful IaC tool that works across multiple cloud providers. Key advantages include:
- Declarative Syntax - Describe what you want, not how to get there
- State Management - Tracks resource changes over time
- Plan Before Apply - Preview changes before execution
- Module System - Reusable infrastructure components
- Multi-Cloud - Works with AWS, GCP, Azure, and more
Project Structure
A well-organized Terraform project makes collaboration easier:
infrastructure/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── prod/
├── modules/
│ ├── vpc/
│ ├── ec2/
│ ├── rds/
│ └── s3/
├── backend.tf
└── versions.tf
Setting Up the Backend
Always use remote state for team collaboration. Here's an S3 backend configuration:
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "infrastructure/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
Create the S3 bucket and DynamoDB table for state locking:
# bootstrap/main.tf - Run this once to create backend resources
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-terraform-state-bucket"
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 = "AES256"
}
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
VPC Module
A reusable VPC module with public and private subnets:
# modules/vpc/main.tf
variable "name" {
description = "Name prefix for resources"
type = string
}
variable "cidr_block" {
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 "environment" {
description = "Environment name"
type = string
}
locals {
public_subnets = [for i, az in var.availability_zones : cidrsubnet(var.cidr_block, 8, i)]
private_subnets = [for i, az in var.availability_zones : cidrsubnet(var.cidr_block, 8, i + 100)]
}
# VPC
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.name}-vpc"
Environment = var.environment
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.name}-igw"
Environment = var.environment
}
}
# Public Subnets
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = local.public_subnets[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.name}-public-${var.availability_zones[count.index]}"
Environment = var.environment
Type = "public"
}
}
# Private Subnets
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = local.private_subnets[count.index]
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.name}-private-${var.availability_zones[count.index]}"
Environment = var.environment
Type = "private"
}
}
# NAT Gateway (one per AZ for high availability)
resource "aws_eip" "nat" {
count = length(var.availability_zones)
domain = "vpc"
tags = {
Name = "${var.name}-nat-eip-${count.index + 1}"
Environment = var.environment
}
depends_on = [aws_internet_gateway.main]
}
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.name}-nat-${count.index + 1}"
Environment = var.environment
}
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 = {
Name = "${var.name}-public-rt"
Environment = var.environment
}
}
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.name}-private-rt-${count.index + 1}"
Environment = var.environment
}
}
# Route Table Associations
resource "aws_route_table_association" "public" {
count = length(var.availability_zones)
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.availability_zones)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# Outputs
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
}
RDS Module
A secure RDS PostgreSQL instance with proper networking:
# modules/rds/main.tf
variable "name" {
type = string
}
variable "vpc_id" {
type = string
}
variable "subnet_ids" {
type = list(string)
}
variable "instance_class" {
type = string
default = "db.t3.micro"
}
variable "allocated_storage" {
type = number
default = 20
}
variable "database_name" {
type = string
}
variable "master_username" {
type = string
sensitive = true
}
variable "master_password" {
type = string
sensitive = true
}
variable "allowed_security_groups" {
type = list(string)
}
variable "environment" {
type = string
}
# Subnet Group
resource "aws_db_subnet_group" "main" {
name = "${var.name}-db-subnet-group"
subnet_ids = var.subnet_ids
tags = {
Name = "${var.name}-db-subnet-group"
Environment = var.environment
}
}
# Security Group
resource "aws_security_group" "rds" {
name = "${var.name}-rds-sg"
description = "Security group for RDS instance"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = var.allowed_security_groups
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.name}-rds-sg"
Environment = var.environment
}
}
# RDS Instance
resource "aws_db_instance" "main" {
identifier = "${var.name}-postgres"
engine = "postgres"
engine_version = "15.4"
instance_class = var.instance_class
allocated_storage = var.allocated_storage
max_allocated_storage = var.allocated_storage * 2
storage_encrypted = true
storage_type = "gp3"
db_name = var.database_name
username = var.master_username
password = var.master_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
multi_az = var.environment == "prod" ? true : false
publicly_accessible = false
deletion_protection = var.environment == "prod" ? true : false
skip_final_snapshot = var.environment != "prod"
final_snapshot_identifier = var.environment == "prod" ? "${var.name}-final-snapshot" : null
backup_retention_period = var.environment == "prod" ? 7 : 1
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
performance_insights_enabled = var.environment == "prod" ? true : false
tags = {
Name = "${var.name}-postgres"
Environment = var.environment
}
}
output "endpoint" {
value = aws_db_instance.main.endpoint
}
output "security_group_id" {
value = aws_security_group.rds.id
}
Using Modules in Environment Configuration
# environments/dev/main.tf
provider "aws" {
region = var.aws_region
}
module "vpc" {
source = "../../modules/vpc"
name = var.project_name
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
environment = "dev"
}
module "rds" {
source = "../../modules/rds"
name = var.project_name
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
database_name = "appdb"
master_username = var.db_username
master_password = var.db_password
allowed_security_groups = [module.app.security_group_id]
environment = "dev"
}
Best Practices
1. Use Variables and Locals
Never hardcode values. Use variables for customization and locals for computed values:
variable "environment" {
type = string
}
locals {
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = var.project_name
}
}
2. Use Data Sources for Existing Resources
Reference existing resources instead of hardcoding IDs:
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
3. Implement Proper State Locking
Always use DynamoDB for state locking in team environments to prevent concurrent modifications.
4. Use Workspaces for Environment Isolation
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select dev
CI/CD Integration
Integrate Terraform with your CI/CD pipeline:
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.6.0
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init
working-directory: ./infrastructure/environments/dev
- name: Terraform Plan
run: terraform plan -no-color
working-directory: ./infrastructure/environments/dev
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve
working-directory: ./infrastructure/environments/dev
Conclusion
Terraform provides a powerful way to manage AWS infrastructure:
- Version Control - Track all infrastructure changes
- Reproducibility - Create identical environments
- Collaboration - Team reviews via pull requests
- Documentation - Code is documentation
Start small, use modules, and gradually build your infrastructure library.
Resources
- Terraform AWS Provider Documentation
- AWS Well-Architected Framework
- Terraform Best Practices Guide
Related Articles
Building Scalable REST APIs with NestJS and PostgreSQL
A comprehensive guide to architecting production-ready REST APIs using NestJS, Prisma ORM, and PostgreSQL. Learn best practices for validation, error handling, and database design.
Docker Multi-Stage Builds for Production Node.js Applications
Optimize your Docker images with multi-stage builds. Learn techniques to reduce image size, improve security, and speed up deployments for Node.js applications.
PostgreSQL Performance Tuning: From Slow Queries to Sub-Second Responses
Deep dive into PostgreSQL optimization techniques. Learn about indexing strategies, query analysis with EXPLAIN, connection pooling, and configuration tuning for high-traffic applications.