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
Overview
Every workload you run in AWS lands in a VPC, and the default one rarely fits production: everything sits in public subnets, flow logs are off, and egress rules are wide open. This recipe builds a production-ready VPC with Terraform: public and private subnets across three AZs, NAT gateways for outbound traffic, least-privilege security groups, flow logs, and VPC endpoints so internal traffic never touches the internet.
When to Use This
- You need repeatable, version-controlled network infrastructure across dev, staging, and prod; recreating the same topology by hand in each account doesn’t scale. See Git Workflow for keeping that code in version control.
- The only things that should talk to the internet are your load balancers — everything behind them (app servers, databases) belongs in subnets with no public route at all. See AWS ECS Fargate for running containers in those subnets.
- You want real segmentation between tiers: a load balancer that only reaches app ports, an app tier that only reaches the database. See Load Balancing HAProxy for the layer above this.
- Compliance asks for flow logs and documented network boundaries, not a diagram drawn after the fact.
Prerequisites
- AWS CLI configured with appropriate credentials
- Terraform 1.5+ installed
Solution
The snippets below are copy-paste ready. A complete runnable version (variables, outputs, and the single-NAT toggle wired through the route tables) lives in the companion repository.
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" }
}
5. VPC Flow Logs
# flow_logs.tf
resource "aws_cloudwatch_log_group" "vpc_flow" {
name = "/aws/vpc/flow-logs"
retention_in_days = 30
}
resource "aws_iam_role" "vpc_flow" {
name = "vpc-flow-logs-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "vpc-flow-logs.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "vpc_flow" {
name = "vpc-flow-logs-policy"
role = aws_iam_role.vpc_flow.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups"
]
Resource = "*"
}]
})
}
resource "aws_flow_log" "main" {
vpc_id = aws_vpc.main.id
iam_role_arn = aws_iam_role.vpc_flow.arn
log_destination = aws_cloudwatch_log_group.vpc_flow.arn
log_destination_type = "cloud-watch-logs"
traffic_type = "ALL"
}
6. VPC Endpoints for AWS Services
# endpoints.tf
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [for rt in aws_route_table.private : rt.id]
}
resource "aws_vpc_endpoint" "secrets_manager" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = [for s in aws_subnet.private : s.id]
security_group_ids = [aws_security_group.database.id]
private_dns_enabled = true
}
7. Network ACLs for Defense in Depth
# nacls.tf
resource "aws_network_acl" "public" {
vpc_id = aws_vpc.main.id
subnet_ids = [for s in aws_subnet.public : s.id]
ingress {
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 443
to_port = 443
}
ingress {
protocol = "tcp"
rule_no = 110
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
egress {
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
}
8. Variables for Reusability
These variables turn the snippets above into a reusable module. One caveat: the conditional NAT block below replaces the aws_nat_gateway.main resource from gateways.tf — keep one or the other. Applying both fails with a duplicate resource error.
# variables.tf
variable "environment" {
type = string
default = "production"
}
variable "cidr_block" {
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "single_nat_gateway" {
type = bool
default = false
}
# Conditional NAT: one per AZ for prod, single for dev
resource "aws_nat_gateway" "main" {
count = var.single_nat_gateway ? 1 : length(var.availability_zones)
allocation_id = var.single_nat_gateway ? aws_eip.nat[0].id : aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
}
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
A request from a private subnet to the internet crosses three layers: subnet → route table → NAT gateway → IGW.
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
}
Further Reading
- Terraform Registry — aws_vpc
- Terraform Registry — aws_nat_gateway
- AWS docs — VPC endpoints
- AWS docs — VPC Flow Logs
- terraform-aws-modules/vpc — the community module worth evaluating before writing your own
Frequently Asked Questions
Should I use one NAT Gateway or one per AZ?
One per AZ. A single NAT gateway is a single point of failure — if its AZ goes down, private subnets in the other zones lose internet access — and it forces cross-AZ data transfer, which AWS bills twice. For a throwaway dev environment one NAT is acceptable; production should run one per AZ.
Can I use the terraform-aws-modules VPC module instead of writing this myself?
In most cases, yes. The community module is the faster path because edge cases like IPv6 and endpoint policies come solved out of the box. Rolling your own pays off when you want full control over every resource or you're learning how the pieces fit together; reading the module's source teaches you the same things.
How do I peer this VPC with another one?
An aws_vpc_peering_connection plus a route in each VPC's route table pointing at the other's CIDR is all it takes. The gotcha is overlapping address space: two VPCs that share 10.0.0.0/16 can never peer, which is worth remembering before you stamp out the same CIDR everywhere.
Can I import an existing VPC into this configuration?
You can. Run terraform import aws_vpc.main vpc-abc123 to bring the VPC under management, then write config that matches what already exists. Importing subnets, route tables, and gateways one by one is tedious but works; for large existing networks, tools like terraformer can generate the initial HCL.
Related Resources
Terraform Best Practices: Modules, State, and Workspaces
A practical guide to Terraform best practices: module design, remote state management, workspaces, and security for production-grade infrastructure as code.
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.
RecipeDeploy Containers to AWS ECS with Fargate
How to deploy Docker containers to AWS ECS using Fargate serverless compute with Terraform and GitHub Actions
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.
RecipeCanary Deployments with Istio Service Mesh
How to use Istio traffic splitting to perform safe canary deployments by gradually shifting user traffic between application versions
PatternBuilder Pattern: Step-by-Step Object Construction
Construct complex objects step by step. A creational design pattern for readable, configurable object construction.