Docs / Use Cases / Transit Gateway + VPC Peering

Transit Gateway + VPC Peering

Build an enterprise-style multi-VPC network on LocalEmu: four VPCs wired into a hub-and-spoke fabric with a Transit Gateway, plus one VPC peering connection, and verify the data plane with a 10-assertion connectivity matrix, including the non-transitive deny that peering enforces.

What the demo does. Provisions 4 VPCs with non-overlapping CIDRs (shared-services 10.0/16, prod-app 10.1/16, prod-data 10.2/16, mgmt 10.99/16), one Transit Gateway with default-RT association + propagation (joining the three production VPCs), one VPC peering connection between mgmt and shared-services, and four route-table entries that program the cross-VPC reachability. Launches one Ubuntu 22.04 EC2 per VPC and bootstraps a python3 -m http.server on the shared host via SSM. Then runs a 10-assertion connectivity matrix from inside each VPC: 4 TGW spoke-to-spoke pings, 2 VPC-peering pings (both directions), 2 non-transitive deny assertions (mgmt cannot reach prod-app/prod-data because peering is point-to-point), and 2 HTTP fetches over the fabric. Total runtime ~45 to 50 s. Requires EC2_VM_MANAGER=docker on the LocalEmu host. Source: 18-transit-gateway-demo/ in the examples repo.

Topology

                    +-----------------------+
                    |        mgmt           |
                    |     (10.99.0.0/16)    |
                    |  host-mgmt (bastion)  |
                    +-----------+-----------+
                                |
                                |  VPC peering
                                |  (auto-accepted)
                                |
                    +-----------v-----------+
                    |    shared-services    |
                    |     (10.0.0.0/16)     |
                    |  host-shared :8080    |
                    +-----------+-----------+
                                |
                                |  Transit Gateway
                                |  (default RT; assoc + prop enabled)
                                |
             +------------------+------------------+
             |                                     |
   +---------v----------+              +-----------v---------+
   |      prod-app      |              |      prod-data      |
   |   (10.1.0.0/16)    |              |    (10.2.0.0/16)    |
   |     host-app       |              |     host-data       |
   +--------------------+              +---------------------+
🌐

Real per-VPC networks

Each VPC gets its own Docker network. Instances inside a VPC share a subnet and real IPs.

🔀

TGW hub-and-spoke

Default-RT association + propagation: attach a VPC and spoke↔spoke just works.

🔗

Point-to-point peering

Two-way DNAT/SNAT under the hood, so packets actually cross to the peer container.

🚫

Non-transitive honored

Peering does not leak traffic through the peer's TGW attachment. Deny is enforced.

Step-by-Step Walkthrough

Step 1: Start LocalEmu with the EC2 Docker backend

Terminal
EC2_VM_MANAGER=docker localemu start

The Docker VM backend gives each EC2 instance a real Linux network namespace so cross-VPC routing actually traverses Docker bridges.

Step 2: Create the four VPCs and their subnets

infra/01_network.sh
$ awsx ec2 create-vpc --cidr-block 10.0.0.0/16 \
    --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=shared-services}]' \
    --query Vpc.VpcId --output text
vpc-db07a67d8807065d3

$ awsx ec2 create-vpc --cidr-block 10.1.0.0/16 ...  # prod-app
$ awsx ec2 create-vpc --cidr-block 10.2.0.0/16 ...  # prod-data
$ awsx ec2 create-vpc --cidr-block 10.99.0.0/16 ... # mgmt

# One /24 subnet per VPC in us-east-1a
$ awsx ec2 create-subnet --vpc-id $SHARED_VPC \
    --cidr-block 10.0.1.0/24 --availability-zone us-east-1a

Four non-overlapping CIDRs: 10.0/16 for shared-services, 10.1/16 and 10.2/16 for the two production spokes, 10.99/16 for the mgmt VPC.

Step 3: Stand up the Transit Gateway and attach three VPCs

infra/01_network.sh
$ awsx ec2 create-transit-gateway \
    --description enterprise-hub \
    --options 'AmazonSideAsn=64512,AutoAcceptSharedAttachments=enable,\
DefaultRouteTableAssociation=enable,DefaultRouteTablePropagation=enable' \
    --query TransitGateway.TransitGatewayId --output text
tgw-6db3bd5b57deb0f1d

$ awsx ec2 create-transit-gateway-vpc-attachment \
    --transit-gateway-id $TGW_ID --vpc-id $SHARED_VPC --subnet-ids $SHARED_SUBNET \
    --query TransitGatewayVpcAttachment.TransitGatewayAttachmentId --output text
