DevOpsLesson
DevOpsLesson

Free, comprehensive DevOps tutorials and learning roadmaps. Master Docker, Kubernetes, CI/CD, and more.

Stay Updated

Get notified about new tutorials and features.

Tutorials

  • What is DevOps?
  • Docker Tutorial
  • Terraform Tutorial
  • CI/CD Pipeline
  • All Tutorials

Roadmaps

  • DevOps Engineer
  • Cloud Engineer
  • SRE Path
  • All Roadmaps

Company

  • About Us
  • Blog
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 DevOpsLesson. All rights reserved.

DOCKERKUBERNETESTERRAFORMAWSCI/CDLINUXGITDEVOPS ROADMAPCLOUD ROADMAPSRE ROADMAPGIT CHEATSHEETDOCKER CHEATSHEETK8S CHEATSHEETTF CHEATSHEETLINUX CHEATSHEETDOCKERFILE LINTERYAML VALIDATORCRON PARSERREGEX TESTER

Terraform Tutorial

Introduction to Terraform
Installing Terraform
The Terraform Core Workflow
Terraform Variables and Outputs
Terraform State and Backends
Terraform Modules
Terraform Data Sources
Terraform Count and for_each
Terraform Expressions and Functions
Terraform Dynamic Blocks
Terraform Workspaces
Terraform with AWS
Terraform Provisioners
Terraform CI/CD Pipeline
Terraform Security

Terraform AWS VPC Setup

PreviousPrev
Next

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:

  1. An AWS account with permission to create VPC resources, subnets, route tables, internet gateways, elastic IPs, and NAT gateways.
  2. Terraform installed and available in your terminal.
  3. AWS credentials configured locally.
  4. A basic understanding of Terraform commands such as terraform init, terraform plan, and terraform apply.

You can provide AWS credentials in several ways:

  • environment variables like AWS_ACCESS_KEY_ID and AWS_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 VPC
  • enable_dns_support, enables DNS resolution inside the VPC
  • enable_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/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.

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:

  1. CIDR blocks: make sure the VPC and subnet ranges are what you intended.
  2. Region and AZs: confirm Terraform is targeting the correct AWS region.
  3. Public subnet behavior: verify map_public_ip_on_launch = true is present only on the public subnets.
  4. Routing targets: verify the public route uses the Internet Gateway and the private route uses the NAT gateway.
  5. 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 plan before every change

With the network in place, you are ready to move on to the compute layer.

Practice Questions

Exercise

Question 1: Public Subnet Behavior

Which setting in the public subnet resource helps EC2 instances receive a public IP automatically at launch?

Exercise

Question 2: NAT Gateway Purpose

Why do private subnets commonly use a NAT gateway?

Exercise

Question 3: Route Tables

What makes a subnet public in this tutorial?

PreviousPrev
Next

Continue Learning

Terraform Dynamic Blocks

Learn how Terraform dynamic blocks generate nested configuration, where they fit well, how to use them safely, and when they make code too complex.

10 min read·Medium

Terraform Workspaces

Learn what Terraform workspaces are, how workspace commands work, how state is separated, and when workspaces are helpful or the wrong environment strategy.

10 min read·Easy

Terraform with AWS

Build a complete AWS project with Terraform by combining a custom VPC, a public EC2 web server, and an S3 bucket for static assets.

5 min read·Medium

Explore Related Topics

AW

AWS Tutorials

Provision AWS infrastructure with Terraform

CI

CI/CD Tutorials

Run terraform plan/apply in CI pipelines

Try the Tool

YAML Validator

Validate Terraform and Kubernetes YAML configs before applying.

On This Page

Project OverviewWhat We Are BuildingWhy public and private subnets?Why a NAT gateway?Why more than one availability zone?PrerequisitesProject StructureProvider ConfigurationDiscovering Availability Zones with a Data SourceCreating the VPCWhy use a `/16` CIDR block?Creating Public Subnets`count``availability_zone``map_public_ip_on_launch = true`Creating Private SubnetsCreating the Internet GatewayCreating the Public Route TableAssociating Public Subnets with the Public Route TableCreating the NAT GatewayWhy is the NAT gateway in a public subnet?Why only one NAT gateway in this lesson?Creating the Private Route TableAssociating Private Subnets with the Private Route TableRunning `terraform plan`What to review in the plan outputComplete Working Code for the Full VPC Module`main.tf``variables.tf``outputs.tf``terraform.tfvars`Applying and Verifying the NetworkFinal ThoughtsPractice Questions