<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shadow Scribe</title>
    <description>The latest articles on DEV Community by Shadow Scribe (@shadow_scribe_a39af3d80a2).</description>
    <link>https://dev.to/shadow_scribe_a39af3d80a2</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4145908%2F2500b8ed-5d8f-46b8-b270-237cb1c2a18a.png</url>
      <title>DEV Community: Shadow Scribe</title>
      <link>https://dev.to/shadow_scribe_a39af3d80a2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shadow_scribe_a39af3d80a2"/>
    <language>en</language>
    <item>
      <title>I Gave an EC2 Instance Zero Internet and It Still Uploaded to S3</title>
      <dc:creator>Shadow Scribe</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:37:27 +0000</pubDate>
      <link>https://dev.to/shadow_scribe_a39af3d80a2/i-gave-an-ec2-instance-zero-internet-and-it-still-uploaded-to-s3-3ijn</link>
      <guid>https://dev.to/shadow_scribe_a39af3d80a2/i-gave-an-ec2-instance-zero-internet-and-it-still-uploaded-to-s3-3ijn</guid>
      <description>&lt;h2&gt;
  
  
  The challenge
&lt;/h2&gt;

&lt;p&gt;The client's requirement fit in one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The private backend must talk to Amazon S3 without ever touching the public internet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The environment had two VPCs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;custom VPC&lt;/strong&gt; with a private &lt;strong&gt;EC2&lt;/strong&gt; instance. It had no &lt;strong&gt;Internet Gateway&lt;/strong&gt; and no &lt;strong&gt;NAT Gateway&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;default VPC&lt;/strong&gt; with a public-facing EC2 instance acting as a &lt;strong&gt;bastion (jump host)&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The plan was to SSH into the bastion, hop to the private machine, and run &lt;code&gt;aws s3 cp&lt;/code&gt; from there while keeping every packet on the AWS network. That took five pieces working together: &lt;strong&gt;VPC Peering&lt;/strong&gt;, &lt;strong&gt;route tables&lt;/strong&gt;, &lt;strong&gt;security groups&lt;/strong&gt;, an &lt;strong&gt;IAM role&lt;/strong&gt; and an &lt;strong&gt;S3 Gateway Endpoint&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;![Full architecture: default VPC with bastion, custom VPC with private EC2, peering link, IAM role and S3 Gateway Endpoint]&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr3nn67eiiz7724r2tpyj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr3nn67eiiz7724r2tpyj.png" alt="Full architecture: default VPC with bastion, custom VPC with private EC2, peering link, IAM role and S3 Gateway Endpoint" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The diagram above is the whole design. The rest of this post builds it one layer at a time and shows where each layer can fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roadblocks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. There was no road out.&lt;/strong&gt;&lt;br&gt;
With no IGW and no NAT, the private subnet has no default route. Any call to the S3 public endpoint just hangs until it times out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Peering is easy to create and easy to get wrong.&lt;/strong&gt;&lt;br&gt;
A &lt;strong&gt;VPC Peering&lt;/strong&gt; connection (&lt;code&gt;pcx-...&lt;/code&gt;) is only a handshake. Traffic flows only when both route tables point at it. My first ping test looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ping 172.31.22.46
8 packets transmitted, 0 received, 100% packet loss, &lt;span class="nb"&gt;time &lt;/span&gt;7274ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common cause of this is a one-sided route. The request reaches the target, but the reply has no route back, so ping reports nothing received.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Locking down security groups without locking yourself out.&lt;/strong&gt;&lt;br&gt;
Both instances started with &lt;code&gt;0.0.0.0/0&lt;/code&gt; on SSH and ICMP, and the console warns about this for good reason. Replacing those rules with &lt;code&gt;/32&lt;/code&gt; entries means one typo can cut off your own access, so the order of changes matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Credentials.&lt;/strong&gt;&lt;br&gt;
Running &lt;code&gt;aws configure&lt;/code&gt; and pasting access keys onto an EC2 instance is exactly what a secure design should avoid.&lt;/p&gt;
&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Create and accept the VPC Peering connection
&lt;/h3&gt;

&lt;p&gt;Request the connection from one VPC and &lt;strong&gt;accept&lt;/strong&gt; it from the other. It is not active until accepted, and that step is easy to forget.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2: Build symmetric route tables
&lt;/h3&gt;