tgw-attach-b43dfaa15582fca4c

# Repeat for prod-app and prod-data (3 attachments total)

With DefaultRouteTableAssociation and DefaultRouteTablePropagation enabled, each VPC attachment is auto-associated with the default RT and its CIDR is propagated, so spoke↔spoke routing needs no extra config on the TGW side.

Step 4: Add a point-to-point VPC peering between mgmt and shared-services

infra/01_network.sh
$ awsx ec2 create-vpc-peering-connection \
    --vpc-id $MGMT_VPC --peer-vpc-id $SHARED_VPC \
    --query VpcPeeringConnection.VpcPeeringConnectionId --output text
pcx-73dcb8b0d31ae4a6a

$ awsx ec2 accept-vpc-peering-connection \
    --vpc-peering-connection-id $PCX_ID
VpcPeeringConnection:
  Status:
    Code: active

The peering is auto-accepted with accept-vpc-peering-connection. LocalEmu installs the two-way NAT between the two peer VPCs so instances reach each other on their real private IPs.

Step 5: Wire the route tables

infra/01_network.sh
# TGW routes: shared/app/data reach each other via the TGW fabric
$ awsx ec2 create-route --route-table-id $SHARED_RT \
    --destination-cidr-block 10.1.0.0/16 --transit-gateway-id $TGW_ID
Return: true

$ awsx ec2 create-route --route-table-id $APP_RT \
    --destination-cidr-block 10.0.0.0/16 --transit-gateway-id $TGW_ID
Return: true

# Peering routes: mgmt <-> shared (non-transitive: no route to app or data)
$ awsx ec2 create-route --route-table-id $MGMT_RT \
    --destination-cidr-block 10.0.0.0/16 --vpc-peering-connection-id $PCX_ID
Return: true

Route targets are --transit-gateway-id for the TGW fabric and --vpc-peering-connection-id for the peering, identical to what Terraform or CloudFormation emits. mgmt gets a route only to shared's CIDR; it has no route to prod-app or prod-data, which is what enforces the non-transitive deny.

Step 6: Launch one EC2 per VPC

infra/02_compute.sh
$ awsx ec2 run-instances \
    --image-id ami-ubuntu-22.04 --instance-type t2.micro --count 1 \
    --subnet-id $SHARED_SUBNET --security-group-ids $SHARED_SG \
    --key-name enterprise-key \
    --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=host-shared}]' \
    --query Instances[0].InstanceId --output text
i-1b15f9cecd3e8a7ce

# Repeat for host-app, host-data, host-mgmt (4 instances total)

$ awsx ec2 wait instance-running --instance-ids $SHARED_INST $APP_INST $DATA_INST $MGMT_INST

$ awsx ec2 describe-instances --instance-ids $SHARED_INST $APP_INST $DATA_INST $MGMT_INST \
    --query 'Reservations[].Instances[].[Tags[?Key==`Name`]|[0].Value,PrivateIpAddress]' \
    --output text
host-shared  10.0.0.3
host-app     10.1.0.3
host-data    10.2.0.3
host-mgmt    10.99.0.3

Four t2.micro Ubuntu 22.04 instances, one per VPC. Each gets a real Docker container with its own IP in the VPC's network namespace.

Step 7: Bootstrap an HTTP service on shared-services via SSM

infra/03_service.sh
# SendCommand over SSM, no SSH keys, no open port
$ awsx ssm send-command --instance-ids $SHARED_INST \
    --document-name AWS-RunShellScript \
    --parameters 'commands=["echo hello from shared-services > /tmp/index.html",
                             "nohup python3 -m http.server 8080 --directory /tmp &"]'

$ awsx ssm send-command --instance-ids $SHARED_INST \
    --document-name AWS-RunShellScript \
    --parameters 'commands=["curl -sS http://127.0.0.1:8080/index.html"]'

StandardOutputContent: hello from shared-services

ssm send-command pushes commands into the instance, no SSH key, no open port 22. We start a python3 -m http.server 8080 in the background and confirm it binds.

Step 8: Run the 10-assertion connectivity matrix

infra/04_connectivity.sh
# --- TGW fabric (spoke &harr; spoke) ---
  &#x2713; [allow] app -> data (10.2.0.3)
  &#x2713; [allow] app -> shared (10.0.0.3)
  &#x2713; [allow] data -> shared (10.0.0.3)
  &#x2713; [allow] data -> app (10.1.0.3)

# --- VPC peering (mgmt &harr; shared) ---
  &#x2713; [allow] mgmt -> shared (10.0.0.3)
  &#x2713; [allow] shared -> mgmt (10.99.0.3)

