Cloud 04 — a VPC module, two AZs, four subnets, and no NAT gateway

In Java the network is somebody else's problem: new Socket(host, 443) either connects or throws, and whatever sits between your JVM and the internet was set up by an ops team you never met. On AWS you draw that network yourself — a private address range (the VPC), subnets pinned to one data centre each, an internet gateway as the only door, and route tables that decide whether a packet may leave at all. None of that costs anything. The one box that lets a machine in a private subnet reach the internet — the NAT gateway — is billed by the hour whether or not a byte crosses it, and it is the line that surprises everyone on their first AWS bill. So this page builds the network every later cloud item runs in, as a reusable Terraform module, and leaves the NAT gateway out on purpose.

A route table is a lookup, not a rule chain: for each packet, the route with the longest matching prefix wins. 10.0.0.0/16 → local keeps traffic inside the VPC; 0.0.0.0/0 → igw matches everything else and sends it out. A subnet whose route table has no 0.0.0.0/0 line is private — not because of a flag, but because there is no way out.

~75 min~$0.00/mo (approx.) 10 resourcesthe VPC stays cloud-04

What this creates

One VPC, 10.0.0.0/16, split across two Availability Zones of ap-south-1 (Mumbai). Each AZ gets a public subnet (10.0.0.0/24, 10.0.1.0/24 — its route table sends 0.0.0.0/0 to the internet gateway, and anything launched in it gets a public IP) and a private subnet (10.0.10.0/24, 10.0.11.0/24 — the VPC's main route table, which knows only local). Boxes carry the AWS name on the first line and the Terraform resource on the second; dashed boxes do not exist in the step you picked. The heading in the scene is the line terraform plan would print for that step, and the readout adds up an approximate monthly figure from the resources that are on — prices are AWS's published us-east-1 rates (Amazon VPC pricing page, read 2026-09-10: $0.045 per NAT-gateway-hour, $0.045 per GB it processes, $0.005 per public IPv4 address per hour) over a 30-day month of 720 hours; ap-south-1's NAT rate is higher, so verify on the pricing page today. The NAT gateway wears the red ring the whole time: greyed until step 3 turns it on.

Preconditions

Do it

1. The module's inputs — modules/vpc/variables.tf. The subnets are a map, keyed by a name you choose. The key becomes part of each subnet's address in the state (aws_subnet.this["public-a"]), which is the whole point of the next step:

# modules/vpc/variables.tf
variable "name" {
  description = "Prefix for every Name tag"
  type        = string
}

variable "cidr_block" {
  description = "The VPC's private address range"
  type        = string
}

variable "subnets" {
  description = "One entry per subnet: key => AZ, CIDR, and whether it is public"
  type = map(object({
    az     = string
    cidr   = string
    public = bool
  }))
}

2. The resources — modules/vpc/main.tf. One VPC, one subnet per map entry via for_each, one internet gateway, one public route table with one route to that gateway, and one association per public subnet (a for expression filters the map). The private subnets are deliberately left unassociated, so AWS gives them the VPC's main route table — local only. There is no aws_nat_gateway and no aws_eip anywhere in the file:

# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = var.name }
}