&lt;p&gt;This was the fix for the 100% packet loss. Each side needs a route to the other:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route table&lt;/th&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Custom VPC (private route table)&lt;/td&gt;
&lt;td&gt;Default VPC CIDR&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pcx-xxxxxxxx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default VPC (main route table)&lt;/td&gt;
&lt;td&gt;Custom VPC CIDR&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pcx-xxxxxxxx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Edit the route table that is actually associated with the subnet. Across two VPCs there are several route tables, including main tables with no explicit associations, so it is easy to edit the wrong one.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Harden security groups to a /32 micro-perimeter
&lt;/h3&gt;

&lt;p&gt;Each instance accepts &lt;strong&gt;SSH (22)&lt;/strong&gt; and &lt;strong&gt;ICMP&lt;/strong&gt; only from the other instance's exact private IP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Private instance SG: allow from &lt;code&gt;&amp;lt;bastion-private-ip&amp;gt;/32&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bastion SG: allow from &lt;code&gt;&amp;lt;private-instance-ip&amp;gt;/32&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do this after peering and routing are confirmed working, and keep one SSH session open while you change the rules. Use private IPs here. Traffic across the peering connection arrives from the peer's private address, not its public one.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 4: Attach an IAM role instead of access keys
&lt;/h3&gt;

&lt;p&gt;Create an &lt;strong&gt;IAM role&lt;/strong&gt; for EC2 with S3 permissions and attach it to the private instance as an instance profile. The AWS CLI picks up temporary credentials from the instance metadata service automatically, so there is nothing to leak.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I used &lt;code&gt;AmazonS3FullAccess&lt;/code&gt; to validate the architecture. For production, scope it down:&lt;br&gt;
&lt;/p&gt;


&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::YOUR-BUCKET"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::YOUR-BUCKET/*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Deploy an S3 Gateway Endpoint
&lt;/h3&gt;

&lt;p&gt;In &lt;strong&gt;VPC console &amp;gt; Endpoints&lt;/strong&gt;, search for the S3 service and choose the &lt;strong&gt;Gateway&lt;/strong&gt; type: &lt;code&gt;com.amazonaws.us-east-1.s3&lt;/code&gt;. Associate it with the &lt;strong&gt;private subnet's route table&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;AWS adds a route that sends S3 traffic (via a managed prefix list) to the endpoint, so S3 requests stay on the AWS network. The bucket must be in the same Region as the endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a request can fail, hop by hop
&lt;/h2&gt;

&lt;p&gt;Each hop has its own checks and its own symptom. This map helped me debug faster than guessing:&lt;/p&gt;

&lt;p&gt;![Hop-by-hop map from laptop to S3 showing the checks that must pass at each hop and the symptom when one fails]&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00w86lh679bxrma1b9dm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00w86lh679bxrma1b9dm.png" alt="Hop-by-hop map from laptop to S3 showing the checks that must pass at each hop and the symptom when one fails" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A short rule of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timeout or 100% packet loss&lt;/strong&gt; means a network problem: peering, routes or security groups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AccessDenied (403)&lt;/strong&gt; means an identity problem: the role policy or bucket policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unable to locate credentials&lt;/strong&gt; means no IAM role is attached to the instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The outcome
&lt;/h2&gt;

&lt;p&gt;Once the routes were symmetric, the same ping that had failed worked immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ping 172.31.22.46
64 bytes from 172.31.22.46: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;127 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.877 ms
64 bytes from 172.31.22.46: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;127 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.508 ms
...
6 packets transmitted, 6 received, 0% packet loss
rtt min/avg/max/mdev &lt;span class="o"&gt;=&lt;/span&gt; 0.490/0.564/0.877/0.139 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;![Before and after ping: 100% packet loss with one-sided routes versus 0% packet loss with symmetric routes]&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7at4nsvm8rrdk8vjd4ed.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7at4nsvm8rrdk8vjd4ed.png" alt="Before and after ping: 100% packet loss with one-sided routes versus 0% packet loss with symmetric routes" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sub-millisecond latency and &lt;strong&gt;0% packet loss&lt;/strong&gt; confirmed the peering was healthy. Then the real test, from the private instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;aws s3 &lt;span class="nb"&gt;ls
&lt;/span&gt;2026-10-17 08:02:05 your-bucket-name

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'this is my first private machine upload'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; test.txt
&lt;span class="nv"&gt;$ &lt;/span&gt;aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;test.txt s3://your-bucket-name/
upload: ./test.txt to s3://your-bucket-name/test.txt

