---
title: "Terraform Networking Patterns: VPC, Subnets, NAT, Peering, and Transit Gateway Across Clouds"
description: "Cloud networking patterns in Terraform for AWS, Azure, and GCP. Covers VPC/VNET/VPC Network design, public vs private subnets, NAT gateway patterns, VPC peering, Transit Gateway and hub-spoke topologies, DNS configuration, CIDR planning for multi-environment and multi-region architectures, and the networking gotchas that cause connectivity failures."
url: https://agent-zone.ai/knowledge/infrastructure/terraform-networking-patterns/
section: knowledge
date: 2026-02-22
categories: ["infrastructure"]
tags: ["terraform","networking","vpc","vnet","subnets","nat","peering","transit-gateway","dns","cidr","hub-spoke"]
skills: ["cloud-networking-terraform","vpc-design","cidr-planning","transit-gateway-patterns","dns-configuration"]
tools: ["terraform","aws-cli","az","gcloud"]
levels: ["intermediate"]
word_count: 1561
formats:
  json: https://agent-zone.ai/knowledge/infrastructure/terraform-networking-patterns/index.json
  html: https://agent-zone.ai/knowledge/infrastructure/terraform-networking-patterns/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Terraform+Networking+Patterns%3A+VPC%2C+Subnets%2C+NAT%2C+Peering%2C+and+Transit+Gateway+Across+Clouds
---


# Terraform Networking Patterns

Networking is the first thing you build and the last thing you want to change. CIDR ranges, subnet allocation, and connectivity topology are difficult to modify after resources depend on them. Getting the network right in Terraform saves months of migration work later.

This article covers the networking patterns across AWS, Azure, and GCP — from basic VPC design to multi-region hub-spoke topologies.

## CIDR Planning

Plan CIDR ranges before writing any Terraform. Once a VPC is created with a CIDR block, changing it requires recreating the VPC and everything in it.

### Allocation Strategy

```
10.0.0.0/8 — total private space (16 million IPs)

Divide by region:
  10.0.0.0/12  — us-east-1   (1M IPs)
  10.16.0.0/12 — us-west-2   (1M IPs)
  10.32.0.0/12 — eu-west-1   (1M IPs)

Divide by environment within region:
  10.0.0.0/16  — us-east-1 production   (65K IPs)
  10.1.0.0/16  — us-east-1 staging      (65K IPs)
  10.2.0.0/16  — us-east-1 dev          (65K IPs)

Divide by subnet within VPC:
  10.0.0.0/24  — public-a    (256 IPs)
  10.0.1.0/24  — public-b    (256 IPs)
  10.0.2.0/24  — public-c    (256 IPs)
  10.0.10.0/24 — private-a   (256 IPs)
  10.0.11.0/24 — private-b   (256 IPs)
  10.0.12.0/24 — private-c   (256 IPs)
  10.0.20.0/24 — database-a  (256 IPs)
  10.0.21.0/24 — database-b  (256 IPs)
  10.0.22.0/24 — database-c  (256 IPs)
```

**Key rules**:
- No CIDR overlap between any two VPCs that might ever be peered
- Leave gaps between allocations for future expansion
- Document the allocation table and commit it to the repo

### CIDR Sizing Guide

| Subnet Purpose | Recommended Size | IPs Available | Notes |
|---|---|---|---|
| Public (load balancers, NAT) | /24 | 251 | Small — few resources need public IPs |
| Private (application workloads) | /20-/22 | 4091-1019 | Size for max pod/instance count |
| Database | /24 | 251 | Small — few database instances |
| GKE/EKS pod range | /16 | 65531 | Large — every pod gets an IP |
| GKE/EKS service range | /20 | 4091 | Medium — one IP per K8s service |

## AWS VPC Pattern

```hcl
data "aws_availability_zones" "available" {
  state = "available"
}

locals {
  azs = slice(data.aws_availability_zones.available.names, 0, 3)
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr  # e.g., "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "${var.environment}-vpc" }
}

# Public subnets — one per AZ
resource "aws_subnet" "public" {
  count                   = length(local.azs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)  # /24 per AZ
  availability_zone       = local.azs[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name                                = "${var.environment}-public-${local.azs[count.index]}"
    "kubernetes.io/role/elb"            = "1"  # required for EKS ALB
  }
}

# Private subnets — one per AZ
resource "aws_subnet" "private" {
  count             = length(local.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)  # /24 starting at .10
  availability_zone = local.azs[count.index]

  tags = {
    Name                                 = "${var.environment}-private-${local.azs[count.index]}"
    "kubernetes.io/role/internal-elb"    = "1"  # required for EKS internal ALB
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${var.environment}-igw" }
}

# Public route table
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 = "${var.environment}-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
}

# NAT Gateway (one for dev, per-AZ for prod)
resource "aws_eip" "nat" {
  count  = var.nat_gateway_count  # 1 for dev, 3 for prod
  domain = "vpc"
  tags   = { Name = "${var.environment}-nat-eip-${count.index}" }
}

resource "aws_nat_gateway" "main" {
  count         = var.nat_gateway_count
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
  tags          = { Name = "${var.environment}-nat-${count.index}" }

  depends_on = [aws_internet_gateway.main]
}

# Private route tables — one per AZ pointing to the appropriate NAT GW
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 % var.nat_gateway_count].id
  }

  tags = { Name = "${var.environment}-private-rt-${count.index}" }
}

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

**Gotcha**: `cidrsubnet(var.vpc_cidr, 8, count.index)` computes subnets automatically. For a /16 VPC, `cidrsubnet("10.0.0.0/16", 8, 0)` = `10.0.0.0/24`, `cidrsubnet("10.0.0.0/16", 8, 10)` = `10.0.10.0/24`.

**Gotcha**: The EKS-specific subnet tags (`kubernetes.io/role/elb` and `kubernetes.io/role/internal-elb`) are required for the AWS Load Balancer Controller to discover subnets. Missing these tags means ALBs fail silently.

## Azure VNET Pattern

```hcl
resource "azurerm_virtual_network" "main" {
  name                = "${var.environment}-vnet"
  resource_group_name = azurerm_resource_group.networking.name
  location            = azurerm_resource_group.networking.location
  address_space       = [var.vnet_cidr]  # e.g., ["10.0.0.0/16"]
}

resource "azurerm_subnet" "app" {
  name                 = "app-subnet"
  resource_group_name  = azurerm_resource_group.networking.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = [cidrsubnet(var.vnet_cidr, 8, 0)]  # 10.0.0.0/24
}

resource "azurerm_subnet" "aks" {
  name                 = "aks-subnet"
  resource_group_name  = azurerm_resource_group.networking.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = [cidrsubnet(var.vnet_cidr, 4, 1)]  # 10.0.16.0/20 — large for AKS
}

resource "azurerm_subnet" "database" {
  name                 = "database-subnet"
  resource_group_name  = azurerm_resource_group.networking.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = [cidrsubnet(var.vnet_cidr, 8, 20)]  # 10.0.20.0/24

  delegation {
    name = "postgresql"
    service_delegation {
      name    = "Microsoft.DBforPostgreSQL/flexibleServers"
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
    }
  }
}

# NAT Gateway
resource "azurerm_public_ip" "nat" {
  name                = "${var.environment}-nat-pip"
  resource_group_name = azurerm_resource_group.networking.name
  location            = azurerm_resource_group.networking.location
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_nat_gateway" "main" {
  name                = "${var.environment}-nat"
  resource_group_name = azurerm_resource_group.networking.name
  location            = azurerm_resource_group.networking.location
  sku_name            = "Standard"
}

resource "azurerm_nat_gateway_public_ip_association" "main" {
  nat_gateway_id       = azurerm_nat_gateway.main.id
  public_ip_address_id = azurerm_public_ip.nat.id
}

resource "azurerm_subnet_nat_gateway_association" "app" {
  subnet_id      = azurerm_subnet.app.id
  nat_gateway_id = azurerm_nat_gateway.main.id
}
```

**Gotcha**: Azure AKS with Azure CNI requires a large subnet because each pod gets a VNET IP. Calculate: `max_pods_per_node × max_nodes`. A /20 gives 4091 IPs — enough for ~36 nodes at 110 pods each.

**Gotcha**: Delegated subnets (for PostgreSQL, App Service, etc.) cannot host any other resource type. Plan your subnet allocation to account for delegations.

## GCP VPC Pattern

```hcl
resource "google_compute_network" "main" {
  name                    = "${var.environment}-vpc"
  project                 = var.project_id
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "app" {
  name          = "${var.environment}-app"
  project       = var.project_id
  region        = var.region
  network       = google_compute_network.main.id
  ip_cidr_range = cidrsubnet(var.vpc_cidr, 8, 0)  # 10.0.0.0/24

  private_ip_google_access = true

  # GKE requires secondary ranges for pods and services
  secondary_ip_range {
    range_name    = "pods"
    ip_cidr_range = "10.1.0.0/16"   # 65K pod IPs
  }

  secondary_ip_range {
    range_name    = "services"
    ip_cidr_range = "10.2.0.0/20"   # 4K service IPs
  }
}

# Cloud NAT
resource "google_compute_router" "main" {
  name    = "${var.environment}-router"
  project = var.project_id
  region  = var.region
  network = google_compute_network.main.id
}

resource "google_compute_router_nat" "main" {
  name                               = "${var.environment}-nat"
  project                            = var.project_id
  region                             = var.region
  router                             = google_compute_router.main.name
  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
}
```

**Gotcha**: `auto_create_subnetworks = false` is essential. The default creates a subnet in every region.

**Gotcha**: GKE secondary ranges must not overlap with any other CIDR in the VPC or any peered VPC.

## VPC Peering

### AWS VPC Peering

```hcl
resource "aws_vpc_peering_connection" "prod_to_shared" {
  vpc_id      = aws_vpc.production.id
  peer_vpc_id = aws_vpc.shared_services.id
  auto_accept = true  # only works same account/region

  tags = { Name = "prod-to-shared" }
}

# Route from production to shared services
resource "aws_route" "prod_to_shared" {
  route_table_id            = aws_route_table.prod_private.id
  destination_cidr_block    = aws_vpc.shared_services.cidr_block
  vpc_peering_connection_id = aws_vpc_peering_connection.prod_to_shared.id
}

# Route from shared services to production
resource "aws_route" "shared_to_prod" {
  route_table_id            = aws_route_table.shared_private.id
  destination_cidr_block    = aws_vpc.production.cidr_block
  vpc_peering_connection_id = aws_vpc_peering_connection.prod_to_shared.id
}
```

**Gotcha**: VPC peering is not transitive. If A peers with B and B peers with C, A cannot reach C through B. For transitive connectivity, use Transit Gateway.

### Azure VNET Peering

```hcl
# Peering must be created from both sides
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "hub-to-spoke"
  resource_group_name       = azurerm_resource_group.hub.name
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.spoke.id
  allow_forwarded_traffic   = true
  allow_gateway_transit     = true
}

resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  name                      = "spoke-to-hub"
  resource_group_name       = azurerm_resource_group.spoke.name
  virtual_network_name      = azurerm_virtual_network.spoke.name
  remote_virtual_network_id = azurerm_virtual_network.hub.id
  allow_forwarded_traffic   = true
  use_remote_gateways       = true
}
```

## Transit Gateway / Hub-Spoke

### AWS Transit Gateway

For connecting many VPCs with transitive routing:

```hcl
resource "aws_ec2_transit_gateway" "main" {
  description                     = "Central transit gateway"
  default_route_table_association = "enable"
  default_route_table_propagation = "enable"
  dns_support                     = "enable"

  tags = { Name = "central-tgw" }
}

# Attach each VPC
resource "aws_ec2_transit_gateway_vpc_attachment" "production" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.production.id
  subnet_ids         = aws_subnet.prod_private[*].id

  tags = { Name = "production-attachment" }
}

resource "aws_ec2_transit_gateway_vpc_attachment" "shared" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.shared_services.id
  subnet_ids         = aws_subnet.shared_private[*].id

  tags = { Name = "shared-attachment" }
}

# Routes in each VPC pointing to TGW for cross-VPC traffic
resource "aws_route" "prod_to_tgw" {
  route_table_id         = aws_route_table.prod_private.id
  destination_cidr_block = "10.0.0.0/8"  # all private traffic via TGW
  transit_gateway_id     = aws_ec2_transit_gateway.main.id
}
```

**When to use Transit Gateway vs Peering**:
- 1-3 VPCs: peering is simpler and cheaper
- 4+ VPCs: Transit Gateway scales better (N attachments vs N*(N-1)/2 peering connections)
- Need transitive routing: Transit Gateway is required

## DNS Patterns

### AWS Route53 Private Hosted Zone

```hcl
resource "aws_route53_zone" "internal" {
  name = "internal.example.com"

  vpc {
    vpc_id = aws_vpc.main.id
  }
}

# Service discovery via DNS
resource "aws_route53_record" "database" {
  zone_id = aws_route53_zone.internal.zone_id
  name    = "db.internal.example.com"
  type    = "CNAME"
  ttl     = 60
  records = [aws_db_instance.main.address]
}
```

### Azure Private DNS Zone

```hcl
resource "azurerm_private_dns_zone" "internal" {
  name                = "internal.example.com"
  resource_group_name = azurerm_resource_group.networking.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "main" {
  name                  = "internal-link"
  resource_group_name   = azurerm_resource_group.networking.name
  private_dns_zone_name = azurerm_private_dns_zone.internal.name
  virtual_network_id    = azurerm_virtual_network.main.id
}
```

## Networking Gotchas Summary

| Gotcha | Cloud | Impact | Fix |
|---|---|---|---|
| Overlapping CIDRs | All | Cannot peer VPCs | Plan CIDR allocation before creating any VPC |
| Missing NAT Gateway | All | Private subnets cannot reach internet | Add NAT for each VPC with private workloads |
| No DNS hostnames on VPC | AWS | Instances do not get DNS names | `enable_dns_hostnames = true` |
| Default security group open | AWS | All VPC members can communicate | Import and tighten, or explicitly manage |
| Missing subnet tags for EKS | AWS | ALB controller cannot find subnets | Add `kubernetes.io/role/elb` tags |
| AKS subnet too small | Azure | Pods fail to schedule (no IPs) | Size for `max_pods × max_nodes` |
| auto_create_subnetworks | GCP | Unwanted subnets in every region | `auto_create_subnetworks = false` |
| Peering not bidirectional | Azure | Peering shows as Initiated | Create peering from both sides |
| Peering not transitive | AWS | VPC A cannot reach VPC C via B | Use Transit Gateway for transitive routing |
| Secondary ranges overlap | GCP | GKE fails to create | Ensure pod/service ranges do not overlap |

