Every resource you will ever run on AWS — an EC2 instance, an EKS node, an RDS database, a Lambda in a VPC, an ALB — lives inside a Virtual Private Cloud (VPC). The VPC is your own logically-isolated slice of the AWS network: you own the address space, you decide which subnets are reachable from the internet and which are not, where traffic egresses, and how packets are filtered on the way. Get the VPC right and everything above it is straightforward; get it wrong and you spend the next quarter renumbering subnets on a running estate — the single least enjoyable operation in cloud, because subnet CIDRs are immutable and instances have to be recreated to move. This lesson builds the network layer the way a platform team actually builds it: as Terraform, so the address plan, the routing, the internet edge and the NAT are all reviewed in a pull request and reproduced identically in dev, staging and prod.
You will build a small but genuinely production-shaped three-tier VPC across two Availability Zones: a 10.0.0.0/16 VPC; public subnets (for the load balancer and the NAT gateways), private subnets (for application compute), and database subnets (isolated, no egress) — each tier fanned across two AZs from a single for_each block with CIDRs carved by cidrsubnet(); an Internet Gateway and a public route table that sends 0.0.0.0/0 to it; NAT Gateways (one per AZ for high availability, or a single shared one when you are optimising the bill) with per-AZ private route tables; a free S3 gateway VPC endpoint; and the DNS and flow-log settings a real VPC needs. We finish with a complete init → plan → apply → verify → destroy walkthrough and a troubleshooting table for the errors — and the surprise NAT bill — you will actually hit.
This lesson assumes you can already run the Terraform loop and have the AWS provider authenticated with a remote backend. If that is not yet true, read Terraform on AWS: Getting Started, Provider Authentication & S3/DynamoDB Backend first — it sets up the aws provider, the S3 + DynamoDB state backend, and the credential flow this lesson depends on. The for_each and count patterns used heavily here are covered in depth in Resources & Meta-Arguments: count, for_each, lifecycle. And once the network exists, Terraform on AWS: Security Groups, EC2 & Key Pairs drops compute into the very subnets you build here.
What you’ll build
The scenario is the one every organisation reaches within its first week on AWS: a VPC with a clear public/private split and redundancy across Availability Zones, so a single hardware fault or one AZ going dark does not take the application down. Public-facing entry points (a load balancer, the NAT gateways) sit in public subnets that have a route to the internet; application servers sit in private subnets that can reach out to the internet for patches and API calls but cannot be reached in; and the database sits in database subnets with no internet route at all. This is AWS’s own reference network — the shape the well-known community VPC module produces, the shape the Well-Architected Framework assumes — and it is small enough to stand up in one terraform apply yet complete enough to teach every core networking resource.
Concretely, the demo provisions, in us-east-1 across two AZs: one VPC (10.0.0.0/16, DNS enabled); six subnets — two public (10.0.0.0/24, 10.0.1.0/24), two private (10.0.8.0/24, 10.0.9.0/24), two database (10.0.16.0/24, 10.0.17.0/24) — all carved with cidrsubnet() and created from one for_each per tier over data.aws_availability_zones; one Internet Gateway; one public route table (0.0.0.0/0 → IGW) associated to both public subnets; two NAT Gateways (one per AZ, each with an Elastic IP) living in the public subnets; two private route tables (each 0.0.0.0/0 → its own AZ’s NAT) associated to the private subnets; one isolated database route table (local routes only); and a free S3 gateway VPC endpoint wired into the route tables. Verification uses aws ec2 describe-vpcs, aws ec2 describe-subnets, aws ec2 describe-nat-gateways and aws ec2 describe-route-tables; cleanup is a single terraform destroy.
Why Terraform rather than the console, the aws CLI, or CloudFormation? Because a network is the one layer where drift is dangerous and reproducibility is mandatory. A subnet added by hand in the console breaks the next apply; a 0.0.0.0/0 route someone added to a private route table during an incident and forgot to revert is a silent hole that turns a “private” subnet public. Terraform makes the whole topology a reviewed artifact and refuses to forget. The table below frames the trade-off.
| Tool | How you express the network | Reproducible across envs? | Drift handling | Best for |
|---|---|---|---|---|
| AWS Console | Click through the VPC wizard | No — manual, unversioned | None; changes are invisible | Learning, one-off inspection |
aws CLI / scripts |
Imperative aws ec2 create-* |
Partly (if scripted) | You write your own diffing | Quick fixes, glue automation |
| CloudFormation | Declarative YAML/JSON, AWS-native | Yes | Drift detection; not auto-corrected | AWS-only shops, Service Catalog tie-in |
Terraform (aws) |
Declarative HCL, multi-cloud | Yes — same code, per-env vars | plan shows drift; apply reconciles |
Platform teams, multi-cloud, PR review |
Here is the component map you will end the lesson with — every resource, the Terraform type that models it, and the one thing to remember about each.
| Component | Terraform resource | Address / key detail | One-line gotcha |
|---|---|---|---|
| VPC | aws_vpc |
cidr_block = "10.0.0.0/16" |
Set enable_dns_hostnames = true for public DNS + endpoints |
| AZ discovery | data.aws_availability_zones |
filter opt-in-not-required |
Never hard-code AZ names; regions differ |
| Public subnets | aws_subnet (via for_each) |
10.0.0.0/24, 10.0.1.0/24 |
map_public_ip_on_launch = true here only |
| Private subnets | aws_subnet (via for_each) |
10.0.8.0/24, 10.0.9.0/24 |
No auto public IP; egress via NAT |
| Database subnets | aws_subnet (via for_each) |
10.0.16.0/24, 10.0.17.0/24 |
No internet route at all |
| Internet Gateway | aws_internet_gateway |
one per VPC | Attaching it does nothing without a route |
| Public route table | aws_route_table + aws_route |
0.0.0.0/0 → IGW |
One shared table for all public subnets is fine |
| NAT Gateways | aws_nat_gateway + aws_eip |
one per AZ | Must live in a public subnet; costs money |
| Private route tables | aws_route_table + aws_route |
0.0.0.0/0 → NAT |
One per AZ so each routes to its own NAT |
| Associations | aws_route_table_association |
subnet ↔ route table | Unassociated subnets fall back to the main table |
| S3 endpoint | aws_vpc_endpoint (Gateway) |
com.amazonaws.<region>.s3 |
Free; adds a prefix-list route to the tables |
| Flow logs | aws_flow_log |
→ CloudWatch or S3 | CloudWatch needs an IAM role; S3 does not |
Plan the address space: CIDR sizing & cidrsubnet()
Before a single resource, you plan addresses — because in AWS, a subnet’s cidr_block is immutable once created (you can add secondary CIDRs to a VPC, but you cannot resize a subnet), and the VPC’s primary CIDR is fixed for life. The address plan is the one decision you cannot cheaply undo.
A VPC owns one or more IPv4 CIDR blocks. AWS accepts any of the RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and allows VPC sizes from /16 (65,536 addresses) down to /28 (16 addresses). The near-universal convention is a /16 per VPC — it gives you room for 256 /24 subnets, which is plenty of headroom for a decade of growth without ever renumbering. Like Azure, AWS reserves five addresses in every subnet, but for different roles.
Reserved IP in a subnet (e.g. 10.0.0.0/24) |
AWS use |
|---|---|
10.0.0.0 |
Network address |
10.0.0.1 |
VPC router (default gateway) |
10.0.0.2 |
AWS DNS (the .2 resolver — base of VPC + 2) |
10.0.0.3 |
Reserved for future use |
10.0.0.255 |
Network broadcast (not supported, still reserved) |
So a /24 gives you 251 usable IPs, not 254. That matters most for EKS (pods consume subnet IPs via the VPC CNI) and for large Auto Scaling Groups; size the subnet to the peak instance/pod count plus headroom.
| CIDR prefix | Total addresses | AWS-reserved | Usable | Typical use |
|---|---|---|---|---|
/28 |
16 | 5 | 11 | Tiny — a NAT/endpoint subnet |
/27 |
32 | 5 | 27 | Bastion, small management tier |
/26 |
64 | 5 | 59 | Small app tier |
/24 |
256 | 5 | 251 | Standard app/data subnet |
/22 |
1024 | 5 | 1019 | EKS node subnet (needs pod headroom) |
/20 |
4096 | 5 | 4091 | Large EKS / big ASG |
/16 |
65536 | 5 per subnet | — | Whole VPC address space |
The 3-tier subnet layout
The rule that governs everything downstream: subnet CIDRs must fall inside the VPC’s cidr_block, must not overlap each other, and — for any VPCs you intend to peer or connect via Transit Gateway — the whole VPCs must not overlap either. Plan the estate’s supernet once. The demo carves the /16 into /24s, leaving numeric gaps between tiers so each tier has room to grow into a /20 later without colliding.
| Tier | AZ us-east-1a |
AZ us-east-1b |
Route to internet? | Hosts |
|---|---|---|---|---|
| Public | 10.0.0.0/24 |
10.0.1.0/24 |
In & out (via IGW) | ALB, NAT gateways, bastion |
| Private | 10.0.8.0/24 |
10.0.9.0/24 |
Out only (via NAT) | EC2 app servers, EKS nodes, Lambda |
| Database | 10.0.16.0/24 |
10.0.17.0/24 |
None (local only) | RDS, ElastiCache, isolated data |
The tier gaps (.0, .8, .16) are deliberate: reserving 8 /24s per tier means the public tier could grow to 10.0.0.0/21, private to 10.0.8.0/21, database to 10.0.16.0/21 — all non-overlapping — the day you need bigger subnets, with no renumbering. This is exactly the discipline cidrsubnet() automates.
cidrsubnet(): carve CIDRs, never hand-count
Hard-coding CIDR strings is where address-plan bugs are born — a fat-fingered 10.0.10.0/24 that overlaps a neighbour surfaces only at apply time as InvalidSubnet.Conflict. Terraform’s built-in cidrsubnet(prefix, newbits, netnum) computes subnets deterministically from the VPC CIDR: newbits is how many bits to add to the prefix length, and netnum selects which of the resulting sub-blocks you want.
cidrsubnet("10.0.0.0/16", 8, 0) extends /16 by 8 bits to /24 and takes block 0 → 10.0.0.0/24. The table below is the exact carve the demo uses; read netnum as “which /24 inside the /16”.
| Call | newbits |
netnum |
Result | Tier / AZ |
|---|---|---|---|---|
cidrsubnet("10.0.0.0/16", 8, 0) |
8 | 0 | 10.0.0.0/24 |
Public / AZ-a |
cidrsubnet("10.0.0.0/16", 8, 1) |
8 | 1 | 10.0.1.0/24 |
Public / AZ-b |
cidrsubnet("10.0.0.0/16", 8, 8) |
8 | 8 | 10.0.8.0/24 |
Private / AZ-a |
cidrsubnet("10.0.0.0/16", 8, 9) |
8 | 9 | 10.0.9.0/24 |
Private / AZ-b |
cidrsubnet("10.0.0.0/16", 8, 16) |
8 | 16 | 10.0.16.0/24 |
Database / AZ-a |
cidrsubnet("10.0.0.0/16", 8, 17) |
8 | 17 | 10.0.17.0/24 |
Database / AZ-b |
Because the netnum is computed from the AZ index plus a per-tier offset, adding a third AZ is a one-number change (az_count = 3) and the CIDRs fall out automatically, guaranteed non-overlapping. That is the whole point: let the function own the arithmetic, and CIDR-overlap errors stop happening.
The VPC: aws_vpc
The VPC is the top-level container. Its one required argument is cidr_block; the two you must consciously set are the DNS toggles, because their defaults surprise people.
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr # "10.0.0.0/16"
instance_tenancy = "default"
enable_dns_support = true # default true — the .2 resolver works
enable_dns_hostnames = true # default FALSE — set true for public DNS + interface endpoints
tags = {
Name = "${var.name}-vpc"
}
}
enable_dns_support (on by default) is what makes the 10.0.0.2 resolver answer inside the VPC — leave it on. enable_dns_hostnames is off by default, and that default bites: without it, instances with public IPs get no public DNS name, and interface VPC endpoints cannot use private DNS. For any real VPC, set it to true.
| Argument | Type | Required | Notes |
|---|---|---|---|
cidr_block |
string | yes* | Primary IPv4 CIDR, e.g. 10.0.0.0/16 (immutable) |
enable_dns_support |
bool | no | Default true; the .2 DNS resolver |
enable_dns_hostnames |
bool | no | Default false; set true for public DNS + endpoint private DNS |
instance_tenancy |
string | no | default (shared) or dedicated (pricey — avoid unless required) |
assign_generated_ipv6_cidr_block |
bool | no | Get an AWS-provided /56 IPv6 block |
ipv4_ipam_pool_id |
string | no | Allocate the CIDR from an IPAM pool instead of cidr_block |
tags |
map(string) | no | Name shows in the console; add cost/owner tags |
*Either cidr_block or an IPAM pool must supply the primary CIDR.
The two DNS attributes are worth pinning down because they gate features you will reach for immediately (public DNS names, private-DNS interface endpoints):
| Attribute | Default | What it enables | Set it when |
|---|---|---|---|
enable_dns_support |
true |
The VPC .2 DNS resolver answers queries |
Always leave on |
enable_dns_hostnames |
false |
Public DNS names for public-IP instances; private DNS for interface endpoints | Always, for a real VPC |
Subnets across AZs: aws_subnet, the AZ data source & for_each
A subnet is a slice of the VPC bound to exactly one Availability Zone. That AZ binding is the whole reason we fan subnets out: to survive an AZ failure you need at least one subnet per tier in each of two (ideally three) AZs, and a load balancer or ASG spread across them.
Never hard-code AZ names. us-east-1 exposes six AZs; ca-central-1 exposes three; and — the subtle one — us-east-1a in your account is not necessarily the same physical datacentre as us-east-1a in mine (AWS randomises the letter-to-hardware mapping per account to spread load). Discover AZs at plan time with a data source, and filter out opt-in regions (Local Zones, Wavelength) that would otherwise sneak into the list:
data "aws_availability_zones" "available" {
state = "available"
filter {
name = "opt-in-status"
values = ["opt-in-not-required"]
}
}
locals {
# Take the first N AZs the region offers (N = var.az_count).
azs = slice(data.aws_availability_zones.available.names, 0, var.az_count)
# Carve each tier as a map of AZ-name => CIDR, computed with cidrsubnet().
public_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i) }
private_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i + 8) }
database_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i + 16) }
}
Each local.*_subnets is a map keyed by AZ name ({"us-east-1a" = "10.0.0.0/24", "us-east-1b" = "10.0.1.0/24"}). Feeding that map to for_each gives one subnet per AZ, keyed by a stable string — which is the entire reason to prefer for_each over count here.
resource "aws_subnet" "public" {
for_each = local.public_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
map_public_ip_on_launch = true # public tier: auto-assign a public IP
tags = { Name = "${var.name}-public-${each.key}", Tier = "public" }
}
resource "aws_subnet" "private" {
for_each = local.private_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key # no map_public_ip_on_launch → stays private
tags = { Name = "${var.name}-private-${each.key}", Tier = "private" }
}
resource "aws_subnet" "database" {
for_each = local.database_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
tags = { Name = "${var.name}-db-${each.key}", Tier = "database" }
}
The single distinguishing argument is map_public_ip_on_launch — set it true only on the public tier so instances launched there receive a public IPv4 automatically. Leave it off (the default) everywhere else; a private/database instance must never get a public IP.
| Argument | Type | Notes |
|---|---|---|
vpc_id |
string | Parent VPC |
cidr_block |
string | The subnet’s slice; must be inside the VPC CIDR, no overlap |
availability_zone |
string | e.g. us-east-1a; or use availability_zone_id (use1-az1) for cross-account stability |
map_public_ip_on_launch |
bool | true only on the public tier; default false |
assign_ipv6_address_on_creation |
bool | Auto-assign IPv6 if the VPC has an IPv6 block |
ipv6_cidr_block |
string | The subnet’s IPv6 /64 |
tags |
map(string) | Tier tags make route-table wiring and cost reports easy |
A note on availability_zone vs availability_zone_id: the name (us-east-1a) is account-scoped and randomised; the ID (use1-az1) is stable and identical across accounts. For a single-account demo, names are fine. For multi-account architectures where two accounts must land in the same physical AZ (to avoid cross-AZ data charges between them), pin availability_zone_id.
Why for_each over count for subnets
You could write count = var.az_count and index AZs with data.aws_availability_zones.available.names[count.index]. Don’t — for the same reason the Azure lesson avoids it. count keys resources by numeric index, so removing the first AZ from the middle of the list shifts every later index down and Terraform force-replaces every subnet after the gap. for_each keys by the AZ name string, a stable identity: dropping one AZ deletes exactly that subnet and touches nothing else. The mechanics — and when count is the right tool — are covered in Resources & Meta-Arguments: count, for_each, lifecycle.
| Meta-argument | Keys resources by | Add/remove one AZ | Verdict for subnets |
|---|---|---|---|
count = N |
numeric index ([0], [1]) |
Index shift → force-replaces later subnets | Avoid for named, per-AZ sets |
for_each = map |
map key (["us-east-1a"]) |
Adds/deletes exactly that subnet | Use this |
The Internet Gateway & the public route table
An Internet Gateway (IGW) is the VPC’s door to the public internet — a horizontally-scaled, fully-managed, free-to-attach component (you pay only for data transfer, and only the normal egress rate). One IGW attaches to one VPC. But attaching it does nothing on its own: a subnet is “public” only when a route table associated with it sends 0.0.0.0/0 to that IGW. That is the definition of a public subnet on AWS — not a flag, but a route.
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.name}-igw" }
}
# ONE public route table, shared by all public subnets (they all egress the same way)
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.name}-rt-public" }
}
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
# Associate every public subnet with that one table
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
Two modelling choices worth calling out. First, the internet route is a separate aws_route resource, not an inline route {} block inside the route table. You can declare routes inline, but — exactly like Azure’s inline-vs-standalone NSG rules — mixing inline route blocks with standalone aws_route resources on the same table makes every plan flap (the table thinks it owns the full route set; the standalone route looks like an extra). Pick one model per table; this lesson uses standalone aws_route throughout so the VPC endpoint and the NAT route can attach cleanly. Second, all public subnets share one route table — they route identically (out via the IGW), so one table is correct and cheaper to reason about. Private subnets, as you will see, do not share, because each must route to the NAT in its own AZ.
Here is the routing plan for the whole VPC — the reference table to keep. Read each route table top-to-bottom as AWS evaluates it (most-specific prefix wins; the local route is implicit and always present):
| Route table | Associated subnets | Destination | Target | Result |
|---|---|---|---|---|
| Public (shared) | both public subnets | 10.0.0.0/16 |
local |
Intra-VPC (implicit) |
0.0.0.0/0 |
IGW | Direct internet, in & out | ||
pl-63a5400a (S3) |
S3 gateway VPCe | S3 over the backbone (free) | ||
| Private-a | private subnet AZ-a | 10.0.0.0/16 |
local |
Intra-VPC |
0.0.0.0/0 |
NAT-a | Egress only, via AZ-a NAT | ||
| Private-b | private subnet AZ-b | 0.0.0.0/0 |
NAT-b | Egress only, via AZ-b NAT |
| Database (shared) | both database subnets | 10.0.0.0/16 |
local |
No 0.0.0.0/0 — fully isolated |
| Resource / argument | Belongs to | Notes |
|---|---|---|
aws_internet_gateway.vpc_id |
IGW | One IGW per VPC; free to attach |
aws_route_table.vpc_id |
route table | A table belongs to the VPC, not a subnet |
aws_route.route_table_id |
route | Which table this route lives in |
aws_route.destination_cidr_block |
route | The prefix this route matches (0.0.0.0/0 = default) |
aws_route.gateway_id |
route | Target for IGW routes (also nat_gateway_id, transit_gateway_id, vpc_peering_connection_id) |
aws_route_table_association.subnet_id |
association | The subnet to bind |
aws_route_table_association.route_table_id |
association | The table to bind it to |
The unassociated-subnet trap: a subnet with no explicit aws_route_table_association silently falls back to the VPC’s main route table. If that main table happens to have an internet route, a subnet you think is private is quietly public — and vice versa. Always associate every subnet explicitly (as the demo does) so nothing depends on the main table’s contents.
NAT Gateways: private egress, HA vs cost
Private-subnet instances often still need to reach out — to download OS patches, call a third-party API, pull from a public container registry — without being reachable in. A NAT Gateway provides exactly that: outbound-only internet for private subnets. The mechanics that trip people up:
- A NAT Gateway lives in a public subnet. It needs a route to the IGW to function, so it sits in a public subnet and carries an Elastic IP. Putting a NAT in a private subnet is the number-one “why is there no internet from my private subnet?” cause — it simply cannot reach the internet itself.
- Traffic flows: private subnet → (private route table
0.0.0.0/0) → NAT in the public subnet → IGW → internet. The private route table points at the NAT, never the IGW. - NAT is zonal. A NAT Gateway lives in one AZ. If that AZ fails, every private subnet routing through it loses egress. For true HA you deploy one NAT per AZ and point each AZ’s private subnets at their local NAT.
locals {
# One NAT per AZ (HA), or a single shared NAT in the first AZ (cheap).
nat_azs = var.single_nat_gateway ? slice(local.azs, 0, 1) : local.azs
}
resource "aws_eip" "nat" {
for_each = toset(local.nat_azs)
domain = "vpc" # replaces the deprecated `vpc = true`
tags = { Name = "${var.name}-eip-nat-${each.key}" }
}
resource "aws_nat_gateway" "nat" {
for_each = toset(local.nat_azs)
allocation_id = aws_eip.nat[each.key].id
subnet_id = aws_subnet.public[each.key].id # NAT lives in the PUBLIC subnet
tags = { Name = "${var.name}-nat-${each.key}" }
# Ensure the IGW exists first — a NAT with no internet path is useless
depends_on = [aws_internet_gateway.igw]
}
# One private route table PER AZ, each pointing at its own AZ's NAT
resource "aws_route_table" "private" {
for_each = aws_subnet.private
vpc_id = aws_vpc.main.id
tags = { Name = "${var.name}-rt-private-${each.key}" }
}
resource "aws_route" "private_nat" {
for_each = aws_route_table.private
route_table_id = each.value.id
destination_cidr_block = "0.0.0.0/0"
# Own-AZ NAT when HA; the single shared NAT when single_nat_gateway = true
nat_gateway_id = aws_nat_gateway.nat[
var.single_nat_gateway ? local.nat_azs[0] : each.key
].id
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private[each.key].id
}
Two details make this correct and idiomatic. domain = "vpc" is the current EIP argument (the old vpc = true boolean is deprecated in AWS provider v5). And depends_on = [aws_internet_gateway.igw] is the documented NAT best practice — a NAT created before its IGW route exists can briefly fail to provision. The single_nat_gateway toggle drives the classic cost/HA decision: when true, nat_azs collapses to one AZ, one NAT and one EIP are created, and every private route table points at that single NAT.
One NAT per AZ vs one shared NAT ⚠️
This is the most expensive decision in the whole VPC, and it is worth understanding before you apply. A NAT Gateway is not free and not cheap: it bills per-hour whether or not any traffic flows, plus a per-GB data-processing charge on top of normal egress. One-per-AZ doubles (or triples) that hourly cost but removes the single point of failure and eliminates cross-AZ data charges for egress.
| Dimension | One NAT per AZ (HA) | One shared NAT (cost) |
|---|---|---|
| Availability | Survives an AZ failure | AZ with the NAT fails → all private egress dies |
| Hourly cost (2 AZs) | ~2× the NAT hourly rate | ~1× |
| Cross-AZ data charge | None (egress stays in-AZ) | Private traffic in the other AZ crosses AZ to reach the NAT (extra per-GB) |
| Blast radius | Isolated per AZ | Whole VPC’s private egress |
single_nat_gateway |
false |
true |
| Use when | Production, real traffic | Dev/test, cost-sensitive, low egress |
The honest guidance: one NAT per AZ in production, one shared NAT in dev/test. The community VPC module exposes exactly this as single_nat_gateway and one_nat_gateway_per_az, because it is the single knob teams toggle most. Set the demo to single_nat_gateway = true while learning to halve the bill; flip it to false when you model production.
| Resource / argument | Belongs to | Notes |
|---|---|---|
aws_eip.domain |
EIP | "vpc"; the deprecated vpc = true still parses but warns |
aws_nat_gateway.allocation_id |
NAT | The EIP’s allocation ID |
aws_nat_gateway.subnet_id |
NAT | Must be a public subnet |
aws_nat_gateway.connectivity_type |
NAT | public (default) or private (NAT with no internet — for on-prem-only egress) |
aws_nat_gateway.depends_on |
NAT | Depend on the IGW so the internet path exists first |
aws_route.nat_gateway_id |
route | Target a NAT (vs gateway_id for the IGW) |
VPC endpoints: gateway (free) & interface (paid)
By default, a private instance reaching an AWS service like S3 or DynamoDB goes out through the NAT, over the internet, and back — paying NAT data-processing charges to talk to a service that lives inside AWS. VPC endpoints fix that: they keep the traffic on the AWS backbone, private, and — for the two gateway-type services — free. There are two kinds, and the difference is money and mechanism.
A Gateway endpoint (S3 and DynamoDB only) is a route-table entry: you attach it to route tables and AWS injects a managed prefix-list route so s3/dynamodb-bound traffic goes straight to the service. It costs nothing and processes no data charges. There is no reason not to create the S3 gateway endpoint in every VPC.
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
# Attach to every route table whose subnets talk to S3
route_table_ids = concat(
[aws_route_table.public.id],
[for rt in aws_route_table.private : rt.id],
[aws_route_table.database.id],
)
tags = { Name = "${var.name}-vpce-s3" }
}
An Interface endpoint (almost every other service — SSM, ECR, Secrets Manager, CloudWatch, KMS…) is a different animal: it puts an Elastic Network Interface with a private IP inside your subnets, fronted by a security group, and (with private_dns_enabled) rewrites the service’s DNS to that private IP. It is powerful — it removes the NAT hop and the internet exposure entirely — but it bills per-hour per-AZ plus per-GB processed, so you add them deliberately, for the services that justify it (ECR + SSM are the usual first two, to let private nodes pull images and be managed without a NAT at all).
resource "aws_vpc_endpoint" "ssm" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.ssm"
vpc_endpoint_type = "Interface"
subnet_ids = [for s in aws_subnet.private : s.id]
security_group_ids = [aws_security_group.endpoints.id]
private_dns_enabled = true # needs enable_dns_hostnames on the VPC
tags = { Name = "${var.name}-vpce-ssm" }
}
| Aspect | Gateway endpoint | Interface endpoint |
|---|---|---|
| Services | S3, DynamoDB only | Almost all others (SSM, ECR, KMS, SecretsMgr, CloudWatch…) |
| Mechanism | Route-table prefix-list route | ENI + private IP in your subnets + SG |
| Cost | Free | Per-hour per-AZ + per-GB processed |
| DNS change | None | private_dns_enabled rewrites the service hostname |
| Terraform args | route_table_ids |
subnet_ids, security_group_ids, private_dns_enabled |
| Replaces the NAT hop? | For S3/DynamoDB, yes (and free) | Yes, for that service (but you pay) |
| Create it when | Always (S3 at minimum) | The service is hot and you want to drop the NAT / stay private |
Common service_name |
Type | Why you’d add it |
|---|---|---|
com.amazonaws.<region>.s3 |
Gateway | Free S3 access off the NAT — always |
com.amazonaws.<region>.dynamodb |
Gateway | Free DynamoDB access — always if you use it |
com.amazonaws.<region>.ecr.dkr + .ecr.api |
Interface | Private nodes pull container images without a NAT |
com.amazonaws.<region>.ssm (+ ssmmessages, ec2messages) |
Interface | Session Manager / patching with no bastion, no NAT |
com.amazonaws.<region>.secretsmanager |
Interface | Fetch secrets privately |
com.amazonaws.<region>.logs |
Interface | Ship CloudWatch Logs privately |
The design pattern worth internalising: gateway endpoints are free wins — always add S3 (and DynamoDB if used). Interface endpoints are a deliberate trade — they let a subnet with no NAT still reach the AWS control plane (a genuinely private, cheaper-at-scale design), but each one has an hourly floor, so add them per-service as traffic justifies.
DNS resolution & VPC Flow Logs
Two operational features round out a real VPC. DNS you already handled on the VPC (enable_dns_support + enable_dns_hostnames) — those two toggles are what make the .2 resolver work and give instances and interface endpoints usable names.
VPC Flow Logs capture metadata about IP traffic (source, destination, ports, bytes, ACCEPT/REJECT) — indispensable for security forensics, debugging “why can’t A reach B”, and detecting exfiltration. Logs go to CloudWatch Logs, S3, or Kinesis Data Firehose. The one gotcha: CloudWatch delivery requires an IAM role the flow-log service assumes; S3 delivery does not (you grant via a bucket policy instead), which makes S3 the lighter-weight choice for high-volume, cheap, queryable-with-Athena logs.
# --- Flow logs to CloudWatch (needs an IAM role) ---
resource "aws_cloudwatch_log_group" "flow" {
name = "/vpc/${var.name}/flow-logs"
retention_in_days = 14
}
resource "aws_iam_role" "flow" {
name = "${var.name}-vpc-flow-log-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "vpc-flow-logs.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "flow" {
role = aws_iam_role.flow.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogGroups", "logs:DescribeLogStreams"]
Resource = "*"
}]
})
}
resource "aws_flow_log" "vpc" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL" # ACCEPT | REJECT | ALL
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.flow.arn
iam_role_arn = aws_iam_role.flow.arn
}
For the cheaper S3 path, drop the role and log group and point at a bucket ARN instead — no IAM role needed:
resource "aws_flow_log" "vpc_s3" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
log_destination_type = "s3"
log_destination = aws_s3_bucket.flow.arn
# optional: Parquet + hourly partitions for cheap Athena queries
destination_options {
file_format = "parquet"
per_hour_partition = true
}
}
| Destination | log_destination_type |
IAM role needed? | Best for |
|---|---|---|---|
| CloudWatch Logs | cloud-watch-logs |
Yes (assume vpc-flow-logs.amazonaws.com) |
Live alerting, Logs Insights queries |
| S3 | s3 |
No (bucket policy) | High volume, cheap retention, Athena/Parquet |
| Kinesis Data Firehose | kinesis-data-firehose |
Yes | Streaming to a SIEM / partner |
aws_flow_log argument |
Notes |
|---|---|
vpc_id / subnet_id / eni_id |
Log a whole VPC, one subnet, or one ENI (choose one) |
traffic_type |
ACCEPT, REJECT, or ALL |
log_destination_type |
cloud-watch-logs (default), s3, kinesis-data-firehose |
log_destination |
The log group ARN, bucket ARN, or Firehose ARN |
iam_role_arn |
Required only for CloudWatch / Firehose |
max_aggregation_interval |
60 or 600 seconds (600 = fewer, cheaper records) |
destination_options |
S3 only: file_format = "parquet", per_hour_partition |
Flow logs are not in the minimal hands-on demo below (to keep the apply lean and free), but this is the exact block to paste in when you need traffic visibility — and it is one boolean in the community module (enable_flow_log = true).
Hands-on: build it with Terraform
Now assemble everything into a working configuration and run it end to end. ⚠️ This one is NOT free. The VPC, subnets, IGW, route tables and the S3 gateway endpoint are free — but the NAT Gateways cost real money the moment they exist (roughly ₹2,700–2,900/month each just to sit there, plus data). With single_nat_gateway = true you run one NAT; with it false (the HA default) you run two. Do the whole walkthrough, verify, and destroy promptly — a NAT you forget about is the classic “why is my AWS bill ₹6,000 higher this month?” surprise.
Layout:
aws-vpc/
├── versions.tf # Terraform + provider pins, S3 backend
├── providers.tf # aws provider + default_tags
├── variables.tf # region, CIDR, az_count, single_nat_gateway
├── main.tf # the whole VPC
└── outputs.tf # IDs downstream lessons consume
versions.tf — pin Terraform and the provider, and configure the remote backend (the S3 bucket + DynamoDB lock table created in the getting-started lesson):
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "kv-tfstate-2026" # your unique bucket
key = "network/vpc.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks" # or use_lockfile = true on TF 1.10+ (S3-native lock)
encrypt = true
}
}
On Terraform ≥ 1.10 the S3 backend supports native locking with
use_lockfile = true, letting you drop the DynamoDB table entirely. This lesson keeps DynamoDB to match the getting-started backend, but the S3-native lock is the modern default.
providers.tf — set the region and, importantly, default_tags so every resource is tagged without repeating yourself:
provider "aws" {
region = var.region
default_tags {
tags = {
Project = "kloudvin-tf-course"
ManagedBy = "terraform"
Env = "demo"
}
}
}
variables.tf — parameterise region, CIDR, AZ count and the NAT knob:
variable "region" {
type = string
default = "us-east-1"
}
variable "name" {
type = string
default = "kv"
description = "Name prefix for all resources."
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "az_count" {
type = number
default = 2
description = "How many AZs to span. 2 for the demo; 3 for prod."
}
variable "single_nat_gateway" {
type = bool
default = true # true = one shared NAT (cheap). false = one per AZ (HA).
description = "One shared NAT to save cost, or one per AZ for HA."
}
main.tf — the whole topology (VPC, subnets, IGW, routing, NAT, S3 endpoint). This is every block from the sections above assembled in order:
data "aws_availability_zones" "available" {
state = "available"
filter {
name = "opt-in-status"
values = ["opt-in-not-required"]
}
}
locals {
azs = slice(data.aws_availability_zones.available.names, 0, var.az_count)
public_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i) }
private_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i + 8) }
database_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i + 16) }
nat_azs = var.single_nat_gateway ? slice(local.azs, 0, 1) : local.azs
}
# ---- VPC ----
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.name}-vpc" }
}
# ---- Subnets (3 tiers × N AZs, for_each) ----
resource "aws_subnet" "public" {
for_each = local.public_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
map_public_ip_on_launch = true
tags = { Name = "${var.name}-public-${each.key}", Tier = "public" }
}
resource "aws_subnet" "private" {
for_each = local.private_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
tags = { Name = "${var.name}-private-${each.key}", Tier = "private" }
}
resource "aws_subnet" "database" {
for_each = local.database_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
tags = { Name = "${var.name}-db-${each.key}", Tier = "database" }
}
# ---- Internet Gateway + public route table ----
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.name}-igw" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.name}-rt-public" }
}
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
# ---- NAT (per-AZ or single) + private route tables ----
resource "aws_eip" "nat" {
for_each = toset(local.nat_azs)
domain = "vpc"
tags = { Name = "${var.name}-eip-nat-${each.key}" }
}
resource "aws_nat_gateway" "nat" {
for_each = toset(local.nat_azs)
allocation_id = aws_eip.nat[each.key].id
subnet_id = aws_subnet.public[each.key].id
tags = { Name = "${var.name}-nat-${each.key}" }
depends_on = [aws_internet_gateway.igw]
}
resource "aws_route_table" "private" {
for_each = aws_subnet.private
vpc_id = aws_vpc.main.id
tags = { Name = "${var.name}-rt-private-${each.key}" }
}
resource "aws_route" "private_nat" {
for_each = aws_route_table.private
route_table_id = each.value.id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat[var.single_nat_gateway ? local.nat_azs[0] : each.key].id
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private[each.key].id
}
# ---- Isolated database route table (local routes only) ----
resource "aws_route_table" "database" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.name}-rt-database" }
}
resource "aws_route_table_association" "database" {
for_each = aws_subnet.database
subnet_id = each.value.id
route_table_id = aws_route_table.database.id
}
# ---- Free S3 gateway endpoint, wired into every route table ----
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = concat(
[aws_route_table.public.id],
[for rt in aws_route_table.private : rt.id],
[aws_route_table.database.id],
)
tags = { Name = "${var.name}-vpce-s3" }
}
outputs.tf — surface the IDs downstream resources (EC2, RDS, ALB) will need. Note the subnet outputs are maps keyed by AZ, so later lessons place instances by AZ name, not fragile index:
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "Map of AZ => public subnet id."
value = { for az, s in aws_subnet.public : az => s.id }
}
output "private_subnet_ids" {
description = "Map of AZ => private subnet id (put app servers here)."
value = { for az, s in aws_subnet.private : az => s.id }
}
output "database_subnet_ids" {
value = { for az, s in aws_subnet.database : az => s.id }
}
output "nat_public_ips" {
description = "Elastic IPs of the NAT gateways (allow-list these on partner APIs)."
value = { for az, e in aws_eip.nat : az => e.public_ip }
}
The diagram below is what this configuration builds — read it left to right: Terraform discovers the AZs and carves subnet CIDRs with cidrsubnet(), creates the VPC and attaches an Internet Gateway, fans public and private subnets across two AZs, places a NAT per AZ in the public subnets so private subnets get egress, and wires a free S3 gateway endpoint into the route tables before the workloads land in the private tier.
Step 1 — terraform init
export AWS_PROFILE="kloudvin" # or use env creds / an assumed role
terraform init
You should see the S3 backend initialise and the aws provider download:
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Installing hashicorp/aws v5.x.x...
Terraform has been successfully initialized!
Step 2 — terraform plan
terraform plan -out=tfplan
Representative tail — note the subnets fanned per AZ (["us-east-1a"], ["us-east-1b"]) and, with single_nat_gateway = true, one NAT/EIP but two private route tables:
# aws_vpc.main will be created
# aws_subnet.public["us-east-1a"] will be created
# aws_subnet.public["us-east-1b"] will be created
# aws_subnet.private["us-east-1a"] will be created
# aws_subnet.private["us-east-1b"] will be created
# aws_subnet.database["us-east-1a"] will be created
# aws_subnet.database["us-east-1b"] will be created
# aws_internet_gateway.igw will be created
# aws_nat_gateway.nat["us-east-1a"] will be created
...
Plan: 24 to add, 0 to change, 0 to destroy.
The resource count depends on the NAT mode. With one shared NAT it is 24; flip single_nat_gateway = false and it becomes 26 (a second EIP, NAT, and the second private route still count the same — the delta is the extra EIP + NAT).
| # | Resources | Count (shared NAT) |
|---|---|---|
| VPC | aws_vpc |
1 |
| Subnets | aws_subnet (2 public + 2 private + 2 db) |
6 |
| IGW | aws_internet_gateway |
1 |
| Route tables | public 1 + private 2 + database 1 | 4 |
| Routes | public→IGW 1 + private→NAT 2 | 3 |
| Associations | public 2 + private 2 + db 2 | 6 |
| EIP + NAT | 1 + 1 (shared) | 2 |
| S3 endpoint | aws_vpc_endpoint |
1 |
| Total | 24 |
Step 3 — terraform apply
terraform apply tfplan
Terraform resolves the dependency graph automatically — the VPC before its subnets, the IGW before the NAT (via depends_on), the NAT before the private route, each subnet before its association — because every child references the parent’s .id. The NAT Gateway is the slow one: it takes 1–2 minutes to provision. Expect Apply complete! Resources: 24 added, 0 changed, 0 destroyed. and your outputs printed.
Step 4 — verify with aws
Confirm the real cloud state, not just Terraform’s word. Capture the VPC ID from the output first:
VPC_ID=$(terraform output -raw vpc_id)
# The VPC and its DNS settings
aws ec2 describe-vpcs --vpc-ids "$VPC_ID" \
--query "Vpcs[].{id:VpcId,cidr:CidrBlock,tenancy:InstanceTenancy}" --output table
# Every subnet: CIDR, AZ, and whether it auto-assigns a public IP
aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" \
--query "Subnets[].{id:SubnetId,cidr:CidrBlock,az:AvailabilityZone,public:MapPublicIpOnLaunch}" \
--output table
# NAT gateways: state should be 'available', in a PUBLIC subnet
aws ec2 describe-nat-gateways --filter "Name=vpc-id,Values=$VPC_ID" \
--query "NatGateways[].{id:NatGatewayId,state:State,subnet:SubnetId}" --output table
# Route tables and their routes — confirm public→igw, private→nat
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC_ID" \
--query "RouteTables[].{id:RouteTableId,routes:Routes[].{dst:DestinationCidrBlock,gw:GatewayId,nat:NatGatewayId}}" \
--output json
The describe-subnets output is the money shot: you should see six subnets, two per AZ per naming convention, with public: True only on the two public subnets. If a private or database subnet shows True, you set map_public_ip_on_launch in the wrong place. And in describe-route-tables, the public table must show 0.0.0.0/0 → an igw-… target, each private table 0.0.0.0/0 → a nat-… target, and the database table no 0.0.0.0/0 route at all.
Step 5 — terraform destroy (⚠️ cleanup)
terraform destroy
Do this promptly — the NAT gateway(s) bill by the hour until destroyed. Terraform removes resources in reverse-dependency order: the S3 endpoint and associations first, then routes, NAT gateways, EIPs, subnets, the IGW, and finally the VPC. Expect Destroy complete! Resources: 24 destroyed. If destroy stalls with DependencyViolation on the subnet or VPC, something outside this config is still attached (a leftover instance, an RDS subnet group, a Lambda ENI, an interface endpoint) — see the troubleshooting section; find and remove the occupant, then re-run.
Variables, outputs & making it reusable — build vs reuse
The demo is already parameterised (region, CIDR, AZ count, the NAT knob). The natural next step is to lift it into a reusable module so every environment calls the same code with different inputs — a vpc module whose interface is essentially the variables above, returning the subnet-ID maps as outputs. A caller then writes:
module "vpc" {
source = "./modules/vpc"
name = "prod"
vpc_cidr = "10.20.0.0/16"
az_count = 3
single_nat_gateway = false # HA in prod
}
But before you write that module, ask the question every senior engineer asks: should you build your own, or use the community module? The terraform-aws-modules/vpc/aws module is one of the most-downloaded modules in the registry — battle-tested, maintained, and it produces exactly the topology this lesson builds (and much more) from a compact input set:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "kv-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
public_subnets = ["10.0.0.0/24", "10.0.1.0/24"]
private_subnets = ["10.0.8.0/24", "10.0.9.0/24"]
database_subnets = ["10.0.16.0/24", "10.0.17.0/24"]
enable_nat_gateway = true
single_nat_gateway = true # flip to false + one_nat_gateway_per_az = true for HA
one_nat_gateway_per_az = false
enable_dns_hostnames = true
enable_dns_support = true
# Flow logs in one switch
enable_flow_log = true
create_flow_log_cloudwatch_log_group = true
create_flow_log_cloudwatch_iam_role = true
tags = { Project = "kloudvin-tf-course", Env = "demo" }
}
That single block produces the VPC, all subnets, the IGW, the NAT gateway(s), all route tables and associations, and flow logs — the entire lesson, plus a database subnet group and dozens of options you did not have to write. (In module v5 the S3/DynamoDB gateway endpoints moved to a separate terraform-aws-modules/vpc/aws//modules/vpc-endpoints submodule; you call that alongside for the endpoints.)
So when do you roll your own? The honest decision table:
| Approach | Use when | Trade-off |
|---|---|---|
terraform-aws-modules/vpc/aws |
Standard topology, want speed + maintenance for free | Less control; you must read its (large) input surface and pin the version |
| Thin roll-your-own module | Opinionated naming / routing / endpoint conventions the generic module fights | You own and maintain it — but it is exactly your shape, and you understand every resource |
| No module (root only) | One environment, or learning (this lesson) | Copy-paste across envs; drifts over time |
The pedagogically honest answer: build it once by hand (as you just did) to understand every resource, then use the community module in production — because you will now read its inputs and outputs fluently, and you will know exactly what single_nat_gateway = true costs you. Rolling your own thin module is justified only when your conventions (naming, tagging, per-tier endpoint policies) are opinionated enough that bending the generic module costs more than owning a small one.
The key module inputs, mapped to what you built by hand:
| Community-module input | Your hand-built equivalent |
|---|---|
cidr |
aws_vpc.cidr_block |
azs, public_subnets, private_subnets, database_subnets |
the for_each + cidrsubnet() locals |
enable_nat_gateway |
creating aws_nat_gateway at all |
single_nat_gateway / one_nat_gateway_per_az |
the nat_azs local |
enable_dns_hostnames |
aws_vpc.enable_dns_hostnames |
enable_flow_log (+ the create-role/log-group flags) |
the aws_flow_log + IAM role block |
map_public_ip_on_launch |
the same arg on aws_subnet.public |
Common mistakes and troubleshooting
VPC errors in the aws provider are usually one of a dozen recognisable shapes. Match the symptom, apply the fix.
| Symptom / error | Cause | Fix |
|---|---|---|
| No internet from a private subnet | Private route table has no 0.0.0.0/0 → NAT route, or the subnet isn’t associated to it |
Add the aws_route (nat_gateway_id) and the aws_route_table_association |
| Still no internet from private | The NAT is in a private subnet (can’t reach the IGW itself) | Put the NAT’s subnet_id on a public subnet; ensure that subnet’s table routes to the IGW |
| Surprise NAT bill (₹5–6k/month) | Two NAT gateways left running, or high data-processing volume | Use single_nat_gateway = true in non-prod; add an S3/DynamoDB endpoint to keep that traffic off the NAT; destroy when idle |
Error: InvalidSubnet.Range / .Conflict |
Subnet CIDR outside the VPC block, or two subnets overlap | Carve with cidrsubnet(); keep subnets inside vpc_cidr, non-overlapping |
| AZ mismatch — subnets land in unexpected AZs | Hard-coded AZ names, or the account’s letter→hardware map differs | Use data.aws_availability_zones; pin availability_zone_id for cross-account sameness |
Error: InvalidParameterValue: Elastic IP … not valid for NAT |
EIP not in the VPC domain, or wrong allocation_id |
Set domain = "vpc" on aws_eip; pass its .id as allocation_id |
DependencyViolation on destroy (subnet/VPC won’t delete) |
A leftover ENI occupies the subnet — an instance, RDS, Lambda-in-VPC, or an interface endpoint outside this config | Delete the occupant first (aws ec2 describe-network-interfaces --filters Name=subnet-id,…), then destroy |
| Public-IP instance has no public DNS name / endpoint private DNS fails | enable_dns_hostnames = false (the default) |
Set enable_dns_hostnames = true on the VPC |
| Route table plan flaps: a route added then removed every apply | Inline route {} blocks and standalone aws_route on the same table |
Pick one model; if standalone, the table must have zero inline route blocks |
Error: creating Route: RouteAlreadyExists |
Two resources define the same destination_cidr_block on one table |
One route per destination per table; remove the duplicate |
| Instance in a “public” subnet can’t be reached | Subnet has no 0.0.0.0/0 → IGW route, or instance has no public IP / open SG |
Associate the public route table; set map_public_ip_on_launch; open the security group |
UnauthorizedOperation creating the VPC |
The identity lacks EC2/VPC permissions | Grant AmazonVPCFullAccess (or scoped equivalents); check the assumed role |
The four you will actually hit deserve prose. “No internet from a private subnet” is the number-one VPC support question, and it is always one of three things: the private route table is missing the 0.0.0.0/0 → NAT route, the subnet was never associated to that table (so it silently uses the main table, which has no NAT route), or — the sneaky one — the NAT gateway itself is sitting in a private subnet and therefore can’t reach the internet to do its job. Check the NAT’s subnet_id is a public subnet whose table routes to the IGW; that fixes it 90% of the time. The surprise NAT bill is the number-one cost incident: NAT gateways bill by the hour whether or not traffic flows, so two of them running idle across a forgotten dev account is ~₹5,500/month for nothing — set single_nat_gateway = true in non-prod, add the free S3/DynamoDB gateway endpoints so that (often large) traffic never touches the NAT, and destroy environments you are not using. AZ mismatch bites when you hard-code us-east-1a: not only does that break in a region that names AZs differently, it can also, in a multi-account setup, land two accounts’ “same” subnet in physically different datacentres (because the letter is randomised per account) — use the data source, and pin availability_zone_id when two accounts must share a physical AZ. And DependencyViolation on destroy means Terraform tried to delete a subnet or the VPC while an ENI it doesn’t manage is still plugged in — a leftover EC2 instance, an RDS instance, a Lambda’s VPC ENI, or an interface endpoint created outside this config; find the ENI (aws ec2 describe-network-interfaces --filters Name=subnet-id,Values=<subnet>), remove its owner, then destroy.
Cost, cleanup & production notes
Cost. Most of this VPC is free; the NAT gateways are not, and they dominate the bill.
| Resource | Cost while running (approx, us-east-1) |
|---|---|
| VPC, subnets, route tables, associations | Free |
| Internet Gateway | Free to attach (normal data-transfer rates apply) |
| S3 / DynamoDB gateway endpoint | Free — no hourly, no data charge |
| NAT Gateway | ~$0.045/hr ≈ $33/mo ≈ ₹2,800/mo each, plus ~$0.045/GB processed |
| Elastic IP (attached to a running NAT) | Free while attached; idle/unattached EIPs now bill (~$0.005/hr) |
| Interface endpoint | ~$0.01/hr/AZ + ~$0.01/GB — per endpoint, per AZ |
| Flow logs | The destination’s cost (CloudWatch ingest or S3 storage) |
So the free primitives cost ₹0, but each NAT gateway is ~₹2,800/month before a single byte flows, and the HA (one-per-AZ) demo runs two ≈ ₹5,600/month. That is the entire reason single_nat_gateway exists and the entire reason to destroy promptly. Two levers cut it hard: run one shared NAT in non-prod, and put S3/DynamoDB traffic through the free gateway endpoints so it never incurs NAT data charges.
Cleanup is terraform destroy. Unlike the free Azure networking demo, here you have a real, ticking meter — do not leave a NAT running overnight “to finish tomorrow.”
Production hardening notes:
- Remote state, always. A VPC is shared infrastructure; its state must be remote (S3 backend) with locking (DynamoDB or the S3-native lock) so two engineers can’t
applyconcurrently — set up in the getting-started lesson. - Least privilege. The pipeline identity needs scoped VPC/EC2 permissions, not
AdministratorAccess. GrantAmazonVPCFullAccessor a tighter custom policy on the networking account. - Tag everything via
default_tagson the provider (as the demo does) so every subnet, route table and NAT is queryable byProject/Env/ownerin Cost Explorer — NAT spend especially. - Three AZs in production. The demo uses two; production web tiers should span three (
az_count = 3) so you survive an AZ failure with capacity to spare. Thecidrsubnet()carve makes this a one-number change. - Add the free gateway endpoints and watch drift. Always create the S3 (and DynamoDB) gateway endpoint — free money off the NAT bill. Run
terraform planon a schedule so a console-added route to a private table surfaces as drift before it becomes a silent security hole.
Cheat-sheet
The resources and their load-bearing arguments:
| Resource | Key arguments | Remember |
|---|---|---|
aws_vpc |
cidr_block, enable_dns_hostnames, enable_dns_support |
Hostnames default false — set it true |
aws_subnet |
cidr_block, availability_zone, map_public_ip_on_launch |
Public-IP flag only on the public tier; for_each over a map |
data.aws_availability_zones |
state, filter opt-in-status |
Never hard-code AZ names |
aws_internet_gateway |
vpc_id |
Free; useless without a 0.0.0.0/0 route |
aws_route_table |
vpc_id |
Public shares one; private is one per AZ |
aws_route |
destination_cidr_block, gateway_id / nat_gateway_id |
Standalone; don’t also use inline route {} |
aws_route_table_association |
subnet_id, route_table_id |
Unassociated subnets use the main table |
aws_eip |
domain = "vpc" |
vpc = true is deprecated |
aws_nat_gateway |
allocation_id, subnet_id, depends_on = [igw] |
Lives in a public subnet; costs money |
aws_vpc_endpoint |
service_name, vpc_endpoint_type, route_table_ids / subnet_ids |
Gateway = free (S3/DDB); Interface = paid |
aws_flow_log |
traffic_type, log_destination_type, iam_role_arn |
CloudWatch needs a role; S3 doesn’t |
Commands:
| Command | Purpose |
|---|---|
terraform init |
Init S3 backend + download aws |
terraform plan -out=tfplan |
Preview; save the plan |
terraform apply tfplan |
Apply the saved plan |
terraform state list |
See aws_subnet.public["us-east-1a"] etc. |
aws ec2 describe-vpcs --vpc-ids <id> |
Verify the VPC + CIDR |
aws ec2 describe-subnets --filters Name=vpc-id,Values=<id> -o table |
Verify subnets + MapPublicIpOnLaunch |
aws ec2 describe-nat-gateways --filter Name=vpc-id,Values=<id> |
Verify NAT state = available |
aws ec2 describe-route-tables --filters Name=vpc-id,Values=<id> |
Verify public→IGW, private→NAT |
terraform destroy |
Tear it down (stop the NAT meter) |
Interview and exam questions
1. What actually makes a subnet “public” on AWS?
Not a flag on the subnet — a route. A subnet is public when a route table associated with it sends 0.0.0.0/0 to an Internet Gateway. (For instances there to be reachable, they also need a public IP — via map_public_ip_on_launch or an Elastic IP — and an open security group.) Remove the IGW route and the same subnet becomes private.
2. Why must a NAT Gateway live in a public subnet, and where does the private route point?
The NAT itself needs outbound internet to do its job, so it must sit in a subnet with a 0.0.0.0/0 → IGW route (a public subnet) and carry an Elastic IP. The private subnet’s route table then sends 0.0.0.0/0 to the NAT (nat_gateway_id), not the IGW. Traffic flows private → NAT (public subnet) → IGW → internet, and return traffic comes back the same way — but nothing can initiate a connection inward.
3. One NAT per AZ vs one shared NAT — what’s the trade-off?
One NAT per AZ costs more (each bills ~₹2,800/month) but survives an AZ failure and avoids cross-AZ data charges (each AZ’s private traffic egresses locally). One shared NAT halves the hourly cost but is a single point of failure — if its AZ dies, all private egress dies — and traffic from the other AZ crosses AZ (extra per-GB) to reach it. Rule of thumb: one per AZ in prod, one shared in dev/test (the module’s single_nat_gateway).
4. Why for_each over a map instead of count for the subnets?
count keys resources by numeric index, so removing an AZ from the middle of the list shifts every later index and Terraform force-replaces those subnets. for_each over a map keyed by AZ name uses stable string identities — adding or removing one AZ affects exactly that subnet and nothing else. Keying by a stable identity is the whole reason.
5. What does cidrsubnet("10.0.0.0/16", 8, 9) return, and why compute CIDRs this way?
10.0.9.0/24. It extends the /16 by 8 bits to /24 and selects block number 9. Computing subnets with cidrsubnet() from the VPC CIDR (netnum = AZ index + a per-tier offset) makes the carve deterministic and guaranteed non-overlapping, so adding an AZ is a one-number change instead of hand-counting CIDR strings and risking an overlap error.
6. Gateway endpoint vs interface endpoint — the one-line distinction and the cost? A gateway endpoint (S3 and DynamoDB only) is a route-table entry and is free. An interface endpoint (almost every other service) is an ENI with a private IP inside your subnets, fronted by a security group, and bills per-hour per-AZ plus per-GB. Always add the S3 gateway endpoint (free win off the NAT bill); add interface endpoints deliberately per service.
7. Your private instances have no internet access. Walk through the diagnosis.
Check, in order: (a) does the private subnet’s route table have 0.0.0.0/0 → a NAT? (b) is the subnet actually associated to that table, or silently using the main table? © is the NAT available and sitting in a public subnet whose table routes to the IGW? (d) does the security group / NACL allow outbound? The most common single cause is the NAT being placed in a private subnet, or the missing route/association.
8. Why set enable_dns_hostnames = true, given enable_dns_support is already on?
enable_dns_support (default on) makes the .2 resolver answer queries inside the VPC. enable_dns_hostnames (default off) is separately required for public-IP instances to get a public DNS name and for interface VPC endpoints to use private DNS. Leaving it off is a classic “my endpoint’s private DNS doesn’t resolve” bug.
9. (Associate-style) True/False: attaching an Internet Gateway to a VPC gives all subnets internet access.
False. The IGW only provides a path; a subnet gets internet access only when a route table associated with it sends 0.0.0.0/0 to that IGW (and, for inbound reachability, the instance needs a public IP and an open SG). Attaching the IGW alone changes nothing.
10. terraform destroy fails with DependencyViolation on the VPC. Why, and how do you fix it?
Something Terraform doesn’t manage still occupies the VPC — a leftover EC2 instance, an RDS instance, a Lambda’s VPC ENI, or an interface endpoint created outside this config. The ENI keeps the subnet (and thus the VPC) from deleting. List ENIs in the subnet (aws ec2 describe-network-interfaces --filters Name=subnet-id,Values=<id>), delete the owning resource, then re-run destroy.
11. Why is each private subnet given its own route table, but all public subnets share one? Public subnets all egress the same way (to the one IGW), so one shared table is correct and simpler. Private subnets in an HA design each route to the NAT in their own AZ — different targets per AZ — so each needs its own route table. Sharing one private table would force all AZs through one NAT, reintroducing the single point of failure and cross-AZ charges.
12. (Associate-style) You add a third AZ by setting az_count = 3. What changes, and does anything get force-replaced?
The AZ data source returns three names, slice takes three, and the for_each maps gain a third key each — Terraform adds one subnet per tier, one more private route table + route + association, and (if HA) a third NAT + EIP. Because for_each keys on stable AZ-name strings, the existing two AZs’ resources are not touched or replaced — only the new AZ’s resources are added.
Key takeaways
- Plan addresses first, carve with
cidrsubnet(). VPC and subnet CIDRs are effectively immutable; AWS reserves 5 IPs per subnet; overlaps fail at apply. Compute CIDRs from the VPC block so adding an AZ never risks an overlap. - “Public” is a route, not a flag. A subnet is public only when its route table sends
0.0.0.0/0to an Internet Gateway; private subnets egress via a NAT Gateway that itself lives in a public subnet. - Use
for_eachover an AZ-keyed map, notcount— stable keys mean adding or removing an AZ never renumbers the others (the mechanics are in the meta-arguments lesson). - NAT is the cost and the HA decision. One per AZ is HA but ~₹2,800/month each; one shared is cheap but a single point of failure. Toggle it per environment, and keep S3/DynamoDB traffic on the free gateway endpoints.
enable_dns_hostnamesdefaults to false — set ittrue, or public DNS names and interface-endpoint private DNS silently don’t work.- Associate every subnet explicitly, or it silently falls back to the main route table; private route tables are one per AZ so each targets its own NAT.
- Build it by hand once to learn it, then use
terraform-aws-modules/vpc/awsin production — you’ll now read its inputs fluently and know exactly what each knob costs. - This demo has a ticking meter (the NAT). Verify with
aws ec2 describe-subnets, thendestroypromptly — and drop compute into these subnets next with Terraform on AWS: Security Groups, EC2 & Key Pairs.