&lt;span class="nv"&gt;$ &lt;/span&gt;aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://your-bucket-name/
2026-10-17 08:04:59         75 test.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI listed buckets and uploaded the file. Authentication came from the &lt;strong&gt;IAM role&lt;/strong&gt;, and the request was routed through the &lt;strong&gt;Gateway Endpoint&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What the design delivers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No static credentials anywhere.&lt;/strong&gt; Temporary role credentials only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tiny attack surface.&lt;/strong&gt; SSH and ICMP are allowed only between two exact private IPs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A private S3 path.&lt;/strong&gt; Traffic to S3 stays on the AWS network instead of crossing the public internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower cost.&lt;/strong&gt; Gateway Endpoints for S3 carry no additional endpoint charge, unlike Interface Endpoints (PrivateLink), which bill hourly and per GB.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Debugging checklist
&lt;/h2&gt;

&lt;p&gt;Work through these in order. A failure early in the chain hides everything after it:&lt;/p&gt;

&lt;p&gt;![Six-step checklist table with what to verify, the symptom if wrong, and the fix]&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdvx9j18i68wk21djcrpk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdvx9j18i68wk21djcrpk.png" alt="Six-step checklist table with what to verify, the symptom if wrong, and the fix" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the peering connection &lt;strong&gt;Active&lt;/strong&gt; (accepted)?&lt;/li&gt;
&lt;li&gt;Do &lt;strong&gt;both&lt;/strong&gt; route tables have the peer CIDR pointing at &lt;code&gt;pcx-&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Is each route table &lt;strong&gt;associated with the right subnet&lt;/strong&gt;?&lt;/li&gt;
&lt;li&gt;Do the security groups allow the peer's &lt;strong&gt;private&lt;/strong&gt; IP as &lt;code&gt;/32&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Is the Gateway Endpoint attached to the &lt;strong&gt;private route table&lt;/strong&gt;, in the bucket's Region?&lt;/li&gt;
&lt;li&gt;Does the instance have an &lt;strong&gt;IAM role&lt;/strong&gt; whose policy allows the S3 actions you call?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Most "it doesn't work" moments in AWS networking come down to routes and security groups. With peering, creating the connection is the easy part, and the route tables on both sides are what make it work. When something fails silently, check both directions before blaming the service.&lt;/p&gt;

&lt;p&gt;Have you run into cross-VPC routing problems like this? &lt;/p&gt;

&lt;p&gt;Tell me in the comments: do you use peering, Transit Gateway, or PrivateLink for this kind of setup?&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cloud</category>
      <category>security</category>
    </item>
    <item>
      <title>The Mesh Trap: Why Your AWS Network Will Outgrow VPC Peering</title>
      <dc:creator>Shadow Scribe</dc:creator>
      <pubDate>Tue, 06 Oct 2026 16:46:58 +0000</pubDate>
      <link>https://dev.to/shadow_scribe_a39af3d80a2/the-mesh-trap-why-your-aws-network-will-outgrow-vpc-peering-1jf7</link>
      <guid>https://dev.to/shadow_scribe_a39af3d80a2/the-mesh-trap-why-your-aws-network-will-outgrow-vpc-peering-1jf7</guid>
      <description>&lt;p&gt;&lt;em&gt;A hands-on build, a deliberate failure, and the cost, resilience and design trade-offs that never make it onto the architecture diagram.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Nobody designs a bad cloud network. They accumulate one reasonable decision at a time.&lt;/p&gt;

&lt;p&gt;It starts with two VPCs and one peering connection, and it works beautifully. Then a third VPC appears for a new team. Then a fourth for a new environment. Six months later you own a web of routes that nobody can fully draw from memory, and a single missed entry can take down a service on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;I wanted to see exactly where that curve bends, so I built both designs in AWS: a peering topology, and a hub-and-spoke network around a Transit Gateway. I tested connectivity, broke it on purpose, and then did the part most tutorials skip: I looked hard at what each choice costs, how it fails, and when it is the wrong answer.&lt;/p&gt;

&lt;p&gt;Here is what I learned, including the places where the "obvious" upgrade is not the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Peering deserves more respect than it gets
&lt;/h2&gt;

&lt;p&gt;Before the case against peering, the case for it, because it is a strong one.&lt;/p&gt;

