Thumbnail

How to Create an AWS VPC with Terraform

Almost every AWS project starts with the same network foundation: public subnets for internet-facing resources and private subnets for everything else. Getting this right in Terraform from the start avoids the common pattern of manually creating VPCs in the console and then having no infrastructure-as-code record of the network decisions.

This covers building that foundation from first principles — Internet Gateway, NAT Gateway, route tables — before showing the community module that wraps it all. Going through the manual version first matters because the module's defaults make more sense once you understand what each resource does.

Prerequisites

  • Terraform installed (installation docs)
  • AWS credentials configured via aws configure or environment variables
  • Basic familiarity with AWS networking concepts (VPCs, subnets, CIDR blocks)

Goals

  • Create a VPC with a /18 CIDR block
  • Set up a public subnet with internet access via an Internet Gateway
  • Set up a private subnet with outbound-only internet access via a NAT Gateway
  • Configure route tables to control traffic flow between subnets and the internet

1. Define the Provider and VPC

Start by creating your main.tf file. First, run terraform init to initialize the working directory and download the AWS provider:

terraform init

Now define the AWS provider pointing to the us-east-1 region, then declare your VPC resource:

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

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/18"

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

A /18 CIDR block gives 16,384 addresses. This is deliberate: undersizing a VPC CIDR is painful to fix after the fact because AWS does not allow shrinking a VPC CIDR, and adding secondary CIDRs adds complexity. Starting with a /18 or larger leaves room for growth without wasting subnets.

2. Create the Public Subnet

The public subnet is where you place resources that need to be reachable from the internet, like load balancers or bastion hosts. Setting map_public_ip_on_launch to true means any EC2 instance launched in this subnet will automatically get a public IP:

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.0.0/24"
  availability_zone       = "us-east-1a"
  map_public_ip_on_launch = true

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

At this point, the subnet only has a local route within the VPC. It's not truly "public" yet. We need an Internet Gateway and a route table entry to make that happen.

3. Create the Private Subnet

The private subnet uses a different CIDR block and is meant for resources that should not be directly accessible from the internet, like databases or internal application servers. I'm placing it in a different availability zone for better fault tolerance:

resource "aws_subnet" "private" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "us-east-1b"

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

In production, create at least two subnets of each type across different availability zones. ALB requires subnets in at least two AZs, and EKS node groups need multi-AZ subnets for proper scheduler zone balancing. A single-AZ private subnet is a reliability liability the first time that AZ has a partial outage.

4. Create the Internet Gateway

The Internet Gateway (IGW) is the component that connects your VPC to the public internet. Without it, nothing in your VPC can communicate with the outside world. It's attached directly to the VPC:

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "main-internet-gateway"
  }
}

5. Create an Elastic IP for the NAT Gateway

The NAT Gateway requires a static public IP address. An Elastic IP (EIP) gives us a fixed address that won't change if the NAT Gateway is recreated:

resource "aws_eip" "nat" {
  domain = "vpc"
}

6. Create the NAT Gateway

The NAT Gateway allows instances in the private subnet to initiate outbound internet traffic (for example, to download packages or call external APIs) without being directly reachable from the internet.

The NAT Gateway must be placed in the public subnet because it needs a route to the Internet Gateway to forward traffic:

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public.id

  tags = {
    Name = "main-nat-gateway"
  }
}

NAT Gateways are billed per hour ($0.045/hr in us-east-1) and per GB processed ($0.045/GB). In a multi-AZ production setup with one NAT Gateway per AZ, this adds up quickly. For non-production environments, a single NAT Gateway or a NAT instance (EC2-based, cheaper but operationally heavier) can reduce cost significantly.

7. Create the Public Route Table

Now we need to tell AWS how to route traffic for the public subnet. The key rule here is: any traffic destined for an address outside the VPC (0.0.0.0/0) should go through the Internet Gateway. The local route for traffic within the VPC CIDR is added implicitly by AWS:

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-route-table"
  }
}

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

The aws_route_table_association resource links the route table to the subnet. Without this association, the subnet would use the VPC's default route table, which has no internet route.

8. Create the Private Route Table

The private route table works the same way, but instead of pointing the default route to the Internet Gateway, it points to the NAT Gateway. This means private instances can reach the internet (outbound) but the internet cannot reach them (inbound):

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.main.id
  }

  tags = {
    Name = "private-route-table"
  }
}

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

9. Deploy the Infrastructure

With all the resources defined, preview what Terraform will create by running plan, then apply it:

terraform plan

Review the output to confirm the resources look correct, then deploy:

terraform apply

Terraform will show the execution plan again and ask for confirmation. Type yes to proceed. After a couple of minutes, your VPC infrastructure will be ready.

To tear everything down when you no longer need it:

terraform destroy

Using the VPC Module

For production projects, you might want to use the official terraform-aws-modules/vpc module instead of managing each resource individually. It handles all the wiring we did above in a few lines:

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "main-vpc"
  cidr = "10.0.0.0/18"

  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.0.0/24", "10.0.16.0/24"]

  enable_nat_gateway = true
}

This module creates everything we built manually, plus it handles multiple availability zones, proper tagging, and many optional features like VPN gateways and VPC flow logs. The module is the right choice for production: it handles tagging conventions (including the tags EKS requires on subnets to discover them for load balancer provisioning), multi-AZ layouts, and optional features like VPC Flow Logs and VPN gateways. The manual approach above is useful for understanding what the module is abstracting.

Conclusion

The VPC pattern here — public subnets for internet-facing resources, private subnets for application servers and databases, NAT Gateway for controlled outbound access — is the foundation of most production AWS architectures.

Here is a recap of the resources created and their purpose:

Resource Purpose
aws_vpc The main VPC with a /18 CIDR block
aws_subnet (public) Public-facing subnet (10.0.0.0/24) with auto-assigned public IPs
aws_subnet (private) Private subnet (10.0.1.0/24) in a separate availability zone
aws_internet_gateway Enables internet access for the public subnet
aws_eip Static IP for the NAT Gateway
aws_nat_gateway Allows private subnet outbound internet traffic
aws_route_table (public) Routes public traffic via Internet Gateway
aws_route_table (private) Routes private traffic via NAT Gateway

Comments