AWS for DevOps — Project 1: Production VPC

9 minute read

Project Overview

Build a production-grade VPC with public subnets, private subnets, database subnets, NAT Gateways, and proper security group configurations across two Availability Zones.

Real-World Analogy: You’re designing the office building layout — where the reception area is (public subnets), where employees work (private subnets), and where the vault is (database subnets). The NAT Gateway is like a secure mail room that lets employees send letters out without exposing their location.

Detail Value
Difficulty ⭐⭐ (Intermediate)
Time 1-2 hours
Cost ~$0.045/hr for NAT Gateway
Services VPC, Subnets, IGW, NAT GW, Route Tables, NACLs, Security Groups

Architecture

┌───────────────────────────────────────────────────────────────┐
│                    VPC: 10.0.0.0/16                           │
│                                                               │
│  ┌─── AZ-a ────────────────┐  ┌─── AZ-b ────────────────┐  │
│  │                          │  │                          │  │
│  │  ┌──────────────────┐   │  │  ┌──────────────────┐   │  │
│  │  │ Public Subnet    │   │  │  │ Public Subnet    │   │  │
│  │  │ 10.0.1.0/24      │   │  │  │ 10.0.2.0/24      │   │  │
│  │  │  ┌────────────┐  │   │  │  │  ┌────────────┐  │   │  │
│  │  │  │  NAT GW    │  │   │  │  │  │  NAT GW    │  │   │  │
│  │  │  └────────────┘  │   │  │  │  └────────────┘  │   │  │
│  │  └──────────────────┘   │  │  └──────────────────┘   │  │
│  │           ↑              │  │           ↑              │  │
│  │  ┌──────────────────┐   │  │  ┌──────────────────┐   │  │
│  │  │ Private Subnet   │   │  │  │ Private Subnet   │   │  │
│  │  │ 10.0.11.0/24     │   │  │  │ 10.0.12.0/24     │   │  │
│  │  │  (App Servers)   │   │  │  │  (App Servers)   │   │  │
│  │  └──────────────────┘   │  │  └──────────────────┘   │  │
│  │                          │  │                          │  │
│  │  ┌──────────────────┐   │  │  ┌──────────────────┐   │  │
│  │  │ Database Subnet  │   │  │  │ Database Subnet  │   │  │
│  │  │ 10.0.21.0/24     │   │  │  │ 10.0.22.0/24     │   │  │
│  │  │  (RDS, ElastiC.) │   │  │  │  (RDS, ElastiC.) │   │  │
│  │  └──────────────────┘   │  │  └──────────────────┘   │  │
│  └──────────────────────────┘  └──────────────────────────┘  │
│                                                               │
│  ┌──────────────┐                                            │
│  │ Internet GW  │ ←── Route to 0.0.0.0/0 from public subs   │
│  └──────────────┘                                            │
└───────────────────────────────────────────────────────────────┘

Step 1 — Create the VPC

# Create the VPC
$ aws ec2 create-vpc \
    --cidr-block 10.0.0.0/16 \
    --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=prod-vpc},{Key=Project,Value=practice-1}]'
{
    "Vpc": {
        "VpcId": "vpc-0abc123def456789",
        "CidrBlock": "10.0.0.0/16",
        "State": "available"
    }
}

# Enable DNS hostnames (required for many AWS services)
$ aws ec2 modify-vpc-attribute \
    --vpc-id vpc-0abc123def456789 \
    --enable-dns-hostnames '{"Value": true}'

Why /16? A /16 CIDR gives you 65,536 IP addresses — plenty of room for growth. Production VPCs often use /16 to avoid running out of IPs.


Step 2 — Create Subnets

Public Subnets (for load balancers, bastion hosts)

# Public Subnet in AZ-a
$ aws ec2 create-subnet \
    --vpc-id vpc-0abc123def456789 \
    --cidr-block 10.0.1.0/24 \
    --availability-zone us-east-1a \
    --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=prod-public-1a},{Key=Tier,Value=public}]'
{
    "Subnet": {
        "SubnetId": "subnet-0pub1a",
        "CidrBlock": "10.0.1.0/24",
        "AvailabilityZone": "us-east-1a"
    }
}

# Public Subnet in AZ-b
$ aws ec2 create-subnet \
    --vpc-id vpc-0abc123def456789 \
    --cidr-block 10.0.2.0/24 \
    --availability-zone us-east-1b \
    --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=prod-public-1b},{Key=Tier,Value=public}]'

Private Subnets (for application servers)

# Private Subnet in AZ-a
$ aws ec2 create-subnet \
    --vpc-id vpc-0abc123def456789 \
    --cidr-block 10.0.11.0/24 \
    --availability-zone us-east-1a \
    --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=prod-private-1a},{Key=Tier,Value=private}]'