&lt;p&gt;A VPC peering connection is a direct, private link between two VPCs. AWS builds it on the existing VPC infrastructure rather than on a gateway or separate appliance, and its own documentation states there is no single point of failure and no bandwidth bottleneck. There is also no charge to create one, and data that stays within an Availability Zone is free.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;For a web tier talking to a database tier, peering is close to perfect. I would still choose it there.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So why leave it? Because of one design property that quietly becomes your biggest problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: peering is non-transitive
&lt;/h2&gt;

&lt;p&gt;If VPC A is peered with B, and B is peered with C, &lt;strong&gt;A still cannot reach C&lt;/strong&gt;. Connectivity never passes through a middleman. The only way to connect everything is a full mesh, where every VPC peers with every other VPC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connections = N × (N − 1) ÷ 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;VPCs&lt;/th&gt;
&lt;th&gt;Peering connections&lt;/th&gt;
&lt;th&gt;Route entries to maintain*&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;1,225&lt;/td&gt;
&lt;td&gt;2,450&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;4,950&lt;/td&gt;
&lt;td&gt;9,900&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;*At least one route per side of every connection, before accounting for multiple subnets and route tables.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Growth here is quadratic, not linear, which is why a network that felt effortless at five VPCs feels unmanageable at twenty. And the connection count is not even the real cost. The real cost is the human one: every new VPC means touching every existing VPC, and every manual touch is a chance to get a route wrong. AWS also caps the number of active peering connections per VPC, so a mesh eventually hits a hard wall as well as a human one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: put a router in the middle
&lt;/h2&gt;

&lt;p&gt;AWS Transit Gateway replaces the mesh with hub-and-spoke. Each VPC attaches once to a central, regional router, and the router handles traffic between them.&lt;/p&gt;

&lt;p&gt;The operational shift is the whole point. Adding the 11th VPC stops meaning &lt;em&gt;"update ten other VPCs"&lt;/em&gt; and starts meaning &lt;em&gt;"attach it to the hub and point its routes there."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;A three-VPC lab around one Transit Gateway:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPC 1, the bastion:&lt;/strong&gt; a public subnet with an Internet Gateway, the only way in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPC 2 and VPC 3, the spokes:&lt;/strong&gt; fully private, with no direct internet access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three VPCs attached to the gateway, and I pointed each VPC's route tables at the hub. To verify it, I SSH'd into the bastion and pinged the private instances in both spokes. Traffic crossed the hub with 0% packet loss.&lt;/p&gt;

&lt;p&gt;That is the classic secure-access pattern: one hardened entry point, private workloads behind it, a central router in between. But getting it working was the easy part. The better lesson came from breaking it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment that taught me the most
&lt;/h2&gt;

&lt;p&gt;I attached VPC 3 to the Transit Gateway but deliberately left the return route to VPC 1 out of its route table. The result: an immediate timeout.&lt;/p&gt;

&lt;p&gt;The attachment existed. The gateway was healthy. But packets had no path back, so the connection failed. Two lessons came out of that single missing line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Attachment is not connectivity.&lt;/strong&gt; Being attached to the hub means nothing until routes on both sides say where traffic goes. When a connection times out, check the return path first. It is the most common culprit, and the one people check last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing is your security boundary.&lt;/strong&gt; You can attach dozens of VPCs to one Transit Gateway and still keep dev and prod unable to reach each other, purely through which routes exist. In production you do this with separate Transit Gateway route tables, controlling which attachments associate with and propagate into each one. Isolation becomes a deliberate design decision, not a happy accident of topology.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Debugging tip worth remembering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A silent timeout on a Transit Gateway network is usually a routing gap, not a firewall. Walk the path in order: source subnet route table, Transit Gateway route table, destination subnet route table, and finally the return path. Then check security groups and network ACLs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The cost question nobody answers honestly
&lt;/h2&gt;

&lt;p&gt;"Transit Gateway is better" is easy to say. "Transit Gateway is worth it for you" requires numbers. Using current AWS list pricing for US East (N. Virginia):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;VPC Peering&lt;/th&gt;
&lt;th&gt;Transit Gateway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fixed cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;$0.05 per attachment-hour (about $36.50/month each)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free within an AZ; $0.01/GB in each direction across AZs&lt;/td&gt;
&lt;td&gt;$0.02/GB processed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3 VPCs, fixed cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;About $110/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;10 VPCs, fixed cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;About $365/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;10 TB through the hub&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Depends on AZ placement&lt;/td&gt;
&lt;td&gt;About $200 in processing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Prices vary by Region and change over time. Confirm on the AWS pricing pages before relying on any figure.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three things stand out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The fixed fee is small next to the cost of getting it wrong.&lt;/strong&gt; A few hundred dollars a month is less than a single day of an engineer untangling a routing outage. Judge it against operational risk, not against zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data charge is the one that scales.&lt;/strong&gt; If two VPCs exchange terabytes constantly, such as database replication or analytics pipelines, that per-GB processing fee adds up. Those pairs are candidates for a direct peering link alongside the hub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared connectivity changes the math.&lt;/strong&gt; One VPN or Direct Connect attachment on the gateway serves every attached VPC, instead of building that link repeatedly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pragmatic answer is often a hybrid: Transit Gateway as the backbone, with selective peering for the few heavy, latency-sensitive pairs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Availability and performance: what really changes
&lt;/h2&gt;