# for_each over a map: each subnet is addressed by its key (aws_subnet.this["public-a"]),
# so removing one entry destroys that subnet only -- a count list would renumber the rest.
resource "aws_subnet" "this" {
  for_each = var.subnets

  vpc_id                  = aws_vpc.this.id
  availability_zone       = each.value.az
  cidr_block              = each.value.cidr
  map_public_ip_on_launch = each.value.public

  tags = { Name = "${var.name}-${each.key}" }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = { Name = var.name }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  tags = { Name = "${var.name}-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.this.id
}

resource "aws_route_table_association" "public" {
  for_each = { for k, s in var.subnets : k => s if s.public }

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.public.id
}

# No aws_nat_gateway, on purpose. The private subnets keep the VPC's main route table,
# which knows only 10.0.0.0/16 -> local: nothing in them reaches the internet, and nothing bills.

3. The module's outputs — modules/vpc/outputs.tf. Three values every later item needs: the VPC id, and the two lists of subnet ids, split by the same public flag:

# modules/vpc/outputs.tf
output "vpc_id" {
  value = aws_vpc.this.id
}

output "public_subnet_ids" {
  value = [for k, s in aws_subnet.this : s.id if var.subnets[k].public]
}

output "private_subnet_ids" {
  value = [for k, s in aws_subnet.this : s.id if !var.subnets[k].public]
}

4. The root module's pins and backend — 04-vpc/versions.tf. The same bucket as cloud-03, a new key: each root module gets its own state object, so a mistake in this one can never rewrite cloud-03's. Change only the bucket name. default_tags stamps every resource, which is how you find them in Cost Explorer later:

# 04-vpc/versions.tf
terraform {
  required_version = "~> 1.11"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  backend "s3" {
    bucket       = "lbv-tfstate-yourname-x7q2"
    key          = "lbv-track/04-vpc/terraform.tfstate"
    region       = "ap-south-1"
    use_lockfile = true
    encrypt      = true
  }
}

provider "aws" {
  region = "ap-south-1"

  default_tags {
    tags = {
      Project = "lbv-track"
      Item    = "cloud-04"
    }
  }
}

5. Call the module — 04-vpc/main.tf, with the four subnets as data:

# 04-vpc/main.tf
module "vpc" {
  source = "../modules/vpc"

  name       = "lbv"
  cidr_block = "10.0.0.0/16"

  subnets = {
    public-a  = { az = "ap-south-1a", cidr = "10.0.0.0/24", public = true }
    public-b  = { az = "ap-south-1b", cidr = "10.0.1.0/24", public = true }
    private-a = { az = "ap-south-1a", cidr = "10.0.10.0/24", public = false }
    private-b = { az = "ap-south-1b", cidr = "10.0.11.0/24", public = false }
  }
}

6. Re-export its outputs — 04-vpc/outputs.tf (a module's outputs are only visible to the root that called it; these make them visible to terraform output and to later items):

# 04-vpc/outputs.tf
output "vpc_id" {
  value = module.vpc.vpc_id
}

output "public_subnet_ids" {
  value = module.vpc.public_subnet_ids
}

output "private_subnet_ids" {
  value = module.vpc.private_subnet_ids
}

7. Init, check, plan. Read the plan before applying it — the count and the absence of one word are the two things to check:

cd ~/lbv-cloud/04-vpc
export AWS_PROFILE=lbv-admin
terraform init
# Initializing the backend... Successfully configured the backend "s3"!
# Initializing modules... - vpc in ../modules/vpc
terraform fmt -recursive -check ..
terraform validate
# Success! The configuration is valid.
terraform plan -out=tfplan
# Plan: 10 to add, 0 to change, 0 to destroy.
#   1 aws_vpc + 4 aws_subnet + 1 aws_internet_gateway + 1 aws_route_table
#   + 1 aws_route + 2 aws_route_table_association = 10
terraform show -no-color tfplan | grep -c aws_nat_gateway
# 0

8. Apply — the first resources of the track that exist on AWS:

terraform apply tfplan
# Apply complete! Resources: 10 added, 0 changed, 0 destroyed.
# Outputs:
#   private_subnet_ids = [ "subnet-…", "subnet-…" ]
#   public_subnet_ids  = [ "subnet-…", "subnet-…" ]
#   vpc_id             = "vpc-…"
VPC=$(terraform output -raw vpc_id); echo "$VPC"

Verify

Teardown

None now — keep the VPC. The Fargate items build into it: cloud-06 reads vpc_id and public_subnet_ids from this state, and cloud-07 runs the service in the public subnets (cloud-05's ECR repository is regional and lives outside any VPC, so it does not need this one). A VPC with no NAT gateway, no Elastic IP and nothing running in it costs nothing to keep — VPCs, subnets, route tables and internet gateways carry no charge of their own.

When the whole track is over, and only after cloud-06/07 have destroyed everything that runs inside it:

cd ~/lbv-cloud/04-vpc
VPC=$(terraform output -raw vpc_id)      # before the destroy: afterwards there is no output to read
aws ec2 describe-network-interfaces --filters Name=vpc-id,Values=$VPC --query 'NetworkInterfaces[].NetworkInterfaceId'
# [] -- nothing still runs in it (a leftover ENI is what makes a VPC destroy hang)
terraform destroy
# Destroy complete! Resources: 10 destroyed.
aws ec2 describe-vpcs --vpc-ids $VPC
# An error occurred (InvalidVpcID.NotFound) ... -- the VPC is gone
aws ec2 describe-addresses --query 'Addresses[].PublicIp'
# [] -- no Elastic IPs left

What remains and what it costs, approx., verify on the pricing page today: after this page, the VPC and its 10 resources — ~$0.00/mo. The public IPv4 charge ($0.005 per address per hour) starts only when something with a public IP runs in a public subnet; that is cloud-06's line, and it is counted there.

There is no teardown box on this page, because the VPC stays. Press the button once the five Verify lines above are true.

This is self-attestation — the site cannot see your AWS account, so pressing the button is you telling The Path the plan said 10 to add, four subnets exist, and no NAT gateway does.

Takeaways: on AWS the network's shape is free — a VPC, subnets, an internet gateway and route tables bill nothing; what bills is what runs in them and the few managed boxes that move traffic for you, of which the NAT gateway is the one that surprises people: billed per hour with zero traffic, per GB on top, one per AZ if you want it highly available. "Public" and "private" are not properties of a subnet but of its route table: a 0.0.0.0/0 → igw line makes it public, the absence of one makes it private. And for_each over a map, not count over a list, is how a module keeps each subnet's address stable when the list changes.