# Private Subnet in AZ-b
$ aws ec2 create-subnet \
    --vpc-id vpc-0abc123def456789 \
    --cidr-block 10.0.12.0/24 \
    --availability-zone us-east-1b \
    --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=prod-private-1b},{Key=Tier,Value=private}]'

Database Subnets (for RDS, ElastiCache)

# Database Subnet in AZ-a
$ aws ec2 create-subnet \
    --vpc-id vpc-0abc123def456789 \
    --cidr-block 10.0.21.0/24 \
    --availability-zone us-east-1a \
    --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=prod-db-1a},{Key=Tier,Value=database}]'

# Database Subnet in AZ-b
$ aws ec2 create-subnet \
    --vpc-id vpc-0abc123def456789 \
    --cidr-block 10.0.22.0/24 \
    --availability-zone us-east-1b \
    --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=prod-db-1b},{Key=Tier,Value=database}]'

Subnet Summary

Subnet CIDR AZ Purpose
prod-public-1a 10.0.1.0/24 us-east-1a ALB, Bastion
prod-public-1b 10.0.2.0/24 us-east-1b ALB, Bastion
prod-private-1a 10.0.11.0/24 us-east-1a App servers
prod-private-1b 10.0.12.0/24 us-east-1b App servers
prod-db-1a 10.0.21.0/24 us-east-1a RDS, ElastiCache
prod-db-1b 10.0.22.0/24 us-east-1b RDS, ElastiCache

Step 3 — Create Internet Gateway

# Create Internet Gateway
$ aws ec2 create-internet-gateway \
    --tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=prod-igw}]'
{
    "InternetGateway": {
        "InternetGatewayId": "igw-0abc123"
    }
}

# Attach to VPC
$ aws ec2 attach-internet-gateway \
    --internet-gateway-id igw-0abc123 \
    --vpc-id vpc-0abc123def456789

Step 4 — Create NAT Gateways

# Allocate Elastic IPs for NAT Gateways
$ aws ec2 allocate-address --domain vpc
{
    "AllocationId": "eipalloc-0nat1a",
    "PublicIp": "52.1.2.3"
}

$ aws ec2 allocate-address --domain vpc
{
    "AllocationId": "eipalloc-0nat1b",
    "PublicIp": "52.4.5.6"
}

# Create NAT Gateway in Public Subnet AZ-a
$ aws ec2 create-nat-gateway \
    --subnet-id subnet-0pub1a \
    --allocation-id eipalloc-0nat1a \
    --tag-specifications 'ResourceType=natgateway,Tags=[{Key=Name,Value=prod-nat-1a}]'
{
    "NatGateway": {
        "NatGatewayId": "nat-0abc1a",
        "State": "pending"
    }
}

# Create NAT Gateway in Public Subnet AZ-b
$ aws ec2 create-nat-gateway \
    --subnet-id subnet-0pub1b \
    --allocation-id eipalloc-0nat1b \
    --tag-specifications 'ResourceType=natgateway,Tags=[{Key=Name,Value=prod-nat-1b}]'

# Wait for NAT Gateways to become available
$ aws ec2 wait nat-gateway-available --nat-gateway-ids nat-0abc1a nat-0abc1b

Why two NAT Gateways? One per AZ provides high availability. If AZ-a goes down, instances in AZ-b still have internet access through their own NAT Gateway.


Step 5 — Configure Route Tables

Public Route Table

# Create public route table
$ aws ec2 create-route-table \
    --vpc-id vpc-0abc123def456789 \
    --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=prod-public-rt}]'
{
    "RouteTable": {
        "RouteTableId": "rtb-0public"
    }
}

# Add route to Internet Gateway
$ aws ec2 create-route \
    --route-table-id rtb-0public \
    --destination-cidr-block 0.0.0.0/0 \
    --gateway-id igw-0abc123

# Associate with public subnets
$ aws ec2 associate-route-table \
    --route-table-id rtb-0public \
    --subnet-id subnet-0pub1a

$ aws ec2 associate-route-table \
    --route-table-id rtb-0public \
    --subnet-id subnet-0pub1b

Private Route Tables (one per AZ)

# Private route table for AZ-a
$ aws ec2 create-route-table \
    --vpc-id vpc-0abc123def456789 \
    --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=prod-private-rt-1a}]'
{
    "RouteTable": {
        "RouteTableId": "rtb-0priv1a"
    }
}

$ aws ec2 create-route \
    --route-table-id rtb-0priv1a \
    --destination-cidr-block 0.0.0.0/0 \
    --nat-gateway-id nat-0abc1a

$ aws ec2 associate-route-table --route-table-id rtb-0priv1a --subnet-id subnet-0priv1a
$ aws ec2 associate-route-table --route-table-id rtb-0priv1a --subnet-id subnet-0db1a

