CloudNetworking.io
AWS Networking + Terraform

AWS VPC Terraform Tutorial: Build a VPC Step by Step

In this guide, you will learn how to create an AWS VPC with Terraform in a way that beginners can understand. We will build a VPC, a public subnet, a private subnet, an Internet Gateway, route tables, and a NAT Gateway.

More importantly, you will also understand what each Terraform block does, why it is needed, and how it fits into a real-world architecture.

🎥 Watch: AWS VPC Terraform Tutorial

Learn how to build AWS VPC using Terraform step by step with real-world examples.

What you will build

This tutorial creates a simple and realistic AWS network layout:

Public side

One public subnet for internet-facing resources such as a load balancer, bastion host, or public web server.

Private side

One private subnet for internal application servers or databases that should not be directly reachable from the internet.

Simple goal: Build a network where public traffic can enter through the public subnet, while private workloads stay protected and only get outbound internet access when needed.

Why use Terraform for AWS VPC?

Terraform lets you define infrastructure as code, which means your VPC design is written in reusable, version-controlled files instead of being created manually in the AWS Console.

That helps with consistency, team collaboration, repeatable environments, faster reviews, and easier disaster recovery.

Real-life example

Imagine a company running three environments: dev, staging, and production. If the network is built manually, each environment may end up slightly different. With Terraform, the team can reuse the same design and only change inputs like CIDR ranges, tags, or Availability Zones.

Full Terraform example: AWS VPC with public and private subnets

This is the full example first. After that, we explain each block in plain English.

main.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "ap-south-1"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "cloudnetworking-vpc"
    Environment = "dev"
  }
}

resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "ap-south-1a"
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "ap-south-1a"

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

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

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

resource "aws_route_table" "public_rt" {
  vpc_id = aws_vpc.main.id

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

resource "aws_route" "public_internet_route" {
  route_table_id         = aws_route_table.public_rt.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw.id
}

resource "aws_route_table_association" "public_assoc" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public_rt.id
}

resource "aws_eip" "nat_eip" {
  domain = "vpc"

  tags = {
    Name = "nat-eip"
  }
}

resource "aws_nat_gateway" "nat" {
  allocation_id = aws_eip.nat_eip.id
  subnet_id     = aws_subnet.public_1.id

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

  depends_on = [aws_internet_gateway.igw]
}

resource "aws_route_table" "private_rt" {
  vpc_id = aws_vpc.main.id

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

resource "aws_route" "private_nat_route" {
  route_table_id         = aws_route_table.private_rt.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.nat.id
}

resource "aws_route_table_association" "private_assoc" {
  subnet_id      = aws_subnet.private_1.id
  route_table_id = aws_route_table.private_rt.id
}
Important: NAT Gateway has cost. For learning labs, some people stop after the public subnet and Internet Gateway. For production-style teaching, NAT Gateway is worth showing because it explains how private workloads reach the internet safely.

Block-by-block explanation

1) Terraform and provider block

Terraform version and AWS provider
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "ap-south-1"
}

This tells Terraform which provider to use and which AWS region to create resources in.

Real-life example: if your company deploys most workloads in Mumbai, you may standardize on ap-south-1 for dev or regional production systems.

2) VPC block

Create the VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "cloudnetworking-vpc"
    Environment = "dev"
  }
}

This creates your private cloud network. The CIDR block 10.0.0.0/16 gives you a large address range to divide into smaller subnets later.

Real-life example: think of the VPC as the boundary of your company’s cloud office. Everything inside it belongs to your environment.

3) Public subnet block

Create the public subnet
resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "ap-south-1a"
  map_public_ip_on_launch = true

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

A public subnet is used for resources that need direct or indirect internet access. The setting map_public_ip_on_launch = true allows instances launched here to receive public IP addresses automatically.

Real-life example: an Application Load Balancer or a bastion host could live in this subnet.

4) Private subnet block

Create the private subnet
resource "aws_subnet" "private_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "ap-south-1a"

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

This subnet is for internal workloads that should not be directly exposed to the internet.

Real-life example: app servers, internal APIs, queue processors, or databases are commonly placed in private subnets.

5) Internet Gateway block

Attach the Internet Gateway
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id

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

The Internet Gateway is what connects your VPC to the public internet. Without it, public-facing resources cannot send or receive internet traffic.

Real-life example: a public website needs this path so customers can access the frontend.

6) Public route table and route

Make the public subnet actually public
resource "aws_route_table" "public_rt" {
  vpc_id = aws_vpc.main.id

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

resource "aws_route" "public_internet_route" {
  route_table_id         = aws_route_table.public_rt.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw.id
}

resource "aws_route_table_association" "public_assoc" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public_rt.id
}

This sends all non-local traffic to the Internet Gateway. That is what makes the subnet behave like a public subnet.

Real-life example: a web server in this subnet can now answer requests from users on the internet.

7) Elastic IP and NAT Gateway

Let private servers reach the internet safely
resource "aws_eip" "nat_eip" {
  domain = "vpc"

  tags = {
    Name = "nat-eip"
  }
}

resource "aws_nat_gateway" "nat" {
  allocation_id = aws_eip.nat_eip.id
  subnet_id     = aws_subnet.public_1.id

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

  depends_on = [aws_internet_gateway.igw]
}

The NAT Gateway lives in the public subnet and uses an Elastic IP. Private resources can send outbound traffic through it without becoming directly reachable from the internet.

Real-life example: a backend server in a private subnet may need software updates, package downloads, or calls to third-party APIs.

8) Private route table and route to NAT

Give the private subnet outbound internet access
resource "aws_route_table" "private_rt" {
  vpc_id = aws_vpc.main.id

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

resource "aws_route" "private_nat_route" {
  route_table_id         = aws_route_table.private_rt.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.nat.id
}

resource "aws_route_table_association" "private_assoc" {
  subnet_id      = aws_subnet.private_1.id
  route_table_id = aws_route_table.private_rt.id
}

This route table gives the private subnet outbound access through the NAT Gateway. It does not make the subnet public.

Real-life example: your app server can pull container images or call payment APIs, but customers still cannot connect directly to it from the internet.

How traffic flows in this VPC

  • A user on the internet reaches a public-facing component in the public subnet.
  • That component forwards requests to application resources in the private subnet.
  • Private workloads answer the request and return the response through the same trusted path.
  • If private workloads need outbound internet access, they use the NAT Gateway.
Easy way to remember it: Public subnet is for entry points. Private subnet is for protected workloads. NAT Gateway is for safe outbound traffic from private resources.

Real-world example: online shopping application

Imagine you are building an online shopping website.

Public subnet

A load balancer accepts traffic from customers who visit your website.

Private subnet

Application servers process orders, user logins, and payments away from the public internet.

In a more advanced design, the database would also live in private subnets, usually across multiple Availability Zones for resilience.

Common beginner mistakes

  • Putting databases in public subnets
  • Forgetting to associate the correct route table with the subnet
  • Assuming a subnet is public just because an instance has a public IP
  • Using overlapping CIDR ranges across environments
  • Creating only one subnet in one Availability Zone for production
  • Forgetting that NAT Gateway adds cost
Production note: For real production workloads, you would usually create subnets in at least two Availability Zones and often use separate public and private subnets per AZ.

Frequently asked questions

Should I learn VPC before Terraform?

Yes. Terraform becomes much easier to understand when you already know what a VPC, subnet, route table, Internet Gateway, and NAT Gateway do.

Can a private subnet access the internet?

Yes. A private subnet can access the internet for outbound traffic through a NAT Gateway or NAT instance, but it does not normally allow direct inbound internet access.

Do I need a NAT Gateway in every lab?

No. Many labs skip NAT Gateway to reduce cost. But it is useful to understand because it is common in production network design.

What should I learn after this page?

Next, learn subnets, route tables, Security Groups, NACLs, and then move into multi-AZ VPC design.