Terraform AWS VPC Setup
Build a custom AWS VPC with Terraform, including public and private subnets, an internet gateway, route tables, and a NAT gateway.
Project Overview
In this lesson, you are building the network foundation for the rest of the AWS project. The goal is a custom VPC with both public and private subnets. Later lessons will place an EC2 web server in a public subnet and store static assets in S3, but neither of those pieces should come first. Good cloud design starts with the network.
The VPC will include:
- one custom
aws_vpc - two public subnets
- two private subnets
- an internet gateway for public internet access
- a public route table
- a private route table
- one NAT gateway so private subnets can reach the internet for updates without becoming publicly reachable
That architecture is common because it separates resources by exposure level. Public subnets are for components that must accept traffic from the internet, such as a bastion host, load balancer, or simple demo web server. Private subnets are for application servers, databases, or internal services that should not receive direct inbound traffic from the public internet.
Even if your first Terraform project is small, learning this pattern now pays off later. Most real AWS environments reuse the same ideas repeatedly.
What We Are Building
Conceptually, the layout looks like this:
VPC 10.0.0.0/16
├── Public subnet A 10.0.1.0/24
├── Public subnet B 10.0.2.0/24
├── Private subnet A 10.0.11.0/24
├── Private subnet B 10.0.12.0/24
├── Internet Gateway
├── NAT Gateway (in a public subnet)
├── Public route table -> 0.0.0.0/0 via Internet Gateway
└── Private route table -> 0.0.0.0/0 via NAT Gateway
A few design principles are worth understanding before you write any code:
Why public and private subnets?
A subnet becomes “public” when its route table sends outbound internet traffic to an Internet Gateway and resources inside it can receive public IP addresses. A subnet stays “private” when it does not expose that direct path.
Why a NAT gateway?
Private resources often still need outbound internet access. For example:
- an EC2 instance in a private subnet may need to download package updates
- an internal application server may need to call external APIs
- a deployment process may need to fetch software packages
A NAT gateway gives those private resources outbound internet connectivity without opening inbound access from the internet.
Why more than one availability zone?
You will spread subnets across multiple AZs by using the AWS availability zone data source. This is good practice because it prepares the environment for high availability. Even if you launch only one EC2 instance today, you are learning a production-friendly network layout.
Prerequisites
Before starting, make sure you have:
- An AWS account with permission to create VPC resources, subnets, route tables, internet gateways, elastic IPs, and NAT gateways.
- Terraform installed and available in your terminal.
- AWS credentials configured locally.
- A basic understanding of Terraform commands such as
terraform init,terraform plan, andterraform apply.
You can provide AWS credentials in several ways:
- environment variables like
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY - an AWS CLI profile in
~/.aws/credentials - federated credentials such as AWS SSO
A good quick validation is:
aws sts get-caller-identity
terraform version
If both commands work, you are ready to continue.
Project Structure
A simple structure for this lesson looks like this:
terraform-aws-vpc/
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars
You can keep everything in a single main.tf for practice, but splitting files by purpose makes the configuration easier to read.
Provider Configuration
Start by telling Terraform which provider you need and which region to target.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
The required_providers block locks in the AWS provider source and version constraint. That matters because provider schemas evolve over time. Pinning a version range reduces unpleasant surprises when teammates or CI pipelines run the same configuration.
The provider block uses a variable for region instead of hardcoding it. That makes the project reusable.
In variables.tf, define the region and network inputs:
variable "aws_region" {
description = "AWS region for the infrastructure"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name used in resource tags"
type = string
default = "devopslesson"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets"
type = list(string)
default = ["10.0.11.0/24", "10.0.12.0/24"]
}
Discovering Availability Zones with a Data Source
Instead of hardcoding availability zone names, ask AWS for them.
data "aws_availability_zones" "available" {
state = "available"
}
Data sources do not create infrastructure. They read information from the provider so you can use that information elsewhere.
Why is this helpful?
- it makes the configuration more portable
- it avoids mistakes from typing the wrong AZ name
- it lets Terraform choose the first two available zones dynamically
You can then refer to:
data.aws_availability_zones.available.names[0]
data.aws_availability_zones.available.names[1]
Creating the VPC
Now create the VPC itself.
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
Environment = "lab"
ManagedBy = "Terraform"
}
}
The key attributes here are:
cidr_block, the IP range for the whole VPCenable_dns_support, enables DNS resolution inside the VPCenable_dns_hostnames, lets instances receive DNS hostnames
enable_dns_hostnames is especially useful for EC2 and load balancer scenarios. If you skip it, later services may behave in ways that confuse beginners.
Why use a /16 CIDR block?
10.0.0.0/16 gives you a large private IP range to divide into many subnets later. It is a common lab and production starting point because it is roomy without being hard to reason about.
Creating Public Subnets
Next, create the public subnets.
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
Tier = "public"
ManagedBy = "Terraform"
}
}
Several ideas come together in this resource:
count
count lets one resource block create multiple resources. Because public_subnet_cidrs contains two CIDR blocks, Terraform creates two public subnets.
availability_zone
Each subnet gets an availability zone from the data source. Subnet 0 lands in AZ 0, subnet 1 lands in AZ 1.
map_public_ip_on_launch = true
This setting is one of the clearest indicators that these are intended to be public subnets. When an instance launches in one of these subnets, AWS can automatically assign it a public IP address.
That does not by itself make the subnet public. You also need a route to an Internet Gateway, which you will create later.
Creating Private Subnets
Private subnets are almost the same, but one important setting changes.
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-${count.index + 1}"
Tier = "private"
ManagedBy = "Terraform"
}
}
Notice that there is no map_public_ip_on_launch = true here. Private subnets should not automatically hand public IPs to launched instances.
These private subnets will later use the NAT gateway for outbound access.
Creating the Internet Gateway
Public internet access for the VPC starts with an internet gateway.
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
ManagedBy = "Terraform"
}
}
An Internet Gateway is attached to the VPC, not to an individual subnet. Subnets become internet-routable only when their route table sends traffic to it.
Creating the Public Route Table
Now define a route table for the public subnets.
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 = "${var.project_name}-public-rt"
ManagedBy = "Terraform"
}
}
The route block is the important part:
- destination
0.0.0.0/0means “all IPv4 addresses not otherwise matched” gateway_id = aws_internet_gateway.main.idsays “send that traffic to the internet gateway”
This is what gives public subnets their outbound internet path.
Associating Public Subnets with the Public Route Table
A route table exists, but it does nothing until subnets use it.
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
}
This association explicitly links each public subnet to the public route table.
Creating the NAT Gateway
Private subnets should not route directly to the Internet Gateway, but they still need outbound connectivity for updates and package downloads. The standard AWS answer is a NAT gateway.
A NAT gateway needs an Elastic IP and must be placed in a public subnet.
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
ManagedBy = "Terraform"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
depends_on = [aws_internet_gateway.main]
tags = {
Name = "${var.project_name}-nat"
ManagedBy = "Terraform"
}
}
Why is the NAT gateway in a public subnet?
Because it needs public internet reachability itself. Private instances send outbound traffic to the NAT gateway, and the NAT gateway forwards that traffic to the internet on their behalf.
Why only one NAT gateway in this lesson?
Cost and simplicity. A highly available production design often uses one NAT gateway per AZ, but for a learning project that gets expensive quickly. One NAT gateway is enough to understand the pattern.
Creating the Private Route Table
Now define the route table for private subnets.
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 = "${var.project_name}-private-rt"
ManagedBy = "Terraform"
}
}
The destination is again 0.0.0.0/0, but this time the next hop is the NAT gateway instead of the Internet Gateway.
That difference is the essence of private subnet design.
Associating Private Subnets with the Private Route Table
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.id
}
At this point, the network layout is complete:
- public subnets route to the Internet Gateway
- private subnets route to the NAT gateway
Running terraform plan
Once the configuration exists, initialize the working directory:
terraform init
Then run:
terraform plan
terraform plan is not just a formality. It is the review step where Terraform tells you exactly what it intends to create.
For this lesson, expect the plan to show resources similar to:
- 1 VPC
- 2 public subnets
- 2 private subnets
- 1 internet gateway
- 1 elastic IP
- 1 NAT gateway
- 2 route tables
- 4 route table associations
- 1 data source read for availability zones
What to review in the plan output
Before you apply, scan for these details:
- CIDR blocks: make sure the VPC and subnet ranges are what you intended.
- Region and AZs: confirm Terraform is targeting the correct AWS region.
- Public subnet behavior: verify
map_public_ip_on_launch = trueis present only on the public subnets. - Routing targets: verify the public route uses the Internet Gateway and the private route uses the NAT gateway.
- Tags: good tags help later when you inspect the AWS console.
The habit to build now is: never apply infrastructure you have not reviewed.
Complete Working Code for the Full VPC Module
Below is a complete working example for this lesson.
main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
Environment = "lab"
ManagedBy = "Terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
ManagedBy = "Terraform"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
Tier = "public"
ManagedBy = "Terraform"
}
}
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-${count.index + 1}"
Tier = "private"
ManagedBy = "Terraform"
}
}
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 = "${var.project_name}-public-rt"
ManagedBy = "Terraform"
}
}
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
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
ManagedBy = "Terraform"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
depends_on = [aws_internet_gateway.main]
tags = {
Name = "${var.project_name}-nat"
ManagedBy = "Terraform"
}
}
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 = "${var.project_name}-private-rt"
ManagedBy = "Terraform"
}
}
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.id
}
variables.tf
variable "aws_region" {
description = "AWS region for the infrastructure"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name used in resource tags"
type = string
default = "devopslesson"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets"
type = list(string)
default = ["10.0.11.0/24", "10.0.12.0/24"]
}
outputs.tf
output "vpc_id" {
description = "ID of the created 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 "internet_gateway_id" {
description = "ID of the internet gateway"
value = aws_internet_gateway.main.id
}
output "nat_gateway_id" {
description = "ID of the NAT gateway"
value = aws_nat_gateway.main.id
}
terraform.tfvars
aws_region = "us-east-1"
project_name = "devopslesson"
vpc_cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24"]
Applying and Verifying the Network
When the plan looks correct, run:
terraform apply
After the apply finishes, verify in the AWS console that:
- the VPC exists with the expected CIDR block
- both public and private subnets were created
- public route table associations point to the Internet Gateway
- private route table associations point to the NAT gateway
- the NAT gateway sits in one of the public subnets
You can also inspect outputs locally:
terraform output
These outputs become useful in the next lesson when the EC2 instance needs to know where to launch.
Final Thoughts
This lesson teaches one of the most reusable AWS infrastructure patterns you can learn. The exact CIDR ranges, AZ count, and route design may change in larger environments, but the core ideas remain the same:
- define a VPC clearly
- separate public and private network tiers
- route internet-bound traffic intentionally
- review
terraform planbefore every change
With the network in place, you are ready to move on to the compute layer.
Practice Questions
Question 1: Public Subnet Behavior
Which setting in the public subnet resource helps EC2 instances receive a public IP automatically at launch?
Question 2: NAT Gateway Purpose
Why do private subnets commonly use a NAT gateway?
Question 3: Route Tables
What makes a subnet public in this tutorial?