# Private route table for AZ-b
$ aws ec2 create-route-table \
    --vpc-id vpc-0abc123def456789 \
    --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=prod-private-rt-1b}]'
{
    "RouteTable": {
        "RouteTableId": "rtb-0priv1b"
    }
}

$ aws ec2 create-route \
    --route-table-id rtb-0priv1b \
    --destination-cidr-block 0.0.0.0/0 \
    --nat-gateway-id nat-0abc1b

$ aws ec2 associate-route-table --route-table-id rtb-0priv1b --subnet-id subnet-0priv1b
$ aws ec2 associate-route-table --route-table-id rtb-0priv1b --subnet-id subnet-0db1b

Route Table Summary

Route Table Destination Target Subnets
prod-public-rt 0.0.0.0/0 igw-0abc123 public-1a, public-1b
prod-private-rt-1a 0.0.0.0/0 nat-0abc1a private-1a, db-1a
prod-private-rt-1b 0.0.0.0/0 nat-0abc1b private-1b, db-1b

Step 6 — Create Security Groups

# ALB Security Group — allows HTTP/HTTPS from internet
$ aws ec2 create-security-group \
    --group-name prod-alb-sg \
    --description "ALB Security Group" \
    --vpc-id vpc-0abc123def456789 \
    --tag-specifications 'ResourceType=security-group,Tags=[{Key=Name,Value=prod-alb-sg}]'
{
    "GroupId": "sg-0alb"
}

$ aws ec2 authorize-security-group-ingress \
    --group-id sg-0alb \
    --protocol tcp --port 80 --cidr 0.0.0.0/0

$ aws ec2 authorize-security-group-ingress \
    --group-id sg-0alb \
    --protocol tcp --port 443 --cidr 0.0.0.0/0

# App Security Group — allows traffic ONLY from ALB
$ aws ec2 create-security-group \
    --group-name prod-app-sg \
    --description "App Server Security Group" \
    --vpc-id vpc-0abc123def456789 \
    --tag-specifications 'ResourceType=security-group,Tags=[{Key=Name,Value=prod-app-sg}]'
{
    "GroupId": "sg-0app"
}

$ aws ec2 authorize-security-group-ingress \
    --group-id sg-0app \
    --protocol tcp --port 8080 \
    --source-group sg-0alb

# Database Security Group — allows traffic ONLY from app servers
$ aws ec2 create-security-group \
    --group-name prod-db-sg \
    --description "Database Security Group" \
    --vpc-id vpc-0abc123def456789 \
    --tag-specifications 'ResourceType=security-group,Tags=[{Key=Name,Value=prod-db-sg}]'
{
    "GroupId": "sg-0db"
}

$ aws ec2 authorize-security-group-ingress \
    --group-id sg-0db \
    --protocol tcp --port 5432 \
    --source-group sg-0app

Security Group Chain

Internet → (80/443) → ALB SG → (8080) → App SG → (5432) → DB SG

Key Principle: Each tier only accepts traffic from the tier above it. The database never talks directly to the internet.


Step 7 — Create Network ACLs

# Create NACL for database subnets (extra layer of security)
$ aws ec2 create-network-acl \
    --vpc-id vpc-0abc123def456789 \
    --tag-specifications 'ResourceType=network-acl,Tags=[{Key=Name,Value=prod-db-nacl}]'
{
    "NetworkAcl": {
        "NetworkAclId": "acl-0db"
    }
}

# Allow inbound PostgreSQL from private subnets only
$ aws ec2 create-network-acl-entry \
    --network-acl-id acl-0db \
    --rule-number 100 \
    --protocol tcp \
    --port-range From=5432,To=5432 \
    --cidr-block 10.0.11.0/24 \
    --rule-action allow \
    --ingress

$ aws ec2 create-network-acl-entry \
    --network-acl-id acl-0db \
    --rule-number 110 \
    --protocol tcp \
    --port-range From=5432,To=5432 \
    --cidr-block 10.0.12.0/24 \
    --rule-action allow \
    --ingress

# Allow ephemeral ports outbound (for responses)
$ aws ec2 create-network-acl-entry \
    --network-acl-id acl-0db \
    --rule-number 100 \
    --protocol tcp \
    --port-range From=1024,To=65535 \
    --cidr-block 10.0.0.0/16 \
    --rule-action allow \
    --egress

# Associate with database subnets
$ aws ec2 replace-network-acl-association \
    --association-id aclassoc-0db1a \
    --network-acl-id acl-0db

Step 8 — Verify the Setup

# Verify VPC
$ aws ec2 describe-vpcs \
    --filters "Name=tag:Name,Values=prod-vpc" \
    --query 'Vpcs[0].{VpcId:VpcId,CIDR:CidrBlock,State:State}'
{
    "VpcId": "vpc-0abc123def456789",
    "CIDR": "10.0.0.0/16",
    "State": "available"
}