# --- Non-transitive peering (must deny) ---
  &#x2713; [deny ] mgmt -> app (10.1.0.3) correctly blocked
  &#x2713; [deny ] mgmt -> data (10.2.0.3) correctly blocked

# --- Application call (curl via TGW) ---
  hello from shared-services
  &#x2713; prod-app read content from shared-services through TGW

# --- Operations call (curl via peering) ---
  hello from shared-services
  &#x2713; mgmt read content from shared-services through peering

Each row is a ping -c 2 (or a curl for the two application-layer assertions) issued from inside one VPC targeting the private IP of a peer VPC's instance. Allow must succeed; deny must fail. All ten pass.

Step 9: Tear down

Terminal
$ ./scripts/teardown.sh
== Cleanup ==
  Terminating EC2 instances
  Deleting TGW attachments
  Deleting VPC peering
  Deleting TGW
  Deleting subnets + VPCs
  Deleting key pair
  &#x2713; Cleanup complete.

Best-effort: swallows per-resource errors so a partial state from a failed step still gets cleaned up.

What the run proves

From → To Path Expected
prod-app → prod-dataTGW spoke↔spokeallow
prod-app → shared-servicesTGW spoke↔spokeallow
prod-data → shared-servicesTGW spoke↔spokeallow
prod-data → prod-appTGW spoke↔spokeallow
mgmt → shared-servicesVPC peeringallow
shared-services → mgmtVPC peering (reverse)allow
mgmt → prod-appno route (non-transitive)deny
mgmt → prod-datano route (non-transitive)deny
prod-app → :8080 on sharedTGW + HTTPallow (body returned)
mgmt → :8080 on sharedpeering + HTTPallow (body returned)

The non-transitive denies are the key: they are what makes AWS peering different from TGW attachments, and they are a frequent source of confusion when operators try to use peering as a shortcut into an otherwise TGW-only fabric. LocalEmu honors the rule, mgmt's route table simply has no entry for the production CIDRs, so packets destined for them are dropped before they ever reach the fabric.

How LocalEmu implements this

1. CreateVpc materializes a Docker network with the requested CIDR, instances in that VPC share that network namespace.
2. CreateTransitGateway creates a bridge network that TGW attachments plug into. With default-RT association enabled, any attached VPC gets reachability to every other attached VPC.
3. CreateTransitGatewayVpcAttachment joins a VPC's network to the TGW bridge and programs cross-VPC forwarding inside the instance containers.
4. CreateVpcPeeringConnection + AcceptVpcPeeringConnection installs a two-way DNAT/SNAT between the two peer VPCs so instances reach each other on their real private IPs.
5. CreateRoute with --transit-gateway-id or --vpc-peering-connection-id targets are persisted so describe-route-tables reports exactly what Terraform or CloudFormation expects.
6. SSM SendCommand executes arbitrary shell inside the instance via Docker exec, no SSH, no open port, no key management.
7. Because peering is a point-to-point bridge, traffic from mgmt cannot cross shared's TGW attachment. The non-transitive deny is enforced by the absence of a route, not an explicit block rule.

Full source: lib/common.sh