&lt;p&gt;Peering has a reputation for resilience, and it is earned: no gateway means no central component to fail. Moving to a hub does introduce a shared dependency, so it deserves a clear-eyed look.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capacity is not the concern.&lt;/strong&gt; A VPC attachment supports up to 100 Gbps per Availability Zone and up to 7.5 million packets per second by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attach in every AZ you use.&lt;/strong&gt; A Transit Gateway only routes to Availability Zones where the VPC has an attachment subnet. Put one in each AZ your workloads run in, or traffic to the missing zone has nowhere to go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the MTU.&lt;/strong&gt; A Transit Gateway supports an MTU of 8500 bytes between VPCs. When migrating from peering, a size mismatch can drop jumbo packets on asymmetric paths, so update both VPCs together and test large transfers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralization concentrates risk.&lt;/strong&gt; One wrong route in a shared table can affect many VPCs at once. Manage the gateway as code, review every change, and use blackhole routes to block traffic deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The hidden factors that decide real-world success
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IP planning comes first.&lt;/strong&gt; Neither approach can route between overlapping CIDR ranges. A clean, non-overlapping address plan is the cheapest decision you will ever make, and the most painful to fix later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-account is where it shines.&lt;/strong&gt; A Transit Gateway can be shared across accounts with AWS Resource Access Manager, so each team attaches its own VPC to a central network owned by the platform team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Central inspection becomes possible.&lt;/strong&gt; Hubs let you steer traffic through a shared firewall layer instead of duplicating security tooling in every VPC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility improves.&lt;/strong&gt; One routing hub means one place to look, log and audit, instead of dozens of point-to-point links.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which should you choose?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Stay with VPC Peering&lt;/th&gt;
&lt;th&gt;Move to Transit Gateway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VPC count&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 to 4, and stable&lt;/td&gt;
&lt;td&gt;5+, or growing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Traffic pattern&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Few pairs, heavy data volume&lt;/td&gt;
&lt;td&gt;Many VPCs, mixed traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Team shape&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One team, one account&lt;/td&gt;
&lt;td&gt;Multiple teams or accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Connectivity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;VPC to VPC only&lt;/td&gt;
&lt;td&gt;Shared VPN or Direct Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Isolation needs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Simple&lt;/td&gt;
&lt;td&gt;Segmented environments (dev, prod, shared)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Priority&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lowest cost&lt;/td&gt;
&lt;td&gt;Lowest operational risk&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A simple rule:&lt;/strong&gt; if you can already see your fourth, fifth, or tenth VPC coming, design the hub now. Migrating a live mesh means changing routes on running production traffic. Starting with a hub means adding an attachment.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are migrating, do it in this order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit CIDRs and routes.&lt;/strong&gt; Find overlaps and document every existing peering path before touching anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the gateway alongside the mesh.&lt;/strong&gt; Attach the VPCs, create the route tables, and leave live traffic untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cut over one pair at a time.&lt;/strong&gt; Update both sides together, validate with pings, flow logs and a large transfer, then move on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retire the peering links last.&lt;/strong&gt; Remove old routes and connections only after the new path has proven itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Peering solves today's problem. Transit Gateway solves the one you will have when your environment doubles.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Network design is cheap to get right early and expensive to fix later. The mesh does not announce itself. It simply grows until the day it hurts. If you are running more VPCs than you can draw from memory, treat that as your signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I would like to hear from you: are you running a peering mesh today, or have you already moved to a hub-and-spoke design? What finally pushed you to change?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3gxyudvwgqu0z1k55n3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3gxyudvwgqu0z1k55n3.jpg" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>infrastructure</category>
      <category>networking</category>
    </item>
  </channel>
</rss>
