Quick take — A reusable Terraform module for AWS VPC Peering: aws_vpc_peering_connection with same/cross-account and cross-region support, auto-accept, DNS resolution options, and automatic route table entries. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "vpc_peering" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc-peering?ref=v1.0.0"
name = "..." # Logical name; used as the `Name` tag.
vpc_id = "..." # Requester VPC ID (initiates the peering).
peer_vpc_id = "..." # Accepter (peer) VPC ID.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A VPC peering connection is a private, non-transitive networking link between two Amazon VPCs that lets instances communicate using private IPv4/IPv6 addresses as if they were on the same network — traffic stays on the AWS backbone and never traverses the public internet, a gateway, or a VPN. The catch is that peering on its own does almost nothing: creating an aws_vpc_peering_connection only establishes the link. For traffic to actually flow you must also accept the connection (which is a separate action when the peer is in another account or region), add routes in the route tables of both sides pointing at the peering connection, and optionally enable DNS hostname resolution across the link. Miss any one of those steps and you get a peering connection in active state that silently drops every packet.
This module wraps all of that into one var-driven unit. It creates the aws_vpc_peering_connection, handles the requester/accepter accept flow for same-account peerings via auto_accept, exposes the cross-account / cross-region escape hatches (peer_owner_id, peer_region), toggles the DNS resolution options on both the requester and accepter sides, and — the part everyone forgets — programmatically injects routes into a caller-supplied list of route tables so the connection is usable the moment apply finishes. Wrapping it in a module gives you consistent tagging, naming, and route wiring across dozens of peerings instead of hand-crafting brittle resource blocks every time.
When to use it
- Two VPCs in the same account/region that need private connectivity (e.g. a shared-services VPC reaching an application VPC) and you want the connection plus both-side routes created in a single
apply. - Cross-account peering between, say, a security/tooling account and a workload account, where the requester creates the connection and you control the accepter via
auto_accept = false(the peer account accepts separately). - Cross-region peering (inter-region VPC peering) to connect a primary region to a DR region without a Transit Gateway, when the traffic volume and topology are small enough that per-pair peering is cheaper and simpler than a TGW mesh.
- Small, stable hub-and-spoke or point-to-point topologies (a handful of VPCs). Reach for AWS Transit Gateway instead once you need transitive routing, more than ~10–15 VPCs, or centralized inspection — VPC peering is non-transitive and the route/CIDR bookkeeping grows O(n²).
- You explicitly do not need this if the two VPCs have overlapping CIDR blocks — peering cannot be created between VPCs with matching or overlapping IPv4 ranges.
Module structure
terraform-module-aws-vpc-peering/
├── versions.tf # provider + Terraform version pins
├── main.tf # aws_vpc_peering_connection (+ options) and route entries
├── variables.tf # all inputs with validation
└── outputs.tf # connection id, status, accept_status, route ids
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Cross-account when an explicit peer owner is given and it differs from the
# requester. We can't read the requester account id at plan time without a
# data source, so we treat "peer_owner_id set" as the signal for cross-account.
is_cross_account = var.peer_owner_id != null && var.peer_owner_id != ""
is_cross_region = var.peer_region != null && var.peer_region != ""
# Auto-accept is only valid for SAME-account, SAME-region peerings. AWS rejects
# auto_accept = true on cross-account or cross-region connections, so we force
# it off in those cases to keep plans valid.
effective_auto_accept = (
!local.is_cross_account && !local.is_cross_region
) ? var.auto_accept : false
common_tags = merge(
var.tags,
{
Name = var.name
ManagedBy = "terraform"
Module = "terraform-module-aws-vpc-peering"
}
)
}
resource "aws_vpc_peering_connection" "this" {
vpc_id = var.vpc_id
peer_vpc_id = var.peer_vpc_id
# null when same-account / same-region; set to enable the cross-* flows.
peer_owner_id = local.is_cross_account ? var.peer_owner_id : null
peer_region = local.is_cross_region ? var.peer_region : null
auto_accept = local.effective_auto_accept
# Requester-side options. allow_remote_vpc_dns_resolution lets THIS VPC resolve
# the peer's private DNS hostnames to private IPs over the peering link.
# These blocks are only honored for same-region peerings; for inter-region
# peering DNS resolution is configured differently, so we omit them there.
dynamic "requester" {
for_each = local.is_cross_region ? [] : [1]
content {
allow_remote_vpc_dns_resolution = var.allow_remote_vpc_dns_resolution
}
}
# Accepter-side options are only settable from the side that owns the accepter
# VPC — i.e. same-account peerings managed here. Skip for cross-account because
# the accepter is owned by the other account.
dynamic "accepter" {
for_each = (local.is_cross_account || local.is_cross_region) ? [] : [1]
content {
allow_remote_vpc_dns_resolution = var.allow_remote_vpc_dns_resolution
}
}
tags = local.common_tags
}
# Routes from THIS VPC's route tables toward the peer VPC's CIDR(s).
# One route per (route_table_id x peer_cidr_block) pair.
resource "aws_route" "to_peer" {
for_each = {
for pair in setproduct(var.route_table_ids, var.peer_cidr_blocks) :
"${pair[0]}|${pair[1]}" => {
route_table_id = pair[0]
cidr_block = pair[1]
}
}
route_table_id = each.value.route_table_id
destination_cidr_block = each.value.cidr_block
vpc_peering_connection_id = aws_vpc_peering_connection.this.id
}
# Optional reverse routes: from the PEER VPC's route tables back to this VPC's
# CIDR(s). Only usable for same-account peerings where Terraform can manage the
# peer side's route tables. For cross-account, the peer owner adds these.
resource "aws_route" "from_peer" {
for_each = {
for pair in setproduct(var.peer_route_table_ids, var.vpc_cidr_blocks) :
"${pair[0]}|${pair[1]}" => {
route_table_id = pair[0]
cidr_block = pair[1]
}
}
route_table_id = each.value.route_table_id
destination_cidr_block = each.value.cidr_block
vpc_peering_connection_id = aws_vpc_peering_connection.this.id
}
variables.tf
variable "name" {
description = "Logical name for the peering connection; used as the Name tag."
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 255
error_message = "name must be between 1 and 255 characters."
}
}
variable "vpc_id" {
description = "ID of the requester VPC (the VPC that initiates the peering)."
type = string
validation {
condition = can(regex("^vpc-[0-9a-f]{8,17}$", var.vpc_id))
error_message = "vpc_id must be a valid VPC id (vpc-xxxxxxxx)."
}
}
variable "peer_vpc_id" {
description = "ID of the accepter (peer) VPC."
type = string
validation {
condition = can(regex("^vpc-[0-9a-f]{8,17}$", var.peer_vpc_id))
error_message = "peer_vpc_id must be a valid VPC id (vpc-xxxxxxxx)."
}
}
variable "peer_owner_id" {
description = "AWS account ID that owns the peer VPC. Leave null/empty for same-account peering."
type = string
default = null
validation {
condition = var.peer_owner_id == null || can(regex("^[0-9]{12}$", var.peer_owner_id))
error_message = "peer_owner_id must be a 12-digit AWS account id, or null."
}
}
variable "peer_region" {
description = "Region of the peer VPC for inter-region peering. Leave null/empty for same-region peering."
type = string
default = null
validation {
condition = var.peer_region == null || can(regex("^[a-z]{2}-[a-z]+-[0-9]$", var.peer_region))
error_message = "peer_region must be a valid AWS region code (e.g. eu-west-1), or null."
}
}
variable "auto_accept" {
description = "Automatically accept the peering connection. Only honored for same-account, same-region peerings; forced false otherwise."
type = bool
default = true
}
variable "allow_remote_vpc_dns_resolution" {
description = "Allow resolution of the peer VPC's private DNS hostnames to private IPs over the peering link."
type = bool
default = true
}
variable "route_table_ids" {
description = "Route table IDs in THIS VPC that should get routes toward the peer's CIDR(s)."
type = list(string)
default = []
validation {
condition = alltrue([for rt in var.route_table_ids : can(regex("^rtb-[0-9a-f]{8,17}$", rt))])
error_message = "Every route_table_ids entry must be a valid route table id (rtb-xxxxxxxx)."
}
}
variable "peer_cidr_blocks" {
description = "CIDR block(s) of the peer VPC to route toward from this VPC's route tables."
type = list(string)
default = []
validation {
condition = alltrue([for c in var.peer_cidr_blocks : can(cidrhost(c, 0))])
error_message = "Every peer_cidr_blocks entry must be a valid IPv4/IPv6 CIDR."
}
}
variable "peer_route_table_ids" {
description = "Optional: route table IDs in the PEER VPC for reverse routes (same-account only)."
type = list(string)
default = []
validation {
condition = alltrue([for rt in var.peer_route_table_ids : can(regex("^rtb-[0-9a-f]{8,17}$", rt))])
error_message = "Every peer_route_table_ids entry must be a valid route table id (rtb-xxxxxxxx)."
}
}
variable "vpc_cidr_blocks" {
description = "CIDR block(s) of THIS VPC, used for reverse routes from the peer side."
type = list(string)
default = []
validation {
condition = alltrue([for c in var.vpc_cidr_blocks : can(cidrhost(c, 0))])
error_message = "Every vpc_cidr_blocks entry must be a valid IPv4/IPv6 CIDR."
}
}
variable "tags" {
description = "Additional tags applied to the peering connection."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "ID of the VPC peering connection."
value = aws_vpc_peering_connection.this.id
}
output "name" {
description = "Name tag of the VPC peering connection."
value = var.name
}
output "accept_status" {
description = "Accept status of the connection (e.g. active, pending-acceptance, provisioning)."
value = aws_vpc_peering_connection.this.accept_status
}
output "vpc_id" {
description = "Requester VPC ID."
value = aws_vpc_peering_connection.this.vpc_id
}
output "peer_vpc_id" {
description = "Accepter (peer) VPC ID."
value = aws_vpc_peering_connection.this.peer_vpc_id
}
output "peer_owner_id" {
description = "Account ID that owns the peer VPC (null for same-account)."
value = aws_vpc_peering_connection.this.peer_owner_id
}
output "peer_region" {
description = "Region of the peer VPC (null for same-region)."
value = aws_vpc_peering_connection.this.peer_region
}
output "requester_route_ids" {
description = "Route resource IDs created in this VPC toward the peer."
value = [for r in aws_route.to_peer : r.id]
}
output "accepter_route_ids" {
description = "Reverse route resource IDs created in the peer VPC (same-account only)."
value = [for r in aws_route.from_peer : r.id]
}
How to use it
Same-account, same-region peering between a shared-services VPC and an application VPC, with routes wired on both sides in one apply:
module "vpc_peering" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc-peering?ref=v1.0.0"
name = "shared-to-app-prod"
vpc_id = aws_vpc.shared.id # requester
peer_vpc_id = aws_vpc.app.id # accepter
auto_accept = true
allow_remote_vpc_dns_resolution = true
# Routes from shared-services -> app CIDR
route_table_ids = aws_route_table.shared_private[*].id
peer_cidr_blocks = [aws_vpc.app.cidr_block]
# Reverse routes from app -> shared CIDR (same account, so we manage both)
peer_route_table_ids = aws_route_table.app_private[*].id
vpc_cidr_blocks = [aws_vpc.shared.cidr_block]
tags = {
Environment = "prod"
Team = "platform"
}
}
# Downstream: reference an output. Here a security group rule allows traffic
# from the peer VPC, and a tag captures the live peering id for auditing.
resource "aws_security_group_rule" "allow_app_subnet" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = [aws_vpc.app.cidr_block]
security_group_id = aws_security_group.shared_db.id
description = "Postgres from app VPC over peering ${module.vpc_peering.id}"
}
For a cross-account peering, set peer_owner_id and leave auto_accept = false (the module forces it off anyway); the peer account accepts the connection separately, and peer_route_table_ids is left empty because the other account owns those route tables.
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/vpc_peering/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc-peering?ref=v1.0.0"
}
inputs = {
name = "..."
vpc_id = "..."
peer_vpc_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/vpc_peering && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Logical name; used as the Name tag. |
vpc_id |
string |
— | Yes | Requester VPC ID (initiates the peering). |
peer_vpc_id |
string |
— | Yes | Accepter (peer) VPC ID. |
peer_owner_id |
string |
null |
No | Account ID owning the peer VPC; set for cross-account peering. |
peer_region |
string |
null |
No | Peer VPC region; set for inter-region peering. |
auto_accept |
bool |
true |
No | Auto-accept; honored only for same-account, same-region peerings. |
allow_remote_vpc_dns_resolution |
bool |
true |
No | Resolve the peer’s private DNS hostnames to private IPs over the link. |
route_table_ids |
list(string) |
[] |
No | Route tables in this VPC to receive routes toward the peer CIDR(s). |
peer_cidr_blocks |
list(string) |
[] |
No | Peer VPC CIDR(s) to route toward from this VPC. |
peer_route_table_ids |
list(string) |
[] |
No | Peer VPC route tables for reverse routes (same-account only). |
vpc_cidr_blocks |
list(string) |
[] |
No | This VPC’s CIDR(s), used for reverse routes from the peer side. |
tags |
map(string) |
{} |
No | Additional tags applied to the peering connection. |
Outputs
| Name | Description |
|---|---|
id |
ID of the VPC peering connection. |
name |
Name tag of the peering connection. |
accept_status |
Accept status (active, pending-acceptance, provisioning, …). |
vpc_id |
Requester VPC ID. |
peer_vpc_id |
Accepter (peer) VPC ID. |
peer_owner_id |
Account ID owning the peer VPC (null for same-account). |
peer_region |
Region of the peer VPC (null for same-region). |
requester_route_ids |
Route resource IDs created in this VPC toward the peer. |
accepter_route_ids |
Reverse route resource IDs created in the peer VPC (same-account only). |
Enterprise scenario
A fintech platform team runs a centralized shared-services VPC (10.0.0.0/16) hosting ECR pull-through caches, a Vault cluster, and outbound NAT, and needs every per-team workload VPC to reach those services privately. They instantiate this module once per workload VPC from a for_each over their team registry, all in the same account and region, with auto_accept = true and DNS resolution on so application pods resolve vault.shared.internal to private IPs. Because the module also writes the reverse routes into each workload VPC’s private route tables, a new team is fully connected to shared services within a single pipeline run — no manual route-table edits and no half-configured “active but dropping packets” peerings.
Best practices
- Wire routes on both sides or it’s a no-op. A peering connection in
activestate without matching routes in both VPCs silently blackholes traffic. This module’sroute_table_ids/peer_route_table_idspairing exists precisely to prevent the classic one-sided route bug — always populate both for same-account peerings. - Never peer overlapping CIDRs. AWS rejects peering between VPCs with overlapping IPv4 ranges, and even non-overlapping-but-adjacent ranges complicate routing. Standardize on a non-overlapping IP address plan (IPAM) before you peer anything.
- Keep
auto_acceptfor same-account only. For cross-account, leave acceptance to the peer account (least privilege) — the module already forcesauto_accept = falsewhenpeer_owner_idorpeer_regionis set, so don’t fight it. - Mind that peering is non-transitive — and watch the cost model. VPC A↔B and B↔C does not give A↔C; you must peer A↔C explicitly, so the connection count grows O(n²). Same-region peering data transfer is billed per GB in both directions, and inter-region peering adds cross-region transfer rates — beyond ~10–15 VPCs, a Transit Gateway is usually cheaper and far simpler to operate.
- Restrict the blast radius with tight routes and SGs. Prefer routing only the specific subnet CIDRs that actually need to talk, not the whole
/16, and gate the peered traffic with security group rules referencing the peer CIDR (as shown above) so the link doesn’t become an open back door between environments. - Name and tag consistently for auditability. Use a directional
name(shared-to-app-prod) plusEnvironment/Teamtags so the dozens ofpcx-…IDs in a large account remain traceable to their purpose during reviews and incident response.