# Infrastructure as Code with Terraform on AWS
Introduction
Managing cloud infrastructure manually through the AWS Console is error-prone and doesn't scale. Terraform by HashiCorp lets you define infrastructure as code (IaC), enabling version control, reproducibility, and automation.
In this tutorial, you'll build a production-ready AWS environment with:
- A custom VPC with public and private subnets
- An EC2 web server with security groups
- An RDS MySQL database in a private subnet
- Outputs for easy access to resources
- AWS account with IAM credentials
- Terraform >= 1.6 installed
- Basic understanding of AWS networking
Prerequisites
Step 1: Install and Configure Terraform
# Install Terraform (macOS)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Verify installation
terraform version
# Configure AWS credentials
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_DEFAULT_REGION="ap-northeast-1"
Step 2: Project Structure
Create the following directory structure:
terraform-aws-demo/
├── main.tf # Root module
├── variables.tf # Input variables
├── outputs.tf # Output values
├── terraform.tfvars # Variable values
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── ec2/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── rds/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
Step 3: Define the VPC Module
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
Environment = var.environment
}
}
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 = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
}
}
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 = var.availability_zones[count.index]
tags = {
Name = "${var.project_name}-private-${count.index + 1}"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
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.project_name}-public-rt"
}
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# modules/vpc/variables.tf
variable "project_name" {
type = string
}
variable "environment" {
type = string
default = "dev"
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
type = list(string)
default = ["10.0.10.0/24", "10.0.20.0/24"]
}
variable "availability_zones" {
type = list(string)
default = ["ap-northeast-1a", "ap-northeast-1c"]
}
# modules/vpc/outputs.tf
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
}
Step 4: Define the EC2 Module
# modules/ec2/main.tf
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_security_group" "web" {
name_prefix = "${var.project_name}-web-"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = var.key_name
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd php php-mysqlnd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Terraform!</h1>" > /var/www/html/index.html
EOF
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
tags = {
Name = "${var.project_name}-web"
Environment = var.environment
}
}
Step 5: Define the RDS Module
# modules/rds/main.tf
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet"
subnet_ids = var.private_subnet_ids
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_security_group" "db" {
name_prefix = "${var.project_name}-db-"
vpc_id = var.vpc_id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [var.web_sg_id]
}
tags = {
Name = "${var.project_name}-db-sg"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "mysql"
engine_version = "8.0"
instance_class = var.db_instance_class
allocated_storage = 20
storage_type = "gp3"
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.db.id]
skip_final_snapshot = true
multi_az = false
tags = {
Name = "${var.project_name}-db"
Environment = var.environment
}
}
Step 6: Root Module Configuration
# main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
module "vpc" {
source = "./modules/vpc"
project_name = var.project_name
environment = var.environment
}
module "ec2" {
source = "./modules/ec2"
project_name = var.project_name
environment = var.environment
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnet_ids[0]
instance_type = "t3.micro"
key_name = var.key_name
ssh_cidr = var.ssh_cidr
}
module "rds" {
source = "./modules/rds"
project_name = var.project_name
environment = var.environment
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
web_sg_id = module.ec2.web_sg_id
db_name = "appdb"
db_username = var.db_username
db_password = var.db_password
}
# variables.tf
variable "project_name" {
type = string
default = "techsfree-demo"
}
variable "environment" {
type = string
default = "dev"
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "key_name" {
type = string
}
variable "ssh_cidr" {
type = string
default = "0.0.0.0/0"
}
variable "db_username" {
type = string
sensitive = true
}
variable "db_password" {
type = string
sensitive = true
}
# outputs.tf
output "vpc_id" {
value = module.vpc.vpc_id
}
output "web_public_ip" {
value = module.ec2.public_ip
}
output "rds_endpoint" {
value = module.rds.endpoint
sensitive = true
}
Step 7: Deploy Your Infrastructure
# Initialize Terraform (downloads providers)
terraform init
# Preview changes
terraform plan -var="key_name=my-key" \
-var="db_username=admin" \
-var="db_password=SecurePass123!"
# Apply changes
terraform apply -auto-approve \
-var="key_name=my-key" \
-var="db_username=admin" \
-var="db_password=SecurePass123!"
# View outputs
terraform output
# Destroy when done (avoid charges!)
terraform destroy -auto-approve
Step 8: Best Practices
Use Remote State
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Use tfvars for Environments
# environments/prod.tfvars
project_name = "techsfree"
environment = "prod"
instance_type = "t3.small"
terraform apply -var-file="environments/prod.tfvars"
Use terraform fmt and terraform validate
# Format all .tf files
terraform fmt -recursive
# Validate configuration
terraform validate
Summary
You've learned to:
Terraform makes infrastructure reproducible and auditable. Combine it with CI/CD pipelines for fully automated deployments.
Next Steps
terragrunt for DRY configurations