Skip to content
Professional10 min read

Transit Gateway Route Tables: Association, Propagation, and Segmentation

A Transit Gateway is not a router with one routing table — it is a routing domain per attachment. Understanding which table governs which direction is what separates a working hub-and-spoke design from one that fails asymmetrically.

Published Feb 17, 2026

Most Transit Gateway confusion traces back to a single wrong assumption: that the Transit Gateway has a route table. It does not. It has as many as you create, each attachment consults exactly one of them, and which one it consults depends on the direction traffic is moving. Get that model right and hub-and-spoke design becomes mechanical. Get it wrong and you produce topologies that work in one direction and fail silently in the other.

Isolated spokes: one routing domain per attachment, two route tables
VPC spoke-a 10.1.0.0/16 AZ a contains spoke-a 10.1.1.0/24, default route 10.0.0.0/8 → tgw, holding Workload. VPC shared services 10.0.0.0/16 AZ a contains shared 10.0.1.0/24, default route 10.0.0.0/8 → tgw, holding Shared service. VPC spoke-b 10.2.0.0/16 AZ a contains spoke-b 10.2.1.0/24, default route 10.0.0.0/8 → tgw, holding Workload.transitgatewaytwo tables, oneper directiontransitgatewaytwo tables, oneper directionVPC spoke-a 10.1.0.0/16AZ aspoke-aprivate10.1.1.0/2410.0.0.0/8 → tgwWorkloadVPC shared services 10.0.0.0/16AZ asharedprivate10.0.1.0/2410.0.0.0/8 → tgwShared serviceVPC spoke-b 10.2.0.0/16AZ aspoke-bprivate10.2.1.0/2410.0.0.0/8 → tgwWorkload

One transit gateway, drawn twice because it sits in both paths. Each connector carries traffic in both directions and each direction is evaluated against a different table, which is why neither connector can be labelled with just one: leaving either spoke consults tgw-rt-spokes, because that is where every spoke attachment is associated, while leaving shared services consults tgw-rt-shared. There is no path drawn between the two spokes, and that is not an omission — it is the segmentation, and it exists because neither spoke is propagated into the table the other one consults. Shared services is marked ↗ because it opens the lab where one spoke can reach it and the other cannot.

The two operations, and why you need both

Every attachment participates in routing through two independent relationships. The AWS console presents them as adjacent tabs with no hint that one without the other is useless.

AssociationPropagation
Question it answersWhich route table does traffic arriving from this attachment consult?Which route tables learn the CIDRs behind this attachment?
DirectionIngress — how this attachment sendsAdvertisement — how others reach this attachment
CardinalityExactly one route table per attachmentAny number of route tables per attachment
Failure symptomThis attachment cannot send anywhereOthers cannot reach this attachment

Read that table again with the cardinality in mind, because it is the asymmetry that causes trouble. An attachment has one association and can have many propagations. A checklist that verifies "attachment is associated" is verifying the operation that is hard to get wrong.

Route lookup happens per direction

This is the part worth internalizing, because it is what makes Transit Gateway failures direction-asymmetric.

When a packet enters the Transit Gateway, the route table consulted is the one associated with the attachment it arrived on. A request and its reply therefore enter on different attachments and may be evaluated against different route tables with different contents.

Consider a spoke talking to shared services:

Request and reply are governed by two different route tables
  1. EC2

    Request leaves spoke-b

    10.2.1.20 → 10.0.1.20

  2. RTB

    Enters on the spoke-b attachment

    consults tgw-rt-spokes — needs 10.0.0.0/16 propagated here

  3. DEST

    Arrives at shared services

    service accepts and answers

  4. RTB

    Reply enters on the shared attachment

    consults tgw-rt-shared — needs 10.2.0.0/16 propagated here

  5. DEST

    Reply reaches spoke-b

    both lookups succeeded

Two lookups, two route tables. A working path requires the destination CIDR to be present in both — and nothing in either VPC's configuration reveals whether it is.

So a complete path needs four things, not two: the sender associated somewhere, the receiver's CIDR propagated into that table, the receiver associated somewhere, and the sender's CIDR propagated into that table. Onboarding runbooks routinely capture the first three.

Segmentation is built from absence

Once you see the Transit Gateway as one routing domain per attachment, segmentation stops needing firewalls or NACLs. You express it by controlling what gets propagated where.

Flat. One route table, every attachment associated with it, every attachment propagated into it. Everything reaches everything. Fine for a small estate; the thing you are migrating away from.

Isolated spokes. Two route tables:

tgw-rt-spokesassoc: every spoke attachment
DestinationTargetState
10.0.0.0/16attach-sharedactiveOnly shared services is propagated here, so a spoke can reach shared and nothing else.

Spoke-to-spoke isolation is enforced by the routes this table does NOT contain. There is no deny rule anywhere.

tgw-rt-sharedassoc: shared services attachment
DestinationTargetState
10.1.0.0/16attach-spoke-aactive
10.2.0.0/16attach-spoke-bactiveEvery spoke must be propagated here, or shared services cannot answer it.

Shared services needs a route back to every spoke. This is the table onboarding forgets.

The consequence for code review is significant: what is missing from a Transit Gateway route table is as load-bearing as what is in it. A reviewer reading only added lines cannot evaluate a segmentation policy, because the policy lives in the absences. Say so explicitly in the module's documentation, or someone will "fix" the missing spoke routes in tgw-rt-spokes and quietly flatten your segmentation while resolving a ticket.

