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:
aws_vpcThat 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.
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 GatewayA few design principles are worth understanding before you write any code:
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.
Private resources often still need outbound internet access. For example:
A NAT gateway gives those private resources outbound internet connectivity without opening inbound access from the internet.
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.
Before starting, make sure you have:
terraform init, terraform plan, and terraform apply.You can provide AWS credentials in several ways:
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY~/.aws/credentialsA good quick validation is:
aws sts get-caller-identity
terraform versionIf both commands work, you are ready to continue.
A simple structure for this lesson looks like this:
terraform-aws-vpc/
âââ main.tf
âââ variables.tf
âââ outputs.tf
âââ terraform.tfvarsYou can keep everything in a single main.tf for practice, but splitting files by purpose makes the configuration easier to read.
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"]
}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?
You can then refer to:
data.aws_availability_zones.available.names[0]
data.aws_availability_zones.available.names[1]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 hostnamesenable_dns_hostnames is especially useful for EC2 and load balancer scenarios. If you skip it, later services may behave in ways that confuse beginners.
/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.
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:
countcount lets one resource block create multiple resources. Because public_subnet_cidrs contains two CIDR blocks, Terraform creates two public subnets.
availability_zoneEach 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 = trueThis 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.
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.
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.
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:
0.0.0.0/0 means âall IPv4 addresses not otherwise matchedâgateway_id = aws_internet_gateway.main.id says âsend that traffic to the internet gatewayâThis is what gives public subnets their outbound internet path.
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.
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"
}
}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.
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.
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.
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:
terraform planOnce the configuration exists, initialize the working directory:
terraform initThen run:
terraform planterraform 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:
Before you apply, scan for these details:
map_public_ip_on_launch = true is present only on the public subnets.The habit to build now is: never apply infrastructure you have not reviewed.
Below is a complete working example for this lesson.
main.tfterraform {
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.tfvariable "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.tfoutput "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.tfvarsaws_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"]When the plan looks correct, run:
terraform applyAfter the apply finishes, verify in the AWS console that:
You can also inspect outputs locally:
terraform outputThese outputs become useful in the next lesson when the EC2 instance needs to know where to launch.
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:
terraform plan before every changeWith the network in place, you are ready to move on to the compute layer.
<MCQExercise
exercise={{
id: 'terraform-aws-vpc-q1',
title: 'Question 1: Public Subnet Behavior',
description: 'Which setting in the public subnet resource helps EC2 instances receive a public IP automatically at launch?',
options: [
{ id: 'a', text: 'enable_dns_hostnames = true' },
{ id: 'b', text: 'map_public_ip_on_launch = true' },
{ id: 'c', text: 'gateway_id = aws_internet_gateway.main.id' },
{ id: 'd', text: 'nat_gateway_id = aws_nat_gateway.main.id' },
],
correctId: 'b',
explanation: 'The subnet attribute map_public_ip_on_launch = true tells AWS to automatically assign public IP addresses to instances launched in that subnet. It is a strong signal that the subnet is intended for public-facing resources.',
wrongExplanations: {
a: 'enable_dns_hostnames is a VPC setting, not a subnet setting for automatic public IP assignment.',
c: 'That route belongs in a route table, not in the subnet definition itself.',
d: 'A NAT gateway is used by private subnets for outbound internet access, not for assigning public IPs to instances.',
},
}}
/>
<MCQExercise exercise={{ id: 'terraform-aws-vpc-q2', title: 'Question 2: NAT Gateway Purpose', description: 'Why do private subnets commonly use a NAT gateway?', options: [ { id: 'a', text: 'To allow inbound SSH from the internet directly into private instances' }, { id: 'b', text: 'To replace the VPC and manage all networking automatically' }, { id: 'c', text: 'To let private instances reach the internet outbound without becoming publicly reachable' }, { id: 'd', text: 'To assign Elastic IPs to every private instance' }, ], correctId: 'c', explanation: 'A NAT gateway provides outbound internet access for resources in private subnets. Those resources can download updates or call external APIs, but they still do not accept direct inbound connections from the public internet.', wrongExplanations: { a: 'A NAT gateway does not provide direct inbound SSH into private instances.', b: 'A NAT gateway is one component inside a VPC design; it does not replace the VPC.', d: 'Private instances typically do not get public Elastic IPs when using a NAT gateway pattern.', }, }} />
<MCQExercise exercise={{ id: 'terraform-aws-vpc-q3', title: 'Question 3: Route Tables', description: 'What makes a subnet public in this tutorial?', options: [ { id: 'a', text: 'It is associated with a route table that sends 0.0.0.0/0 to an Internet Gateway' }, { id: 'b', text: 'It exists in the first availability zone returned by AWS' }, { id: 'c', text: 'It has DNS support enabled in the VPC' }, { id: 'd', text: 'It shares the same CIDR block as the NAT gateway' }, ], correctId: 'a', explanation: 'A subnet becomes public when its route table gives it a path to the internet through an Internet Gateway. Public IP assignment is helpful too, but the route table is the networking rule that actually creates internet reachability.', wrongExplanations: { b: 'Availability zone selection does not determine whether a subnet is public or private.', c: 'DNS support is useful, but it does not control public internet routing.', d: 'The NAT gateway is used by private subnets for outbound traffic and does not define a public subnet.', }, }} />
Query the available zones with the aws_availability_zones data source rather than hardcoding names, then create subnets with for_each over a map keyed on a stable name. Use cidrsubnet() to carve the ranges from the VPC CIDR so they are derived rather than hand-calculated. Avoid count with an index into the AZ list, if AWS changes zone ordering or you remove one, positional identity causes unrelated subnets to be recreated.
One per availability zone for production, since a NAT gateway is zonal and losing its AZ takes outbound access with it. The community module exposes single_nat_gateway and one_nat_gateway_per_az to control this, and using a single gateway in non-production is a reasonable saving. Be aware NAT gateways are frequently the largest networking cost on an AWS bill, so add free S3 and DynamoDB gateway endpoints in the same configuration.
Because a subnet's CIDR block is immutable. AWS provides no way to change it in place, so Terraform must replace the resource. The plan shows -/+ with a "forces replacement" annotation. Anything inside that subnet is affected, so treat CIDR changes as a migration rather than an edit. This is why sizing the VPC and its subnets generously at creation matters: a /20 per subnet costs nothing extra and avoids the problem entirely.
The AWS Load Balancer Controller and EKS discover where to place resources from subnet tags, kubernetes.io/role/elb set to 1 on public subnets and kubernetes.io/role/internal-elb on private ones, plus a cluster ownership tag. The community VPC module accepts public_subnet_tags and private_subnet_tags for exactly this. Missing tags produce an Ingress that sits in pending with no clear error, which is a slow thing to diagnose.
Usually not. Networking changes rarely and is shared by many workloads, while application infrastructure changes constantly. Keeping them together means every application deploy plans against the whole VPC and a mistake has a larger blast radius. Put the VPC in its own configuration and expose IDs via outputs, then consume them from application configurations with a data source or by reading a parameter from SSM.
Launch an EC2 web server with Terraform: add a security group, find the latest Ubuntu AMI, create a key pair, write user data and attach an Elastic IP.
Create an S3 bucket with Terraform: unique names with random_id, versioning, lifecycle rules, public access settings, a bucket policy and object uploads.
Learn how Terraform provisioners work: local-exec, remote-exec, file and connection blocks, null_resource, and why provisioners should be a last resort.