Part 2: Core AWS Resources and Basic Architecture

Cover Image for Part 2: Core AWS Resources and Basic Architecture
DevOps5 min read

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

  1. Initialize Terraform:
terraform init
  1. Validate configuration:
terraform validate
  1. Plan the deployment:
terraform plan -var-file="environments/dev/terraform.tfvars"
  1. 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

  1. Right-size instances: Start with smaller instances and scale as needed
  2. Use Reserved Instances: For predictable workloads
  3. Enable auto-scaling: Match capacity to demand
  4. 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

Next Part 3: Advanced Infrastructure Patterns