This is a lab in the SA Pro hands-on track. The reference posts argue
the decisions; this stands one up. It pairs with the peering-versus-Transit-Gateway post, which weighs four connectivity options across 52 VPCs and three Regions. Here we build the hub and wire it by hand. The full lab is in lab-sa-pro-02-transit-gateway.zip.
Before your first lab, do the one-time, once-per-account setup: run the zip’s
preflight.sh to confirm your account is ready, then deploy the lab reaper,
a standing backstop that auto-deletes any lab you forget to tear down after 24
hours. Every SA Pro lab tags its stack for the reaper on deploy.
Everything runs in ap-southeast-2, and unlike most of these labs this one is not
close to free. A Transit Gateway VPC attachment is charged per attachment-hour at
USD$0.07 on the Sydney list price, and the stack builds two of them. That is
USD$0.14 an hour for as long as the stack stands, plus USD$0.02 per GB processed.
The reaper’s 24-hour default caps a forgotten stack around USD$3.40. Tear it down
yourself anyway.
The scenario
Two VPCs need to reach each other, and there are more on the way. VPC peering handles a single pair fine, but peerings do not transit, so each new VPC has to peer with every existing one. Ten VPCs is forty-five peerings, and every route table grows with the estate. The mesh also hits a wall. The default quota is 50 active peering connections per VPC, adjustable to 125, so a full mesh stops at 51 VPCs until someone files the increase.
A Transit Gateway is the hub. Each VPC attaches once, the gateway routes between the spokes, and the eleventh VPC is one attachment rather than ten new peerings. The gateway here is created with default association and default propagation disabled. Disable both and AWS does not create a default route table for the gateway at all, so the custom table is the only one there is and nothing routes until you say so. That is the version worth practising: explicit routing, where you choose which attachments share a route table and what each one learns, is how you build segmentation later.
What you’re given
CloudFormation builds the Transit Gateway (with the two defaults disabled), VPC A
on 10.10.0.0/16 and VPC B on 10.20.0.0/16, each with a subnet and its own route
table, a VPC attachment for each VPC, and a custom TGW route table. What is
missing is the routing itself. The gap in src/template.yaml is a TODO block
where six resources go: the two associations
(AWS::EC2::TransitGatewayRouteTableAssociation), the two propagations
(AWS::EC2::TransitGatewayRouteTablePropagation), and the two return routes
(AWS::EC2::Route, one in each VPC’s route table). Each is only a few lines:
the associations and propagations name an attachment and the custom route
table; the routes name a VPC route table, the other VPC’s CIDR, and the
gateway.
Deploying the stack as shipped succeeds, but the two VPCs cannot reach each other: nothing is associated or propagated to the custom route table, and neither VPC has a route pointing at the gateway.
Your task
Fill in the TODO block with those six resources, one set for VPC A and the
mirror image for VPC B. Two decisions do the work, and it helps to keep them
separate in your head. Association picks which route table an attachment
consults when it sends traffic into the gateway. Propagation installs an
attachment’s routes into a route table, so the gateway learns 10.10.0.0/16 from
VPC A’s attachment and 10.20.0.0/16 from VPC B’s. For a VPC attachment the
propagated routes are the VPC’s own CIDR blocks and nothing else; you cannot
filter what an attachment advertises.
An attachment is associated with at most one route table, and with both defaults off it arrives associated with none, which is why the shipped stack routes nothing. The same attachment can propagate into as many route tables as you like. That asymmetry is what makes segmentation possible later. Associate and propagate both attachments to the one custom route table and the gateway has a route to either VPC.
That fills the gateway’s table, not the VPCs’. Each VPC’s route table still needs
a route sending the other CIDR to the gateway: 10.20.0.0/16 via the Transit
Gateway in VPC A, 10.10.0.0/16 via the Transit Gateway in VPC B. The DependsOn
matters here. A transit gateway must have at least one VPC attachment before it
can be added to a route table, and CloudFormation has no way to infer that from
a !Ref to the gateway, so a route built in the same template needs the
dependency spelled out. The template names both attachments on both routes,
which is symmetric and costs nothing.
One thing the template does that production should not. A VPC attachment reaches
only the Availability Zones where you enabled a subnet, and this stack enables
one subnet per VPC. An instance in any other zone can hold the route and still
have its traffic never forwarded to the gateway. The documented shape is a dedicated small subnet for the
attachment, a /28 is the suggested size, in every zone the workload runs in.
The same wiring holds when the spokes sit in other accounts. Share the gateway
with AWS RAM and each spoke
account runs CreateTransitGatewayVpcAttachment against its own VPC. The share
does not carry the routing: an account the gateway is shared with cannot create,
modify or delete its route tables, propagations or associations. The two knobs
you are wiring by hand here stay with the hub account permanently, which is what
makes a shared gateway safe to hand out. One wrinkle when the gateway and the
attachments sit in different accounts: identify zones by AZ ID, the form AWS
writes as use1-az1, because a zone letter maps to a different physical zone in
every account.
Run it
./scripts/deploy.sh # deploys src/template.yaml
./scripts/test.sh # checks the TGW and VPC route tables
./scripts/teardown.sh # deletes everything
test.sh never launches an instance, so the only charge is the pair of
attachments. It reads the custom Transit Gateway route table with
search-transit-gateway-routes, filtered on state=active, and asserts both VPC
CIDRs show up; each one arrives typed propagated rather than static. It then
reads both VPC route tables and asserts each has a route to the other VPC via a
Transit Gateway. With the unedited template it reports nothing routing; with the
wiring in place it prints:
ok: TGW route table has an active route for 10.10.0.0/16
ok: TGW route table has an active route for 10.20.0.0/16
ok: rtb-... routes 10.20.0.0/16 via the Transit Gateway
ok: rtb-... routes 10.10.0.0/16 via the Transit Gateway
PASS: both spokes are associated and propagated, and each VPC routes to the other via the Transit Gateway.
If it fails
- No active routes in the TGW route table. Propagation is missing. The gateway only learns a VPC’s CIDR when that attachment is propagated to the route table; association alone installs no routes.
- The TGW route table has routes, but the VPCs still cannot reach each other. The return routes are missing. Propagation fills the gateway’s table; each VPC still needs its own route sending the other CIDR to the gateway.
- A
Routeresource fails to create. Check theDependsOn. The gateway needs at least one VPC attachment in place before a route table can point at it. - Every check passes and traffic still does not flow.
test.shproves routes, not reachability. The usual cause is an instance in a zone where the attachment has no subnet.
Reveal the solution
Work the six resources out yourself first. When you want the reference answer, deploy it without editing anything:
SRC=solution ./scripts/deploy.sh && ./scripts/test.sh
Or unfold it here:
Show the answer
One set for VPC A; VPC B is the mirror image (swap the attachment, the route table, and the CIDR).
AssociationA:
Type: AWS::EC2::TransitGatewayRouteTableAssociation
Properties:
TransitGatewayAttachmentId: !Ref AttachmentA
TransitGatewayRouteTableId: !Ref TransitGatewayRouteTable
PropagationA:
Type: AWS::EC2::TransitGatewayRouteTablePropagation
Properties:
TransitGatewayAttachmentId: !Ref AttachmentA
TransitGatewayRouteTableId: !Ref TransitGatewayRouteTable
RouteAToB:
Type: AWS::EC2::Route
DependsOn: [AttachmentA, AttachmentB]
Properties:
RouteTableId: !Ref RouteTableA
DestinationCidrBlock: 10.20.0.0/16
TransitGatewayId: !Ref TransitGateway
What you just learned
- A Transit Gateway replaces an N-squared mesh of peerings with a hub. Each VPC attaches once, and adding a spoke is one attachment rather than one peering per existing VPC.
- Association and propagation are two separate decisions, and they have different cardinality. An attachment is associated with at most one route table; it can propagate into many.
- Disabling both defaults means no default route table is created, so every path is one you wrote. Segmentation falls out of that: attachments associated with different route tables cannot reach each other unless you propagate across or add a static route.
- Filling the gateway’s table is only half the job. Each VPC still needs a route in its own table pointing the other CIDR at the gateway, or the traffic never leaves the VPC.
- Across accounts, RAM shares the gateway and each spoke account attaches its own VPC, but the route tables, associations and propagations stay with the owner. The hub team carries that routing work for every spoke account.
Next
The rest of the SA Pro lab arc is in the track’s README: guarded cross-region backups, weighted blue/green traffic shifting, tag-driven cost visibility, and a DMS change-data-capture migration.