Deploying a Production VPC on AWS with Terraform
Reusable Networking Foundation
A production-ready VPC with public/private subnets across multiple AZs, NAT Gateway, Internet Gateway, route tables, and security group defaults. Almost every AWS project links back to this — it becomes the dependency anchor for your entire Terraform portfolio.
What You Will Build
| Resource | Purpose |
|---|---|
aws_vpc | The network boundary — your private slice of AWS |
aws_subnet (×4) | 2 public + 2 private, one per Availability Zone |
aws_internet_gateway | Connects public subnets to the internet |
aws_nat_gateway | Lets private subnets make outbound requests securely |
aws_eip | Static IP attached to the NAT Gateway |
aws_route_table (×2) | Public and private routing rules |
aws_route_table_association (×4) | Wires subnets to the correct route table |
Terraform Concepts Covered
| Concept | Where |
|---|---|
Module structure (variables → main → outputs) | Throughout |
count meta-argument | Subnet and route table associations |
Conditional resources (count = condition ? 1 : 0) | NAT Gateway + EIP |
depends_on | NAT Gateway after IGW |
merge() for tags | Every resource |
| Outputs as module connectors | outputs.tf |
Complete this checklist before writing any code. A misconfigured environment causes cryptic errors that are hard to debug later.
| Tool | Version | Install |
|---|---|---|
| Terraform | ≥ 1.5 | terraform.io/downloads |
| AWS CLI | ≥ 2.x | aws.amazon.com/cli |
| AWS Account | Free Tier OK | aws.amazon.com |
| Git | Any | git-scm.com |
| VS Code | Any | code.visualstudio.com |
Verify your setup:
# Terraform installed
terraform --version
# AWS CLI installed
aws --version
# Credentials configured
aws sts get-caller-identity
If aws sts get-caller-identity fails, run aws configure and enter your
Access Key ID, Secret Access Key, and set the default region to us-east-1.
Repository Structure
Terraform reads all .tf files in a directory — the file names are
purely for human organisation. Splitting locals, data, and main
into separate files is a convention, not a requirement.
Step 1 — Initialize the Repository
Create the project folder, scaffold the files, and initialise Git.
mkdir tf-aws-vpc-baseline
cd tf-aws-vpc-baseline
git init
# Scaffold all Terraform files
touch main.tf variables.tf outputs.tf versions.tf
# Create the examples directory
mkdir -p examples/basic
# Create a .gitignore
cat > .gitignore << 'EOF'
*.tfvars
*.tfstate
*.tfstate.backup
.terraform/
.terraform.lock.hcl
crash.log
EOF
Run ls -la to confirm the .git/ folder and all files were created before
moving on.
Step 2 — Write versions.tf
Always start here. This file locks the Terraform engine and AWS provider versions so the module behaves identically on every machine and in every CI/CD pipeline.
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
Version constraint cheat sheet:
| Constraint | Meaning |
|---|---|
>= 1.5.0 | 1.5 or any newer version |
~> 5.0 | 5.x only — blocks 6.0 |
= 5.2.0 | Exact version only |
Without version constraints a teammate on Terraform 0.12 would silently break your code. Always pin your versions.
Step 3 — Write variables.tf
Variables make your module reusable. Instead of hardcoding a CIDR block in
10 places, you accept it as an input once and reference var.vpc_cidr everywhere.
# variables.tf
variable "aws_region" {
description = "AWS region to deploy the VPC"
type = string
default = "us-east-1"
}
variable "vpc_name" {
description = "Name tag applied to the VPC and all child resources"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for the VPC (e.g. 10.0.0.0/16)"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
description = "List of CIDR blocks for public subnets (one per AZ)"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
description = "List of CIDR blocks for private subnets (one per AZ)"
type = list(string)
default = ["10.0.10.0/24", "10.0.20.0/24"]
}
variable "availability_zones" {
description = "List of AZs to deploy subnets into"
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
variable "enable_nat_gateway" {
description = "Set to false to skip NAT Gateway (saves ~$32/month in dev)"
type = bool
default = true
}
variable "tags" {
description = "Map of additional tags to apply to all resources"
type = map(string)
default = {}
}
variables vs locals — variables are for inputs (values passed in
from outside the module). Locals are for derived values computed from
other variables. If the value comes from outside, use a variable.
Step 4 — Write main.tf
This is the core of the module. Build it in logical sections — each resource group depends on the previous one, which mirrors how AWS actually wires networking together.
4a — VPC
# main.tf
# ── VPC ──────────────────────────────────────────────────────────────────────
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true # lets instances resolve AWS hostnames
enable_dns_hostnames = true # gives EC2 instances public DNS names
tags = merge(var.tags, {
Name = var.vpc_name
})
}
Always set both DNS flags to true in production VPCs. Many AWS services
(RDS, EKS, ECS) require them to resolve internal hostnames correctly.
4b — Internet Gateway
# ── Internet Gateway ─────────────────────────────────────────────────────────
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.tags, {
Name = "${var.vpc_name}-igw"
})
}
4c — Public & Private Subnets
# ── Public Subnets (one per AZ) ───────────────────────────────────────────────
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
# Instances launched here get a public IP automatically
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.vpc_name}-public-${count.index + 1}"
Tier = "public"
})
}
# ── Private Subnets (one per AZ) ──────────────────────────────────────────────
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = merge(var.tags, {
Name = "${var.vpc_name}-private-${count.index + 1}"
Tier = "private"
})
}
How count.index works: count = length(var.public_subnet_cidrs) creates
one resource per item in the list. count.index is the zero-based position
(0, 1, 2…). Add a CIDR to the list → get a new subnet automatically.
4d — NAT Gateway (conditional)
# ── Elastic IP for NAT Gateway ────────────────────────────────────────────────
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? 1 : 0
domain = "vpc"
tags = merge(var.tags, {
Name = "${var.vpc_name}-nat-eip"
})
}
# ── NAT Gateway (deployed in first public subnet) ─────────────────────────────
resource "aws_nat_gateway" "this" {
count = var.enable_nat_gateway ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = merge(var.tags, {
Name = "${var.vpc_name}-nat"
})
# NAT Gateway must be created after the IGW is attached to the VPC
depends_on = [aws_internet_gateway.this]
}
The NAT Gateway costs ~$0.045/hour + data transfer charges. Set
enable_nat_gateway = false in dev environments to save ~$32/month.
4e — Route Tables
# ── Public Route Table → 0.0.0.0/0 via Internet Gateway ──────────────────────
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = merge(var.tags, {
Name = "${var.vpc_name}-public-rt"
})
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# ── Private Route Table → 0.0.0.0/0 via NAT Gateway ─────────────────────────
resource "aws_route_table" "private" {
vpc_id = aws_vpc.this.id
dynamic "route" {
for_each = var.enable_nat_gateway ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[0].id
}
}
tags = merge(var.tags, {
Name = "${var.vpc_name}-private-rt"
})
}
resource "aws_route_table_association" "private" {
count = length(var.private_subnet_cidrs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
The golden rule: A subnet is "public" if its route table has a route to an Internet Gateway. A subnet is "private" if it routes through a NAT Gateway. The subnet setting alone doesn't make it public or private — the route table does.
Step 5 — Write outputs.tf
Outputs are the "LEGO connectors" of your module. When another Terraform
project calls tf-aws-vpc-baseline, it reads these outputs to get the VPC ID,
subnet IDs, and CIDRs it needs — without knowing or caring about the internal
implementation.
# outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.this.id
}
output "vpc_cidr" {
description = "The CIDR block of the VPC"
value = aws_vpc.this.cidr_block
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
output "public_subnet_cidrs" {
description = "List of public subnet CIDR blocks"
value = aws_subnet.public[*].cidr_block
}
output "private_subnet_cidrs" {
description = "List of private subnet CIDR blocks"
value = aws_subnet.private[*].cidr_block
}
output "nat_gateway_id" {
description = "NAT Gateway ID (null if disabled)"
value = length(aws_nat_gateway.this) > 0 ? aws_nat_gateway.this[0].id : null
}
output "internet_gateway_id" {
description = "Internet Gateway ID"
value = aws_internet_gateway.this.id
}
Step 6 — Create terraform.tfvars
# terraform.tfvars — add this file to .gitignore!
aws_region = "us-east-1"
vpc_name = "my-baseline-vpc"
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.10.0/24", "10.0.20.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
enable_nat_gateway = true
tags = {
Environment = "lab"
Project = "tf-aws-vpc-baseline"
ManagedBy = "terraform"
}
Step 7 — Plan, Apply, Verify
Initialize
# Downloads the AWS provider plugin (~30 seconds)
terraform init
Expected output:
Terraform has been successfully initialized!
Format & Validate
# Auto-formats all .tf files to canonical style
terraform fmt
# Validates syntax and configuration logic
terraform validate
Expected output:
Success! The configuration is valid.
Plan
terraform plan
Read the plan carefully before applying. You should see ~12–14 resources to create.
| Symbol | Meaning |
|---|---|
+ | New resource being created |
~ | Existing resource being changed |
- | Existing resource being deleted |
Apply
terraform apply
# Type 'yes' when prompted
The NAT Gateway takes 2–3 minutes to provision. The apply will appear to pause — this is normal. Do not cancel it.
After apply, view your outputs:
terraform output
Expected outputs:
internet_gateway_id = "igw-0abc..."
nat_gateway_id = "nat-0def..."
private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24"]
private_subnet_ids = ["subnet-0aaa...", "subnet-0bbb..."]
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnet_ids = ["subnet-0ccc...", "subnet-0ddd..."]
vpc_cidr = "10.0.0.0/16"
vpc_id = "vpc-0eee..."
Step 8 — Verify in AWS Console
Always validate that what Terraform built actually exists. This builds debugging muscle memory.
| # | Navigate to | What to confirm |
|---|---|---|
| 1 | VPC → Your VPCs | my-baseline-vpc with CIDR 10.0.0.0/16 |
| 2 | VPC → Subnets | 4 subnets: 2 public, 2 private across 2 AZs |
| 3 | VPC → Internet Gateways | IGW attached to your VPC |
| 4 | VPC → NAT Gateways | NAT GW in Available state |
| 5 | VPC → Route Tables | Public RT: 0.0.0.0/0 → IGW, Private RT: 0.0.0.0/0 → NAT |
| 6 | VPC → Route Tables → Associations | Subnets are associated to the correct route table |
Cleanup
NAT Gateways cost ~$0.045/hour. Always destroy lab resources when finished.
terraform destroy
# Type 'yes' to confirm (~3–5 minutes to complete)
# Verify resources are gone
aws ec2 describe-vpcs \
--filters Name=tag:Project,Values=tf-aws-vpc-baseline
Key Concepts Summary
| Concept | What You Now Understand |
|---|---|
| Module structure | Every reusable module uses the same 4-file pattern |
count meta-argument | How to create multiple similar resources from a list |
| Conditional resources | count = condition ? 1 : 0 to make resources optional |
depends_on | Explicit ordering when Terraform can't infer the dependency |
merge() for tags | Combining default and custom tags on every resource |
| VPC networking | IGW = public access; NAT GW = private outbound access |
| Outputs as connectors | How modules expose values for other modules to consume |
| Terraform workflow | init → fmt → validate → plan → apply → verify → destroy |