In DevOps, setting up a secure network foundation is the absolute first step. On AWS, this means building a Virtual Private Cloud (VPC). Doing this manually through the AWS Console is slow, error-prone, and impossible to track. That's where Infrastructure as Code (IaC) comes in.

In this guide, we will write a production-ready, highly available, multi-Availability Zone (AZ) AWS VPC using Terraform. We'll set up public subnets for our load balancers, private subnets for our application servers, and automate route tables and NAT Gateways.

Note: We will configure this layout across 2 Availability Zones to ensure high availability while keeping cost efficiency in mind by sharing NAT Gateways.

The Architecture

Before writing code, it's vital to understand what we're building. Our VPC will have:

  • A Classless Inter-Domain Routing (CIDR) block of 10.0.0.0/16.
  • 2 Public Subnets (one in each AZ) which route internet traffic through an Internet Gateway.
  • 2 Private Subnets (one in each AZ) which route outbound-only traffic through a NAT Gateway.
  • Properly associated Route Tables to secure private environments from direct ingress.

Step 1: Provider Setup

Create a file named main.tf. First, we define our AWS provider and specify our working region.

main.tf (Provider Section)
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

Step 2: Define the VPC and Gateways

Next, we provision the main VPC container and create the Internet Gateway (IGW) for public routing. We'll also allocate an Elastic IP (EIP) and set up a NAT Gateway so that instances in our private subnets can connect to the internet (for updates, patches, etc.) without exposing themselves to incoming connections.

main.tf (VPC & Gateways)
# 1. Main VPC resource
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "production-vpc"
  }
}

# 2. Internet Gateway for public traffic
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "production-igw"
  }
}

# 3. Elastic IP for the NAT Gateway
resource "aws_eip" "nat" {
  domain     = "vpc"
  depends_on = [aws_internet_gateway.igw]
}

# 4. NAT Gateway (placed in public subnet 1)
resource "aws_nat_gateway" "nat" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_1.id

  tags = {
    Name = "production-nat-gw"
  }
}

Step 3: Creating Subnets Across AZs

We divide our 10.0.0.0/16 network block into smaller chunks using subnet CIDR blocks. We place subnets in both us-east-1a and us-east-1b for high availability.

main.tf (Subnets definition)
# Public Subnet 1 (AZ A)
resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "us-east-1a"
  map_public_ip_on_launch = true

  tags = { Name = "public-subnet-1a" }
}

# Public Subnet 2 (AZ B)
resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = "us-east-1b"
  map_public_ip_on_launch = true

  tags = { Name = "public-subnet-1b" }
}

# Private Subnet 1 (AZ A)
resource "aws_subnet" "private_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.10.0/24"
  availability_zone = "us-east-1a"

  tags = { Name = "private-subnet-1a" }
}

# Private Subnet 2 (AZ B)
resource "aws_subnet" "private_2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = "us-east-1b"

  tags = { Name = "private-subnet-1b" }
}

Step 4: Route Tables and Associations

Now we define how network packages flow. The public route table sends all external traffic (0.0.0.0/0) to the IGW. The private route table routes outgoing traffic to the NAT Gateway.

main.tf (Route Tables)
# 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.igw.id
  }

  tags = { Name = "public-rt" }
}

# Private Route Table
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat.id
  }

  tags = { Name = "private-rt" }
}

# Route Table Associations
resource "aws_route_table_association" "pub_1" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "pub_2" {
  subnet_id      = aws_subnet.public_2.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "priv_1" {
  subnet_id      = aws_subnet.private_1.id
  route_table_id = aws_route_table.private.id
}

resource "aws_route_table_association" "priv_2" {
  subnet_id      = aws_subnet.private_2.id
  route_table_id = aws_route_table.private.id
}

Step 5: Initialization and Execution

With the code ready, let's run our terminal execution. Fire up your terminal inside your project directory and follow these commands:

terminal โ€” bash
dinesh@devops ~ โฏ terraform init
Initializing the backend...
Initializing provider plugins...
โœ” Terraform has been successfully initialized!

dinesh@devops ~ โฏ terraform validate
โœ” Success! The configuration is valid.

dinesh@devops ~ โฏ terraform plan
...
Plan: 13 to add, 0 to change, 0 to destroy.

Conclusion

Congratulations! You've just codified your network infrastructure using Terraform. Your architecture is highly available, isolated, and secure across multiple AZs. By versioning this code in Git, you now have a clean system audit log of your infrastructure topology.

In the next guide, we'll configure security groups and spin up containerized ECS tasks inside this exact private network.