Introduction
Now that you understand Terraform basics, let's build a real AWS infrastructure. In this part, we'll create a foundational architecture including VPC, subnets, security groups, and EC2 instances. This forms the backbone of most AWS applications.
Project Overview
We'll build a typical 3-tier architecture:
- Presentation Tier: Public subnets with internet access
- Application Tier: Private subnets for application servers
- Data Tier: Private subnets for databases
Setting Up Project Structure
Let's organize our code properly from the beginning:
mkdir aws-terraform-project
cd aws-terraform-project
# Create directory structure
mkdir -p {modules,environments/dev,environments/prod}
Understanding AWS Networking
VPC (Virtual Private Cloud)
A VPC is your isolated network in AWS. Think of it as your private data center in the cloud.
# main.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main-vpc"
Environment = var.environment
}
}
Subnets
Subnets divide your VPC into smaller networks. We'll create public and private subnets across multiple availability zones for high availability.
# Get available AZs
data "aws_availability_zones" "available" {
state = "available"
}
# Public subnets
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "public-subnet-${count.index + 1}"
Type = "public"
}
}
# Private subnets
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "private-subnet-${count.index + 1}"
Type = "private"
}
}
Internet Connectivity
Internet Gateway
Enables internet access for your VPC:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "main-igw"
}
}
NAT Gateway
Allows private subnet resources to access the internet (outbound only):
# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
count = 2
domain = "vpc"
depends_on = [aws_internet_gateway.main]
tags = {
Name = "nat-eip-${count.index + 1}"
}
}
# NAT Gateways
resource "aws_nat_gateway" "main" {
count = 2
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = {
Name = "nat-gateway-${count.index + 1}"
}
}
Routing Tables
Route tables determine where network traffic goes:
# Public route table
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 = "public-rt"
}
}
# Associate public subnets with public route table
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
}
# Private route tables (one per AZ for high availability)
resource "aws_route_table" "private" {
count = length(aws_subnet.private)
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 = "private-rt-${count.index + 1}"
}
}
# Associate private subnets with private route tables
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
Security Groups
Security groups act as virtual firewalls controlling traffic to/from resources:
# Web server security group
resource "aws_security_group" "web" {
name = "web-sg"
description = "Security group for web servers"
vpc_id = aws_vpc.main.id
# HTTP access from anywhere
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# HTTPS access from anywhere
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# SSH access from admin subnet only
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.1.0/24"] # Admin subnet
}
# All outbound traffic
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "web-security-group"
}
}
# Database security group
resource "aws_security_group" "database" {
name = "db-sg"
description = "Security group for database servers"
vpc_id = aws_vpc.main.id
# MySQL/Aurora access from app servers only
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
# PostgreSQL access from app servers only
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
# Outbound for updates only
egress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "database-security-group"
}
}
# Application server security group
resource "aws_security_group" "app" {
name = "app-sg"
description = "Security group for application servers"
vpc_id = aws_vpc.main.id
# App port from load balancer
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.web.id]
}
# SSH access from bastion
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
security_groups = [aws_security_group.bastion.id]
}
# All outbound
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "app-security-group"
}
}
# Bastion host security group
resource "aws_security_group" "bastion" {
name = "bastion-sg"
description = "Security group for bastion host"
vpc_id = aws_vpc.main.id
# SSH access from your IP only
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.admin_cidr_block]
}
# All outbound
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "bastion-security-group"
}
}
Key Pairs and EC2 Instances
Key Pair for SSH Access
# Generate a key pair
resource "aws_key_pair" "main" {
key_name = "${var.environment}-keypair"
public_key = file("~/.ssh/id_rsa.pub") # Your public key
tags = {
Name = "${var.environment}-keypair"
}
}
AMI Data Source
# Get latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
EC2 Instances
# Bastion host (jump server)
resource "aws_instance" "bastion" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
key_name = aws_key_pair.main.key_name
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.bastion.id]
# Enable detailed monitoring
monitoring = true
# Root volume configuration
root_block_device {
volume_type = "gp3"
volume_size = 20
delete_on_termination = true
encrypted = true
}
# User data script
user_data = base64encode(templatefile("${path.module}/scripts/bastion-setup.sh", {
hostname = "bastion-${var.environment}"
}))
tags = {
Name = "bastion-${var.environment}"
Type = "bastion"
}
}
# Web servers
resource "aws_instance" "web" {
count = 2
ami = data.aws_ami.amazon_linux.id
instance_type = var.web_instance_type
key_name = aws_key_pair.main.key_name
subnet_id = aws_subnet.public[count.index].id
vpc_security_group_ids = [aws_security_group.web.id]
# Enable detailed monitoring
monitoring = true
# Root volume configuration
root_block_device {
volume_type = "gp3"
volume_size = 20
delete_on_termination = true
encrypted = true
}
# User data script
user_data = base64encode(templatefile("${path.module}/scripts/web-server-setup.sh", {
instance_id = count.index + 1
}))
tags = {
Name = "web-${count.index + 1}-${var.environment}"
Type = "web"
}
}
# Application servers
resource "aws_instance" "app" {
count = 2
ami = data.aws_ami.amazon_linux.id
instance_type = var.app_instance_type
key_name = aws_key_pair.main.key_name
subnet_id = aws_subnet.private[count.index].id
vpc_security_group_ids = [aws_security_group.app.id]
# Enable detailed monitoring
monitoring = true
# Root volume configuration
root_block_device {
volume_type = "gp3"
volume_size = 30
delete_on_termination = true
encrypted = true
}
# User data script
user_data = base64encode(templatefile("${path.module}/scripts/app-server-setup.sh", {
instance_id = count.index + 1
db_endpoint = aws_db_instance.main.endpoint
}))
tags = {
Name = "app-${count.index + 1}-${var.environment}"
Type = "application"
}
}
RDS Database
# DB subnet group
resource "aws_db_subnet_group" "main" {
name = "${var.environment}-db-subnet-group"
subnet_ids = aws_subnet.private[*].id
tags = {
Name = "${var.environment}-db-subnet-group"
}
}
# RDS instance
resource "aws_db_instance" "main" {
identifier = "${var.environment}-database"
# Engine configuration
engine = "postgres"
engine_version = "15.4"
instance_class = var.db_instance_type
# Storage configuration
allocated_storage = 20
max_allocated_storage = 100
storage_type = "gp3"
storage_encrypted = true
# Database configuration
db_name = var.db_name
username = var.db_username
password = var.db_password
# Network configuration
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.database.id]
# Backup configuration
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "sun:04:00-sun:05:00"
# Monitoring
monitoring_interval = 60
monitoring_role_arn = aws_iam_role.rds_enhanced_monitoring.arn
# Performance Insights
performance_insights_enabled = true
# Deletion protection
deletion_protection = var.environment == "prod" ? true : false
skip_final_snapshot = var.environment == "dev" ? true : false
tags = {
Name = "${var.environment}-database"
}
}
IAM Roles and Policies
# RDS Enhanced Monitoring role
resource "aws_iam_role" "rds_enhanced_monitoring" {
name = "${var.environment}-rds-enhanced-monitoring"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "monitoring.rds.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.environment}-rds-enhanced-monitoring"
}
}
# Attach AWS managed policy
resource "aws_iam_role_policy_attachment" "rds_enhanced_monitoring" {
role = aws_iam_role.rds_enhanced_monitoring.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole"
}
# EC2 instance profile for web servers
resource "aws_iam_role" "web_instance_role" {
name = "${var.environment}-web-instance-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.environment}-web-instance-role"
}
}
# Policy for CloudWatch agent
resource "aws_iam_role_policy" "web_cloudwatch" {
name = "${var.environment}-web-cloudwatch-policy"
role = aws_iam_role.web_instance_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"cloudwatch:PutMetricData",
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "*"
}
]
})
}
# Instance profile
resource "aws_iam_instance_profile" "web" {
name = "${var.environment}-web-instance-profile"
role = aws_iam_role.web_instance_role.name
}
Variables
Create variables.tf:
variable "environment" {
description = "Environment name"
type = string
}
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "admin_cidr_block" {
description = "CIDR block for admin access"
type = string
default = "0.0.0.0/0" # Change to your IP for security
}
variable "web_instance_type" {
description = "Instance type for web servers"
type = string
default = "t3.micro"
}
variable "app_instance_type" {
description = "Instance type for app servers"
type = string
default = "t3.small"
}
variable "db_instance_type" {
description = "RDS instance type"
type = string
default = "db.t3.micro"
}
variable "db_name" {
description = "Database name"
type = string
default = "myapp"
}
variable "db_username" {
description = "Database username"
type = string
default = "admin"
}
variable "db_password" {
description = "Database password"
type = string
sensitive = true
}
Outputs
Create outputs.tf:
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of the private subnets"
value = aws_subnet.private[*].id
}
output "bastion_public_ip" {
description = "Public IP of bastion host"
value = aws_instance.bastion.public_ip
}
output "web_server_ips" {
description = "Public IPs of web servers"
value = aws_instance.web[*].public_ip
}
output "database_endpoint" {
description = "RDS instance endpoint"
value = aws_db_instance.main.endpoint
sensitive = true
}
User Data Scripts
Create scripts/bastion-setup.sh:
#!/bin/bash
yum update -y
yum install -y htop tree
# Set hostname
hostnamectl set-hostname ${hostname}
# Install CloudWatch agent
yum install -y amazon-cloudwatch-agent
# Configure SSH
sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart sshd
Create scripts/web-server-setup.sh:
#!/bin/bash
yum update -y
yum install -y httpd
# Start and enable httpd
systemctl start httpd
systemctl enable httpd
# Create a simple index page
cat <<EOF > /var/www/html/index.html
<h1>Web Server ${instance_id}</h1>
<p>This is web server ${instance_id} running on $(hostname)</p>
<p>Current time: $(date)</p>
EOF
# Install CloudWatch agent
yum install -y amazon-cloudwatch-agent
Terraform Configuration Files
Create terraform.tf:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Configure remote state (optional)
backend "s3" {
bucket = "your-terraform-state-bucket"
key = "dev/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = var.aws_region
}
Environment-Specific Configurations
Create environments/dev/terraform.tfvars:
environment = "dev"
aws_region = "us-east-1"
# Instance types for dev
web_instance_type = "t3.micro"
app_instance_type = "t3.micro"
db_instance_type = "db.t3.micro"
# Database configuration
db_name = "devapp"
db_username = "devuser"
db_password = "changeme123!" # Use AWS Secrets Manager in production
Deploying Your Infrastructure
- Initialize Terraform:
terraform init
- Validate configuration:
terraform validate
- Plan the deployment:
terraform plan -var-file="environments/dev/terraform.tfvars"
- Apply the changes:
terraform apply -var-file="environments/dev/terraform.tfvars"
Security Best Practices
1. Network Segmentation
- Use private subnets for application and database tiers
- Implement proper security group rules
- Use NACLs for additional network-level security
2. Access Control
- Use bastion hosts for SSH access
- Implement least-privilege IAM policies
- Enable MFA where possible
3. Data Protection
- Enable encryption at rest for EBS volumes
- Enable encryption for RDS instances
- Use AWS KMS for key management
4. Monitoring and Logging
- Enable VPC Flow Logs
- Configure CloudWatch monitoring
- Set up CloudTrail for API logging
Cost Optimization Tips
- Right-size instances: Start with smaller instances and scale as needed
- Use Reserved Instances: For predictable workloads
- Enable auto-scaling: Match capacity to demand
- Monitor unused resources: Regular cleanup of orphaned resources
Common Troubleshooting
Connection Issues
- Check security group rules
- Verify route table configurations
- Confirm NAT Gateway functionality
Instance Launch Problems
- Verify AMI availability in the region
- Check subnet capacity
- Review IAM permissions
Next Steps
You now have a solid foundation with:
- ✅ VPC with public/private subnets
- ✅ Proper routing and internet connectivity
- ✅ Security groups with layered security
- ✅ EC2 instances in multi-tier architecture
- ✅ RDS database with backups
- ✅ IAM roles and policies
In Part 3, we'll add advanced features like:
- Application Load Balancers
- Auto Scaling Groups
- Container orchestration with ECS
- Caching with ElastiCache
- CDN with CloudFront
Key Takeaways:
- Always use multiple AZs for high availability
- Implement security in layers (network + instance + application)
- Use infrastructure as code for consistency
- Monitor and log everything for troubleshooting

