Exam Room · Advanced Solutions Architect

Lab: Route Between VPCs With a Transit Gateway

· 16 min read

Advanced Cloud Architecture · part of The Exam Room

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 Route resource fails to create. Check the DependsOn. 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.sh proves 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.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.