Centralized inspection. Spokes associate with a table whose default route points at an inspection VPC attachment; the inspection VPC associates with a table carrying routes to all spokes. Traffic is forced through the appliance because it is the only next hop the spoke table offers.

Appliance mode

If you build centralized inspection, this setting is not optional.

A Transit Gateway attachment spans multiple availability zones, and by default it keeps traffic in the AZ it arrived in. For a stateful appliance that is fatal: the request may be handled by the appliance ENI in us-east-1a while the reply arrives at the ENI in us-east-1b. The second appliance has no state for that flow and drops it.

resource "aws_ec2_transit_gateway_vpc_attachment" "inspection" {
  transit_gateway_id = aws_ec2_transit_gateway.hub.id
  vpc_id             = aws_vpc.inspection.id
  subnet_ids         = values(aws_subnet.inspection)[*].id
 
  # Pins both directions of a flow to the same AZ so a stateful appliance sees
  # the whole conversation. Set this on the INSPECTION attachment, not the
  # spokes.
  appliance_mode_support = "enable"
}

The symptom without it is memorable and easily misdiagnosed: connections work intermittently, and the pattern correlates with which AZ the client happens to be in rather than with load or time. It looks like a flaky appliance. It is a routing decision.

Two further constraints worth knowing before you design around it. Appliance mode is only supported on VPC attachments — you cannot set it on a VPN or Connect attachment, though flows originating from those attachment types are handled correctly by an appliance-mode VPC attachment. And enabling it on an existing attachment can rebalance flows across AZs as it takes effect, so it is not a free change on a live path.

Reading a route table when something fails

Two habits worth building.

Search for the destination rather than reading the table. A route table with thirty entries is not something you scan reliably. Ask the specific question:

aws ec2 search-transit-gateway-routes \
  --transit-gateway-route-table-id tgw-rtb-0a1b2c3d \
  --filters "Name=route-search.longest-prefix-match,Values=10.2.1.20" \
  --query 'Routes[].[DestinationCidrBlock,Type,State]' --output table

An empty result is a finding, not an absence of information.

Distinguish a missing route from a blackhole. They produce identical symptoms and require different fixes:

Missing routeBlackhole route
Appears in the tableNoYes, with state blackhole
Usual causePropagation never configuredStatic route whose attachment was deleted, or a deliberate isolation
How you find itSearch returns emptyVisible when listing routes
FixAdd the propagationRemove the static route or restore the attachment
Identical symptoms, drawn differently
Missing route Search returns empty contains spoke-a 10.1.1.0/24, default route 10.2.0.0/16 — no entry, holding Workload. Blackhole route Attached: attachment deleted. Visible when listing routes contains spoke-a 10.1.1.0/24, default route 10.2.0.0/16, holding Workload.Missing routeSearch returns emptyspoke-aprivate10.1.1.0/2410.2.0.0/16 — no entryWorkloadBlackhole routeVisible when listing routesspoke-aprivate10.1.1.0/24Workload10.2.0.0/16attachment deletedstate: blackhole

On the left the route table has nothing for the destination, so there is no arrow to draw — the absence is the finding. On the right the route exists and is selected, and the connector ends in a bar rather than an arrowhead because nothing is on the other side of it. The packet is discarded at the same place either way, which is why the two need different fixes and look the same from the sending host.

A blackhole records intent. A missing propagation leaves nothing behind, which is why it is the harder of the two and why the search-returns-empty habit matters.

What to instrument

Transit Gateway publishes a CloudWatch metric that fires for exactly the failure described above:

  • PacketDropCountNoRoute — a packet arrived and no route matched. Non-zero means some path in your estate is broken right now. Alarm on it.
  • PacketDropCountBlackhole — matched a blackhole route. Expected if you use blackholes deliberately; otherwise a signal that an attachment went away and left a static route behind.
  • PacketDropCountTTLExpired — the third drop cause, and the one the table above does not cover. A packet that dies of TTL rather than of a missing route has usually been handed back and forth between attachments, so treat this as the signature of a routing loop rather than a gap.
  • BytesIn / BytesOut per attachment — for cost attribution, since Transit Gateway bills per attachment-hour plus per gigabyte processed. Both are published at attachment level, and VPC attachments also publish them per Availability Zone.

The no-route metric is the one people wish they had had. It is populated from the first dropped packet, well before anyone files a ticket.

Cost, briefly

Transit Gateway is one of the easier services to overspend on because the hourly component scales with attachment count rather than traffic:

  • Roughly $0.05 per attachment-hour. Ten attachments is about $360/month before a single byte moves.
  • Roughly $0.02 per gigabyte processed.
  • Traffic that crosses the Transit Gateway twice — spoke to inspection, inspection to spoke — is processed twice and billed twice. Centralized inspection designs should model this deliberately rather than discovering it.

For an estate of two or three VPCs, peering is cheaper and simpler. Transit Gateway earns its cost at the point where the number of peering relationships becomes unmanageable, or where you need segmentation and centralized inspection.

Where to practice

Lab 03 deploys a hub-and-spoke topology where one spoke is correctly associated and never propagated into the shared services route table. Both VPCs come from the same module, every attachment reports available, and one spoke silently cannot be answered.

Lab 19 puts an AWS Network Firewall in the inspection VPC that the rest of this track routes traffic through, and asks why a correctly written drop rule stops the security team's test host and nothing else. The routing is right. The thing that is wrong is a variable with a default, and the default is the inspection VPC itself.