# Verify all subnets
$ aws ec2 describe-subnets \
    --filters "Name=vpc-id,Values=vpc-0abc123def456789" \
    --query 'Subnets[].{Name:Tags[?Key==`Name`].Value|[0],CIDR:CidrBlock,AZ:AvailabilityZone}' \
    --output table
------------------------------------------------------
|                   DescribeSubnets                   |
+--------------+------------------+-------------------+
|      AZ      |      CIDR        |       Name        |
+--------------+------------------+-------------------+
|  us-east-1a  |  10.0.1.0/24     |  prod-public-1a   |
|  us-east-1b  |  10.0.2.0/24     |  prod-public-1b   |
|  us-east-1a  |  10.0.11.0/24    |  prod-private-1a  |
|  us-east-1b  |  10.0.12.0/24    |  prod-private-1b  |
|  us-east-1a  |  10.0.21.0/24    |  prod-db-1a       |
|  us-east-1b  |  10.0.22.0/24    |  prod-db-1b       |
+--------------+------------------+-------------------+

# Verify NAT Gateways
$ aws ec2 describe-nat-gateways \
    --filter "Name=vpc-id,Values=vpc-0abc123def456789" \
    --query 'NatGateways[].{Id:NatGatewayId,State:State,SubnetId:SubnetId}'

Step 9 — Test Connectivity

# Launch a test instance in the private subnet
$ aws ec2 run-instances \
    --image-id ami-0c55b159cbfafe1f0 \
    --instance-type t2.micro \
    --subnet-id subnet-0priv1a \
    --security-group-ids sg-0app \
    --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=prod-test-instance}]' \
    --key-name my-key-pair

# Use SSM Session Manager to connect (no SSH needed)
$ aws ssm start-session --target i-0abc123

# From inside the instance — test outbound internet via NAT
$ curl -s ifconfig.me
52.1.2.3    # This is the NAT Gateway's EIP

Step 10 — Clean Up

⚠️ Always clean up to avoid charges, especially the NAT Gateways.

# Delete in reverse order of creation

# 1. Terminate test instances
$ aws ec2 terminate-instances --instance-ids i-0abc123

# 2. Delete NAT Gateways
$ aws ec2 delete-nat-gateway --nat-gateway-id nat-0abc1a
$ aws ec2 delete-nat-gateway --nat-gateway-id nat-0abc1b

# Wait for NAT gateways to be deleted
$ sleep 60

# 3. Release Elastic IPs
$ aws ec2 release-address --allocation-id eipalloc-0nat1a
$ aws ec2 release-address --allocation-id eipalloc-0nat1b

# 4. Delete subnets
$ for subnet in subnet-0pub1a subnet-0pub1b subnet-0priv1a subnet-0priv1b subnet-0db1a subnet-0db1b; do
    aws ec2 delete-subnet --subnet-id $subnet
done

# 5. Delete security groups
$ aws ec2 delete-security-group --group-id sg-0db
$ aws ec2 delete-security-group --group-id sg-0app
$ aws ec2 delete-security-group --group-id sg-0alb

# 6. Detach and delete Internet Gateway
$ aws ec2 detach-internet-gateway --internet-gateway-id igw-0abc123 --vpc-id vpc-0abc123def456789
$ aws ec2 delete-internet-gateway --internet-gateway-id igw-0abc123

# 7. Delete route tables (non-main)
$ aws ec2 delete-route-table --route-table-id rtb-0public
$ aws ec2 delete-route-table --route-table-id rtb-0priv1a
$ aws ec2 delete-route-table --route-table-id rtb-0priv1b

# 8. Delete VPC
$ aws ec2 delete-vpc --vpc-id vpc-0abc123def456789

What You Built

Component Count Purpose
VPC 1 Network boundary
Public Subnets 2 Load balancers, bastion
Private Subnets 2 Application servers
Database Subnets 2 RDS, ElastiCache
Internet Gateway 1 Public internet access
NAT Gateways 2 Private subnet outbound
Route Tables 3 Traffic routing
Security Groups 3 Layered firewall
NACLs 1 Subnet-level firewall

Common Mistakes

Mistake Impact Fix
Forgetting DNS hostnames Services can’t resolve VPC endpoints Enable enableDnsHostnames
Single NAT Gateway Single point of failure One NAT per AZ
Overlapping CIDRs Can’t peer VPCs later Plan CIDR ranges upfront
Wide-open security groups Security vulnerability Use source security groups
Forgetting ephemeral ports in NACLs Broken responses Allow 1024-65535 outbound

Next → Project 2: ECS Fargate with CI/CD

Updated: