IaC AWS

Terraform Module: AWS VPC Peering — One-Shot Cross-VPC Connectivity with Auto-Accept and Route Wiring

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

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 configlive/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 configlive/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

TerraformAWSVPC PeeringModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading