Provision an AWS VPC with Terraform
How to use Terraform to provision a production-ready AWS VPC with public and private subnets, NAT gateways, and security groups
A well-structured VPC is the foundation of secure cloud infrastructure. Terraform allows you to define the entire network topology — subnets, routing, gateways, and security groups — as version-controlled code that can be recreated identically across environments.
When to Use This
- You need repeatable, version-controlled network infrastructure across dev/staging/prod. See Git Workflow for version-controlled infrastructure.
- Applications require both public-facing and internal-only resources. See AWS ECS Fargate for container deployment.
- You want to enforce network segmentation between different tiers. See Load Balancing HAProxy for tier separation.
Prerequisites
- AWS CLI configured with appropriate credentials
- Terraform 1.5+ installed
Solution
1. VPC and Subnets
# vpc.tf
locals {
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "production-vpc"
}
}
resource "aws_subnet" "public" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = {
Name = "public-subnet-${count.index + 1}"
Type = "public"
}
}
resource "aws_subnet" "private" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 100)
availability_zone = local.azs[count.index]
tags = {
Name = "private-subnet-${count.index + 1}"
Type = "private"
}
}
2. Internet and NAT Gateways
# gateways.tf
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "main-igw" }
}
resource "aws_eip" "nat" {
count = length(local.azs)
domain = "vpc"
tags = { Name = "nat-eip-${count.index + 1}" }
}
resource "aws_nat_gateway" "main" {
count = length(local.azs)
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "nat-gateway-${count.index + 1}" }
}
3. Route Tables
# routing.tf
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 = "public-rt" }
}
resource "aws_route_table_association" "public" {
count = length(local.azs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
tags = { Name = "private-rt-${count.index + 1}" }
}
resource "aws_route_table_association" "private" {
count = length(local.azs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
4. Security Groups
# security.tf
resource "aws_security_group" "web" {
name_prefix = "web-"
vpc_id = aws_vpc.main.id
description = "Allow HTTP and HTTPS traffic"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "web-sg" }
}
resource "aws_security_group" "database" {
name_prefix = "db-"
vpc_id = aws_vpc.main.id
description = "Allow database access from web tier only"
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id]
}
tags = { Name = "database-sg" }
}
How It Works
- VPC defines the private IP address space for all resources
- Public Subnets route traffic through the Internet Gateway for external access
- Private Subnets route outbound traffic through NAT Gateways for security
- Route Tables control traffic direction per subnet
- Security Groups act as stateful firewalls at the instance level
Production Considerations
- Use Terraform workspaces or separate state files for each environment
- Enable VPC Flow Logs to CloudWatch for network traffic auditing
- Place databases and internal services in private subnets only
- Use AWS Network Firewall or Security Groups for defense in depth
Common Mistakes
- Forgetting to enable
map_public_ip_on_launchfor public subnet instances - Placing NAT Gateways in private subnets instead of public subnets
- Using overly permissive CIDR blocks like
0.0.0.0/0in security group ingress
Performance Tips
- Use Gateway endpoints for S3 and DynamoDB. No per-GB charge, unlike Interface endpoints:
# Gateway endpoint: free, no per-GB cost
vpc_endpoint_type = "Gateway"
# Interface endpoint: $7/month + per-GB
vpc_endpoint_type = "Interface"
- Place NAT Gateways in each AZ. Cross-AZ NAT traffic incurs double data transfer costs:
# One NAT per AZ avoids cross-AZ charges
count = length(var.availability_zones)
- Use
aws_ec2_transit_gatewayfor multi-VPC. Peering scales as O(n²) connections; Transit Gateway is O(n):
resource "aws_ec2_transit_gateway" "main" {
description = "Platform transit gateway"
tags = local.common_tags
} Frequently Asked Questions
Should I use one NAT Gateway or one per AZ?
One per AZ eliminates a single point of failure and avoids cross-AZ data transfer costs. For cost-sensitive dev environments, one NAT Gateway is acceptable.
How do I peer this VPC with another?
Use aws_vpc_peering_connection and add routes in both VPCs pointing to the peered CIDR block.
Can I import an existing VPC into Terraform?
Yes. Use terraform import aws_vpc.main <vpc-id> and then write the matching configuration.
Related Resources
Deploy Containers to AWS ECS with Fargate
How to deploy Docker containers to AWS ECS using Fargate serverless compute with Terraform and GitHub Actions
GuideInfrastructure as Code — Terraform and Pulumi
A practical guide to managing infrastructure as code: benefits of declarative vs imperative approaches, state management, modules, and testing infrastructure changes.
PatternBuilder Pattern for Complex Configuration Objects
Use the Builder pattern to construct complex configuration objects with optional parameters and sensible defaults without telescoping constructors
RecipeLoad Balancing with HAProxy and Health Checks
Configure HAProxy as a high-performance load balancer with active health checks, sticky sessions, and SSL termination for resilient service distribution
RecipeConfigure Nginx as a Reverse Proxy and API Gateway
How to use Nginx as a reverse proxy for backend services, implement load balancing, SSL termination, and rate limiting for production API gateways
RecipeAnsible Playbook for Server Configuration
How to write and run Ansible playbooks for provisioning, configuring, and managing servers with idempotent tasks, roles, and inventory files.