The shared helper library every infra/*.sh script sources. Wraps the AWS CLI to always hit the LocalEmu endpoint, ships an SSM exec helper that polls until the command finishes, and writes ids into .state/ids.env so later steps do not re-query.

#!/usr/bin/env bash
# Shared helpers for the tutorial scripts. Sourced by every infra/*.sh.

# Fail fast, preserve pipe exit codes, surface undefined variables.
set -Eeuo pipefail

# ---- LocalEmu endpoint ------------------------------------------------------

: "${AWS_ENDPOINT_URL:=http://localhost:4566}"
: "${AWS_REGION:=us-east-1}"
: "${AWS_DEFAULT_REGION:=us-east-1}"
: "${AWS_ACCESS_KEY_ID:=AKIAIOSFODNN7EXAMPLE}"
: "${AWS_SECRET_ACCESS_KEY:=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY}"
export AWS_ENDPOINT_URL AWS_REGION AWS_DEFAULT_REGION
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY

# ---- State ------------------------------------------------------------------
# Each script writes ids into $STATE_DIR/*.env so later scripts can source them
# without re-querying AWS.

TUTORIAL_ROOT="${TUTORIAL_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." &amp;&amp; pwd)}"
export TUTORIAL_ROOT
STATE_DIR="${STATE_DIR:-${TUTORIAL_ROOT}/.state}"
export STATE_DIR
mkdir -p "${STATE_DIR}"

# ---- Logging ----------------------------------------------------------------
# Prefix every line so mixed step output is readable when piped together.

_color() { printf '\033[%sm%s\033[0m\n' "$1" "$2"; }
log()   { _color '36' "[$(date +%H:%M:%S)] $*"; }
ok()    { _color '32' "  ✓ $*"; }
warn()  { _color '33' "  ! $*"; }
fail()  { _color '31' "  ✗ $*"; exit 1; }

# ---- AWS CLI wrapper --------------------------------------------------------
# Always targets the LocalEmu endpoint. Use instead of `aws` directly.

awsx() {
  aws --endpoint-url "${AWS_ENDPOINT_URL}" --region "${AWS_REGION}" "$@"
}

# ---- Retry primitive --------------------------------------------------------
# retry &lt;max_tries&gt; &lt;sleep_seconds&gt; &lt;cmd...&gt;
# Runs the command up to max_tries times with a short delay between attempts.
# Exits 0 on first success, non-zero if all attempts fail.

retry() {
  local max="$1"; local delay="$2"; shift 2
  local attempt=1 rc
  while true; do
    if "$@"; then return 0; fi
    rc=$?
    if (( attempt &gt;= max )); then
      return "${rc}"
    fi
    sleep "${delay}"
    attempt=$(( attempt + 1 ))
  done
}

# ---- Wait-for helpers -------------------------------------------------------

wait_instance_running() {
  local iid="$1"; local timeout="${2:-60}"
  local deadline=$(( $(date +%s) + timeout ))
  while (( $(date +%s) &lt; deadline )); do
    local s
    s=$(awsx ec2 describe-instances --instance-ids "${iid}" \
          --query 'Reservations[0].Instances[0].State.Name' --output text 2&gt;/dev/null || echo "?")
    [[ "${s}" == "running" ]] &amp;&amp; return 0
    sleep 1
  done
  return 1
}

# ---- SSM exec ---------------------------------------------------------------
# ssm_run &lt;instance-id&gt; &lt;shell-cmd&gt;
#   Sends a shell command to the instance via SSM. Prints stdout only when
#   the invocation finishes Success; non-zero exit with stderr otherwise.
#   Usage: out=$(ssm_run i-abc "hostname")

ssm_run() {
  local iid="$1"; shift
  local cmd="$*"
  local cid
  cid=$(awsx ssm send-command \
          --document-name AWS-RunShellScript \
          --instance-ids "${iid}" \
          --parameters "commands=[$(printf '%s' "${cmd}" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))')]" \
          --query 'Command.CommandId' --output text)
  local deadline=$(( $(date +%s) + 30 ))
  while (( $(date +%s) &lt; deadline )); do
    local status output rc
    status=$(awsx ssm get-command-invocation \
              --command-id "${cid}" --instance-id "${iid}" \
              --query 'Status' --output text 2&gt;/dev/null || echo "Pending")
    case "${status}" in
      Success)
        awsx ssm get-command-invocation \
          --command-id "${cid}" --instance-id "${iid}" \
          --query 'StandardOutputContent' --output text
        return 0
        ;;
      Failed|TimedOut|Cancelled)
        output=$(awsx ssm get-command-invocation \
                  --command-id "${cid}" --instance-id "${iid}" \
                  --query 'StandardErrorContent' --output text 2&gt;/dev/null || echo "")
        rc=$(awsx ssm get-command-invocation \
                  --command-id "${cid}" --instance-id "${iid}" \
                  --query 'ResponseCode' --output text 2&gt;/dev/null || echo "-1")
        echo "${output}" &gt;&amp;2
        return "${rc}"
        ;;
    esac
    sleep 1
  done
  echo "SSM timeout" &gt;&amp;2
  return 124
}

# ---- Kick-off helpers -------------------------------------------------------

need_healthy_localemu() {
  if ! curl -s -m 3 "${AWS_ENDPOINT_URL}/_localemu/health" &gt;/dev/null; then
    fail "LocalEmu is not responding at ${AWS_ENDPOINT_URL}. Start it first: 'localemu start'"
  fi
}

state_set() { printf 'export %s=%q\n' "$1" "$2" &gt;&gt; "${STATE_DIR}/ids.env"; }
state_load() { [[ -f "${STATE_DIR}/ids.env" ]] &amp;&amp; source "${STATE_DIR}/ids.env" || true; }

Full demo output

Captured from a clean run on LocalEmu v0.1.dev133 with EC2_VM_MANAGER=docker. ANSI color codes stripped for readability.

[15:27:03] == Step 01: network plane ==
[15:27:03] Creating VPC shared-services (10.0.0.0/16)
  ✓ shared-services vpc-7aeeed09f04eddd47, subnet subnet-f5a28cd9b3d9fede7
[15:27:05] Creating VPC prod-app (10.1.0.0/16)
  ✓ prod-app vpc-bd3a241ed2df7ea07, subnet subnet-cf153dd34efd28b25
[15:27:05] Creating VPC prod-data (10.2.0.0/16)
  ✓ prod-data vpc-8b8f73129e1836831, subnet subnet-c217a0db499d48e36
[15:27:06] Creating VPC mgmt (10.99.0.0/16)
  ✓ mgmt vpc-caae142aaf49a4176, subnet subnet-5a3ba71128217934e
[15:27:07] Creating security groups
  ✓ SGs: shared=sg-60e10a2368df0b7b6 app=sg-4980145f0dd30d5f7 data=sg-c9d9a35d108b73610 mgmt=sg-379e601d251db17fb
[15:27:11] Creating Transit Gateway
  ✓ TGW tgw-a4fa197f5a23eb3cc
[15:27:12] Attaching shared-services, prod-app, prod-data to TGW
  ✓ TGW attachments: shared=tgw-attach-7dc24112abb013f41 app=tgw-attach-0a3c22e178c0a9859 data=tgw-attach-6757129b59573df97
[15:27:13] Creating VPC peering mgmt &lt;-&gt; shared-services
  ✓ VPC peering pcx-d8cde14bc38c38021 established
[15:27:13] Programming routing tables
  ✓ Routes wired
[15:27:17] Network plane ready. Ids in ./.state/ids.env
[15:27:17] == Step 02: compute ==
[15:27:17] Creating key pair enterprise-key-30822
  ✓ Key pair created (pem saved to ./.state/enterprise-key-30822.pem)
[15:27:18] Launching shared-services host
[15:27:19] Launching prod-app host
[15:27:21] Launching prod-data host
[15:27:23] Launching mgmt host
[15:27:24] Waiting for instances to reach running state
  ✓ shared i-e60d2176a266a0329 running
  ✓ app i-b8230bc5c5981380b running
  ✓ data i-242b4aa4383a95e83 running
  ✓ mgmt i-628446ba9113e4d4f running
  ✓ Private IPs:
      shared=10.0.0.3  app=10.1.0.3  data=10.2.0.3  mgmt=10.99.0.3
[15:27:27] Compute plane ready.
[15:27:27] == Step 03: bring up HTTP service on shared-services ==
[15:27:27] Writing content
  ✓ Content written
[15:27:28] Starting http server on :8080 (background, via nohup)
      SERVER_UP
  ✓ Server listening on 0.0.0.0:8080
[15:27:30] Self-check: curl from shared-services itself
      hello from shared-services (10.0.1.0/24)
  ✓ Service healthy
[15:27:31] == Step 04: connectivity matrix ==
[15:27:31] -- TGW fabric (spoke ↔ spoke) --
  ✓ [allow] app -&gt; data (10.2.0.3)
  ✓ [allow] app -&gt; shared (10.0.0.3)
  ✓ [allow] data -&gt; shared (10.0.0.3)
  ✓ [allow] data -&gt; app (10.1.0.3)
[15:27:40] -- VPC peering mgmt ↔ shared --
  ✓ [allow] mgmt -&gt; shared (10.0.0.3)
  ✓ [allow] shared -&gt; mgmt (10.99.0.3)
[15:27:44] -- Non-transitive peering (must deny) --
  ✓ [deny ] mgmt -&gt; app (10.1.0.3) correctly blocked
  ✓ [deny ] mgmt -&gt; data (10.2.0.3) correctly blocked
[15:27:54] -- Application call (curl via TGW) --
      hello from shared-services (10.0.1.0/24)
  ✓ prod-app read content from shared-services through TGW
[15:27:54] -- Operations call (curl via peering) --
      hello from shared-services (10.0.1.0/24)
  ✓ mgmt read content from shared-services through peering
[15:27:55] All connectivity assertions passed.

→ demo complete. Run scripts/teardown.sh when done.

Files

Repository layout you get when you clone the examples repo.

18-transit-gateway-demo/
├── README.md
├── scripts/
│   ├── demo.sh
│   └── teardown.sh
├── lib/
│   └── common.sh         (awsx wrapper, ssm_run, state helpers)
└── infra/
    ├── 01_network.sh     (VPCs, subnets, SGs, TGW, peering, routes)
    ├── 02_compute.sh     (one EC2 per VPC)
    ├── 03_service.sh     (python http.server on shared-services)
    └── 04_connectivity.sh (the 10-assertion matrix)