Skip to content

Commit f48e36b

Browse files
authored
Add Transit Gateway egress mode for new VPCs (#115)
Add an `enable_transit_gateway` toggle (+ `transit_gateway_id`) to route new-VPC private-subnet egress through a Transit Gateway instead of NAT gateways. IPv6 egress is opt-in via `transit_gateway_ipv6_egress`. Includes vpc/quilt validation + smoke tests and docs. Co-authored-by: Ernie Prabhakar <drernie@users.noreply.github.com>
1 parent a816756 commit f48e36b

11 files changed

Lines changed: 405 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Optional release notice.
1818

1919
## [Unreleased] - YYYY-MM-DD
2020

21+
- [Added] Transit Gateway egress mode for new VPCs: set `enable_transit_gateway = true` (+ `transit_gateway_id`) to route private-subnet egress through a Transit Gateway instead of NAT gateways; IPv6 egress is opt-in via `transit_gateway_ipv6_egress`. See [Transit Gateway egress](README.md#transit-gateway-egress) ([#115](https://github.com/quiltdata/iac/pull/115))
22+
2123
## [1.7.2] - 2026-06-08
2224

2325
- [Fixed] Bump `modules/cnames` AWS provider constraint from `~> 5.0` to `~> 6.0` so it resolves alongside the `vpc` module's `aws >= 6.28` requirement — using `quilt` + `cnames` in one root previously failed `terraform init` ([#117](https://github.com/quiltdata/iac/pull/117))

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,67 @@ resource "aws_vpc_endpoint" "api_gateway_endpoint" {
771771
}
772772
```
773773

774+
### Transit Gateway egress
775+
776+
By default a new Quilt VPC reaches the internet through Quilt-created NAT
777+
gateways. If you operate a Transit Gateway (TGW) as your egress boundary, set
778+
`enable_transit_gateway = true` (only with `create_new_vpc = true`). Quilt still
779+
creates the VPC, subnets, and endpoints, but instead disables the NAT gateways
780+
and the IPv6 egress-only IGW, attaches the VPC to your TGW (in the intra
781+
subnets), and points each private route table's default route at the TGW. The
782+
S3 gateway endpoint is unchanged, so bulk S3 traffic stays on the endpoint and
783+
does **not** traverse the TGW — only genuinely external egress does.
784+
785+
```hcl
786+
module "quilt" {
787+
# ...
788+
create_new_vpc = true
789+
enable_transit_gateway = true
790+
transit_gateway_id = "tgw-0123456789abcdef0" # an existing TGW, or one created in this same config
791+
# transit_gateway_ipv6_egress = true # only if your TGW carries IPv6 egress
792+
}
793+
```
794+
795+
`transit_gateway_id` may be a value known only after apply (e.g. a TGW you
796+
create in the same Terraform configuration) — the toggle is the separate
797+
`enable_transit_gateway` bool, so this does not break planning.
798+
799+
**You must provide the egress path.** Quilt owns only the VPC→TGW leg. Before
800+
apply, your TGW must:
801+
802+
- be reachable from the deployment account — share it via
803+
[AWS Resource Access Manager](https://aws.amazon.com/ram/) and accept the VPC
804+
attachment (or enable auto-accept) if the TGW lives in another account;
805+
- have route tables that forward the VPC's egress out to the internet (e.g. via
806+
a central egress VPC / NAT) **and** route return traffic back to the VPC's
807+
CIDR.
808+
809+
**CIDR uniqueness:** a TGW cannot route between overlapping CIDRs, so any VPCs
810+
attached to the same TGW must have non-overlapping ranges. Set `cidr`
811+
accordingly if more than one Quilt stack shares a TGW (the default is
812+
`10.0.0.0/16`).
813+
814+
**IPv6** egress through the TGW is opt-in (`transit_gateway_ipv6_egress`,
815+
default `false`). The VPC is dual-stack, so set this `true` **only if your TGW
816+
actually carries IPv6 egress**: pointing `::/0` at a TGW that can't route IPv6
817+
black-holes those packets, and clients without
818+
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs) IPv6+IPv4 dual
819+
stack support (e.g. Python's `requests`/`urllib3`) then stall on the connection
820+
timeout before falling back
821+
to IPv4. Left `false`, the new VPC has no IPv6 default route, so an IPv6
822+
attempt fails immediately (`ENETUNREACH`) and the client uses IPv4 with no
823+
delay.
824+
825+
**Reversibility:** removing `enable_transit_gateway` (or setting it `false`)
826+
restores the NAT gateways and IPv6 egress-only IGW. Toggling it on or off for an
827+
already-deployed VPC recreates/destroys NAT gateways and their Elastic IPs and
828+
briefly interrupts egress, so do it in a maintenance window. Either direction
829+
also **changes the stack's public egress IP** — disabling releases the NAT
830+
Elastic IPs (AWS won't hand the same ones back), and enabling sends egress out
831+
through the TGW's NAT instead — so anything that allowlists Quilt's egress
832+
address (a license endpoint, a partner firewall, a SaaS IP allowlist) must be
833+
updated, or it breaks silently.
834+
774835
### Profile
775836
You may wish to set a specific AWS profile before executing `terraform`
776837
commands.

VARIABLES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ This document provides comprehensive documentation for all variables available i
2626
| `user_subnets` | `list(string)` | `null` | ALB subnet IDs (exactly 2 required for internal ALB with existing VPC) |
2727
| `user_security_group` | `string` | `null` | Security group ID for ALB access (required for existing VPC) |
2828
| `api_endpoint` | `string` | `null` | VPC endpoint ID for API Gateway (required for internal ALB with existing VPC) |
29+
| `enable_transit_gateway` | `bool` | `false` | Route private-subnet egress through a Transit Gateway instead of NAT gateways (`create_new_vpc = true` only). Disables NAT + the IPv6 egress-only IGW; requires `transit_gateway_id`. See [Transit Gateway egress](README.md#transit-gateway-egress). |
30+
| `transit_gateway_id` | `string` | `null` | Transit Gateway to attach to (required when `enable_transit_gateway = true`). May be a value known only after apply (e.g. a TGW created in the same configuration). |
31+
| `transit_gateway_ipv6_egress` | `bool` | `false` | Also route IPv6 (`::/0`) egress through the TGW. Leave off unless the TGW carries IPv6 egress, otherwise IPv6 traffic would be black-holed. |
2932

3033
### Database Configuration Variables
3134

examples/main.tf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ module "quilt" {
137137
# user_security_group = "sg-YOUR-SECURITY-GROUP" # For ALB access
138138
# user_subnets = ["subnet-YOUR-USER-1", "subnet-YOUR-USER-2"] # For ALB (if internal = true)
139139
# api_endpoint = "vpce-YOUR-VPC-ENDPOINT" # VPC endpoint (if internal = true)
140+
# enable_transit_gateway = true # Route private-subnet egress via a TGW instead of NAT (create_new_vpc = true only)
141+
# transit_gateway_id = "tgw-YOUR-TRANSIT-GATEWAY-ID" # Required when enable_transit_gateway = true; the TGW must route to the internet and back
142+
# transit_gateway_ipv6_egress = true # Only if the TGW carries IPv6 egress; off = no IPv6 default route (clients use IPv4)
140143

141144
# CloudFormation notifications (optional)
142145
# stack_notification_arns = ["arn:aws:sns:YOUR-AWS-REGION:YOUR-ACCOUNT-ID:quilt-notifications"]

modules/quilt/main.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ module "vpc" {
1414
cidr = var.cidr
1515
internal = var.internal
1616

17+
enable_transit_gateway = var.enable_transit_gateway
18+
transit_gateway_id = var.transit_gateway_id
19+
transit_gateway_ipv6_egress = var.transit_gateway_ipv6_egress
20+
1721
create_new_vpc = var.create_new_vpc
1822
existing_api_endpoint = var.api_endpoint
1923
existing_vpc_id = var.vpc_id

modules/quilt/tests/smoke/main.tf

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,32 @@ variable "user_subnets" {
6666
default = null
6767
}
6868

69+
variable "enable_transit_gateway" {
70+
type = bool
71+
default = false
72+
}
73+
74+
variable "transit_gateway_id" {
75+
type = string
76+
default = null
77+
}
78+
79+
variable "transit_gateway_ipv6_egress" {
80+
type = bool
81+
default = false
82+
}
83+
6984
# New inputs added to the quilt module must be threaded through here, or the
7085
# smoke coverage silently narrows (the new input is never exercised).
7186
module "quilt" {
7287
source = "../../"
7388

74-
name = "quilt-test"
75-
parameters = {}
76-
template_file = "${path.module}/fixtures/quilt.yaml"
89+
name = "quilt-test"
90+
parameters = {}
91+
template_file = "${path.module}/fixtures/quilt.yaml"
92+
enable_transit_gateway = var.enable_transit_gateway
93+
transit_gateway_id = var.transit_gateway_id
94+
transit_gateway_ipv6_egress = var.transit_gateway_ipv6_egress
7795

7896
create_new_vpc = var.create_new_vpc
7997
internal = var.internal

modules/quilt/tests/smoke/smoke.tftest.hcl

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,37 @@ run "new_vpc_plans" {
3232
}
3333
}
3434

35+
run "new_vpc_transit_gateway_plans" {
36+
command = plan
37+
variables {
38+
create_new_vpc = true
39+
internal = false
40+
enable_transit_gateway = true
41+
transit_gateway_id = "tgw-00000000000000000"
42+
}
43+
# TGW egress mode on a new VPC must plan end-to-end through the public module.
44+
assert {
45+
condition = output.stack_name == "quilt-test"
46+
error_message = "The CloudFormation stack must be named after var.name"
47+
}
48+
}
49+
50+
run "new_vpc_transit_gateway_ipv6_plans" {
51+
command = plan
52+
variables {
53+
create_new_vpc = true
54+
internal = false
55+
enable_transit_gateway = true
56+
transit_gateway_id = "tgw-00000000000000000"
57+
transit_gateway_ipv6_egress = true
58+
}
59+
# TGW egress with IPv6 opted in must also plan end-to-end.
60+
assert {
61+
condition = output.stack_name == "quilt-test"
62+
error_message = "The CloudFormation stack must be named after var.name"
63+
}
64+
}
65+
3566
run "new_vpc_internal_plans" {
3667
command = plan
3768
variables {

modules/quilt/variables.tf

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ variable "internal" {
2929
description = "If true create an inward ELBv2, else create an internet-facing ELBv2."
3030
}
3131

32+
variable "enable_transit_gateway" {
33+
type = bool
34+
default = false
35+
description = "Route private subnet egress through a Transit Gateway instead of NAT gateways. Only supported when create_new_vpc == true. When true, transit_gateway_id is required, and NAT gateways and the IPv6 egress-only gateway are disabled. (Toggle is a separate bool so transit_gateway_id may be a value known only after apply, e.g. a TGW created in the same configuration.)"
36+
}
37+
38+
variable "transit_gateway_id" {
39+
type = string
40+
default = null
41+
description = "Transit Gateway ID for private subnet egress. Required when enable_transit_gateway == true; may be a computed value (e.g. a TGW created in the same configuration)."
42+
validation {
43+
condition = var.transit_gateway_id == null || can(regex("^tgw-[0-9a-f]+$", var.transit_gateway_id))
44+
error_message = "transit_gateway_id must be null or a valid Transit Gateway ID (e.g. tgw-0123456789abcdef0)."
45+
}
46+
}
47+
48+
variable "transit_gateway_ipv6_egress" {
49+
type = bool
50+
default = false
51+
description = "When enable_transit_gateway is true, also route IPv6 (::/0) egress through the Transit Gateway. Set true only if the Transit Gateway carries IPv6 egress: pointing ::/0 at a TGW that can't route IPv6 black-holes the traffic and stalls clients without Happy Eyeballs (e.g. Python requests/urllib3) on the connection timeout. Left false (default), the VPC has no IPv6 default route, so IPv6 attempts fail immediately and clients use IPv4 with no delay. No effect when enable_transit_gateway is false."
52+
}
53+
3254
variable "db_snapshot_identifier" {
3355
type = string
3456
nullable = true

modules/vpc/main.tf

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ locals {
1717
"user_security_group (required)" : var.existing_user_security_group != null,
1818
"user_subnets (required if var.internal == true and var.create_new_vpc == false, else must be null)" : (var.internal && !var.create_new_vpc) == (var.existing_user_subnets != null)
1919
"api_endpoint (required if var.internal == true, else must be null)" : var.internal == (var.existing_api_endpoint != null),
20+
"enable_transit_gateway == false (TGW egress requires create_new_vpc == true)" : var.enable_transit_gateway == false,
2021
}
2122
new_network_requires = {
2223
"create_new_vpc == true" : var.create_new_vpc == true,
@@ -32,6 +33,11 @@ locals {
3233
new_network_valid = alltrue(values(local.new_network_requires))
3334
configuration_error = !local.existing_network_valid && !local.new_network_valid
3435

36+
# TGW egress is gated on the bool (not transit_gateway_id != null) so the
37+
# resource counts stay known at plan time even when transit_gateway_id is a
38+
# computed value (e.g. a TGW created in the same configuration).
39+
transit_gateway_enabled = local.new_network_valid && var.enable_transit_gateway
40+
3541
azs = slice(data.aws_availability_zones.available.names, 0, 2)
3642
subnet_cidrs = [for k, v in local.azs : cidrsubnet(var.cidr, 1, k)]
3743
}
@@ -71,8 +77,52 @@ module "vpc" {
7177

7278
enable_dns_hostnames = true
7379
enable_dns_support = true
74-
enable_nat_gateway = true
75-
one_nat_gateway_per_az = true
80+
enable_nat_gateway = !var.enable_transit_gateway
81+
one_nat_gateway_per_az = !var.enable_transit_gateway
82+
create_egress_only_igw = !var.enable_transit_gateway
83+
}
84+
85+
resource "aws_ec2_transit_gateway_vpc_attachment" "egress" {
86+
count = local.transit_gateway_enabled ? 1 : 0
87+
88+
# Intra subnets only host the attachment ENIs (they have no internet route).
89+
# The egress default routes go in the private route tables below — don't move
90+
# this to private_subnets.
91+
subnet_ids = module.vpc.intra_subnets
92+
transit_gateway_id = var.transit_gateway_id
93+
vpc_id = module.vpc.vpc_id
94+
ipv6_support = var.transit_gateway_ipv6_egress ? "enable" : "disable"
95+
96+
tags = {
97+
Name = "${var.name}-egress"
98+
}
99+
100+
lifecycle {
101+
precondition {
102+
condition = var.transit_gateway_id != null
103+
error_message = "transit_gateway_id is required when enable_transit_gateway is true."
104+
}
105+
}
106+
}
107+
108+
resource "aws_route" "private_tgw_ipv4_egress" {
109+
count = local.transit_gateway_enabled ? length(module.vpc.private_route_table_ids) : 0
110+
111+
route_table_id = module.vpc.private_route_table_ids[count.index]
112+
destination_cidr_block = "0.0.0.0/0"
113+
transit_gateway_id = var.transit_gateway_id
114+
115+
depends_on = [aws_ec2_transit_gateway_vpc_attachment.egress]
116+
}
117+
118+
resource "aws_route" "private_tgw_ipv6_egress" {
119+
count = local.transit_gateway_enabled && var.transit_gateway_ipv6_egress ? length(module.vpc.private_route_table_ids) : 0
120+
121+
route_table_id = module.vpc.private_route_table_ids[count.index]
122+
destination_ipv6_cidr_block = "::/0"
123+
transit_gateway_id = var.transit_gateway_id
124+
125+
depends_on = [aws_ec2_transit_gateway_vpc_attachment.egress]
76126
}
77127

78128
// Module name no longer accurate (see description); changing name causes tf apply to fail

0 commit comments

Comments
 (0)