Deploying a Production VPC on AWS with Terraform

GitHub Repository: tf-aws

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

ResourcePurpose
aws_vpcThe network boundary — your private slice of AWS
aws_subnet (×4)2 public + 2 private, one per Availability Zone
aws_internet_gatewayConnects public subnets to the internet
aws_nat_gatewayLets private subnets make outbound requests securely
aws_eipStatic 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

ConceptWhere
Module structure (variablesmainoutputs)Throughout
count meta-argumentSubnet and route table associations
Conditional resources (count = condition ? 1 : 0)NAT Gateway + EIP
depends_onNAT Gateway after IGW
merge() for tagsEvery resource
Outputs as module connectorsoutputs.tf


ToolVersionInstall
Terraform≥ 1.5terraform.io/downloads
AWS CLI≥ 2.xaws.amazon.com/cli
AWS AccountFree Tier OKaws.amazon.com
GitAnygit-scm.com
VS CodeAnycode.visualstudio.com

Verify your setup:

# Terraform installed
terraform --version

# AWS CLI installed
aws --version

# Credentials configured
aws sts get-caller-identity

Repository Structure

main.tfCore resources — VPC, subnets, IGW, NAT
variables.tfAll input variables with defaults
outputs.tfExported values for other modules
versions.tfTerraform & provider version pins
terraform.tfvarsYour local values (gitignored)
.gitignore
README.md

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


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:

ConstraintMeaning
>= 1.5.01.5 or any newer version
~> 5.05.x only — blocks 6.0
= 5.2.0Exact version only


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     = {}
}


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
  })
}

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"
  })
}

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]
}

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
}


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.

SymbolMeaning
+New resource being created
~Existing resource being changed
-Existing resource being deleted

Apply

terraform apply
# Type 'yes' when prompted

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 toWhat to confirm
1VPC → Your VPCsmy-baseline-vpc with CIDR 10.0.0.0/16
2VPC → Subnets4 subnets: 2 public, 2 private across 2 AZs
3VPC → Internet GatewaysIGW attached to your VPC
4VPC → NAT GatewaysNAT GW in Available state
5VPC → Route TablesPublic RT: 0.0.0.0/0 → IGW, Private RT: 0.0.0.0/0 → NAT
6VPC → Route Tables → AssociationsSubnets are associated to the correct route table


Cleanup


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

ConceptWhat You Now Understand
Module structureEvery reusable module uses the same 4-file pattern
count meta-argumentHow to create multiple similar resources from a list
Conditional resourcescount = condition ? 1 : 0 to make resources optional
depends_onExplicit ordering when Terraform can't infer the dependency
merge() for tagsCombining default and custom tags on every resource
VPC networkingIGW = public access; NAT GW = private outbound access
Outputs as connectorsHow modules expose values for other modules to consume
Terraform workflowinit → fmt → validate → plan → apply → verify → destroy