<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Networking on Omegion</title><link>https://omegion.dev/tags/networking/</link><description>Recent content in Networking on Omegion</description><generator>Hugo</generator><language>en-us</language><copyright>Omegion</copyright><lastBuildDate>Wed, 26 Aug 2026 19:26:28 +0200</lastBuildDate><atom:link href="https://omegion.dev/tags/networking/index.xml" rel="self" type="application/rss+xml"/><item><title>When VPC Peering Stops Scaling: Moving to Transit Gateway</title><link>https://omegion.dev/2026/05/when-vpc-peering-stops-scaling-moving-to-transit-gateway/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/05/when-vpc-peering-stops-scaling-moving-to-transit-gateway/</guid><description>Why VPC peering stops scaling once Kubernetes clusters, a shared datastore, and Lambda functions all need their own VPC, and what moving to a Transit Gateway actually costs instead.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>When I started at my current job, we were running self-managed, bare-metal
Kubernetes clusters on kOps, partway through a migration to EKS. That
migration was also our chance to redesign the networking. Under kOps, a
handful of clusters shared a VPC, roughly one VPC for every three or four
clusters, so around 80 clusters came out to a manageable number of VPCs.
For EKS we wanted a tighter boundary: one VPC per cluster, so a cluster&rsquo;s
blast radius stopped at its own network. That gave us the isolation we
wanted, and a new problem we hadn&rsquo;t planned for. Eighty clusters now meant
close to eighty VPCs, and VPC peering doesn&rsquo;t stay manageable at that size.</p>
<h2 id="the-mesh-problem">The mesh problem</h2>
<p>VPC peering connections are point to point. There&rsquo;s no such thing as
transitive peering, so if VPC A peers with B and B peers with C, A still
can&rsquo;t reach C unless you peer A and C directly too. That means the number of
connections you need grows with the number of <em>pairs</em> of VPCs, not the
number of VPCs.</p>
<p>With 4 VPCs that&rsquo;s 6 peering connections. With 10 VPCs it&rsquo;s 45. Worse, that
growth lands on you one VPC at a time: every new VPC needs its own peering
connection to every VPC that&rsquo;s already there.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "%%{init: {\n  \"theme\": \"base\",\n  \"themeVariables\": {\n    \"primaryColor\": \"#FFF7ED\",\n    \"primaryBorderColor\": \"#FF9900\",\n    \"primaryTextColor\": \"#232F3E\",\n    \"lineColor\": \"#FF9900\"\n  }\n}}%%\n\nblock-beta\n    columns 5\n\n    space V1([\"VPC 1\"]) V2([\"VPC 2\"]) V3([\"VPC 3\"]) space\n    V10([\"VPC 10\"]) space:3 V4([\"VPC 4\"])\n    V9([\"VPC 9\"]) space:3 V5([\"VPC 5\"])\n    space V8([\"VPC 8\"]) V7([\"VPC 7\"]) V6([\"VPC 6\"]) space\n\n    V1 --- V2\n    V1 --- V3\n    V1 --- V4\n    V1 --- V5\n    V1 --- V6\n    V1 --- V7\n    V1 --- V8\n    V1 --- V9\n    V1 --- V10\n\n    V2 --- V3\n    V2 --- V4\n    V2 --- V5\n    V2 --- V6\n    V2 --- V7\n    V2 --- V8\n    V2 --- V9\n    V2 --- V10\n\n    V3 --- V4\n    V3 --- V5\n    V3 --- V6\n    V3 --- V7\n    V3 --- V8\n    V3 --- V9\n    V3 --- V10\n\n    V4 --- V5\n    V4 --- V6\n    V4 --- V7\n    V4 --- V8\n    V4 --- V9\n    V4 --- V10\n\n    V5 --- V6\n    V5 --- V7\n    V5 --- V8\n    V5 --- V9\n    V5 --- V10\n\n    V6 --- V7\n    V6 --- V8\n    V6 --- V9\n    V6 --- V10\n\n    V7 --- V8\n    V7 --- V9\n    V7 --- V10\n\n    V8 --- V9\n    V8 --- V10\n\n    V9 --- V10\n\n    classDef vpc fill:#FFF7ED,stroke:#FF9900,stroke-width:2px,color:#232F3E;\n    class V1,V2,V3,V4,V5,V6,V7,V8,V9,V10 vpc;";</script>

<p>Add a fifth VPC to a set of four and that&rsquo;s four new peering connections,
each with its own route table entries on both sides and its own security
group rules, not one. Multiply that by eighty clusters and you stop
reasoning about the mesh as a diagram at all, you just grep route tables
when something can&rsquo;t connect.</p>
<h2 id="where-transit-gateway-fits">Where Transit Gateway fits</h2>
<p>A <a href="https://aws.amazon.com/transit-gateway/" target="_blank" rel="noopener noreferrer">Transit Gateway</a>
 is a regional
router that VPCs attach to instead of attaching to each other. Each VPC
needs exactly one attachment, and traffic between any two VPCs routes
through the gateway.</p>
<pre class="mermaid" id="mermaid-1"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-1"] = "architecture-beta\n    service tgw(internet)[Transit Gateway]\n    service vpcA(cloud)[VPC A]\n    service vpcB(cloud)[VPC B]\n    service vpcC(cloud)[VPC C]\n    service vpcD(cloud)[VPC D]\n\n    vpcA:B -- T:tgw\n    vpcB:R -- L:tgw\n    tgw:R -- L:vpcC\n    tgw:B -- T:vpcD";</script>

<p>Adding a VPC now means one attachment and one route, not one new connection
per existing VPC. The route tables live on the gateway, in one place,
instead of scattered across every VPC that needs to reach every other one.</p>
<p>The attachment itself is a small piece of Terraform:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;Cluster VPC connectivity&#34;</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway_vpc_attachment&#34; &#34;cluster&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_id</span> <span class="o">=</span> <span class="k">aws_ec2_transit_gateway</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  vpc_id</span>             <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">vpc_id</span>
</span></span><span class="line"><span class="cl"><span class="n">  subnet_ids</span>         <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">private_subnet_ids</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Every cluster VPC gets one of these attachments, and a route pointing at the
transit gateway for the CIDR ranges of the other VPCs. That&rsquo;s the whole
change, per VPC, from here on.</p>
<h2 id="the-same-problem-for-datastores-and-lambdas">The same problem for datastores and Lambdas</h2>
<p>Cluster VPCs peering with each other isn&rsquo;t the only traffic pushing on
this. Datastores usually end up isolated into their own VPC too, an RDS
instance behind its own subnets and security groups, and every cluster that
reads from it needs a path in. A Lambda function calling a service running
inside a cluster needs the same kind of path, just from the other
direction.</p>
<p>Neither of those is a new cluster added to the mesh, but each one is a new
participant, and peering treats it the same way it treats a cluster VPC:
one more VPC, one more connection to every VPC it needs to reach. A shared
RDS VPC that every one of 80 cluster VPCs reads from picks up all 80 of
those connections itself. Its route table and its security groups end up
tracking every consumer that was ever added.</p>
<pre class="mermaid" id="mermaid-3"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-3"] = "architecture-beta\n    service tgw(internet)[Transit Gateway]\n\n    group clusterA(cloud)[Cluster VPC]\n    group clusterB(cloud)[Cluster VPC]\n    group data(cloud)[Datastore VPC]\n    group fnGroup(cloud)[Lambda VPC]\n\n    service c1(server)[Workload] in clusterA\n    service c2(server)[Workload] in clusterB\n    service db(database)[RDS] in data\n    service fn(server)[Lambda] in fnGroup\n\n    fn:B -- T:tgw\n    c1:L -- R:tgw\n    c2:T -- B:tgw\n    db:R -- L:tgw";</script>

<p>Put that RDS VPC on the Transit Gateway instead and it&rsquo;s one more
attachment, same as a cluster. A Lambda VPC is the same story. Reachability
now comes down to
<a href="https://docs.aws.amazon.com/vpc/latest/tgw/tgw-route-tables.html" target="_blank" rel="noopener noreferrer">Transit Gateway route tables</a>
,
not which VPCs happen to have a peering connection to the datastore: an
attachment only gets a route to it if it&rsquo;s associated with a route table
that carries one.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">hcl</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway_route_table&#34; &#34;datastores&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_id</span> <span class="o">=</span> <span class="k">aws_ec2_transit_gateway</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway_route_table_association&#34; &#34;rds&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_attachment_id</span>  <span class="o">=</span> <span class="k">aws_ec2_transit_gateway_vpc_attachment</span><span class="p">.</span><span class="k">rds</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_route_table_id</span> <span class="o">=</span> <span class="k">aws_ec2_transit_gateway_route_table</span><span class="p">.</span><span class="k">datastores</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_ec2_transit_gateway_route_table_association&#34; &#34;cluster&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_attachment_id</span>  <span class="o">=</span> <span class="k">aws_ec2_transit_gateway_vpc_attachment</span><span class="p">.</span><span class="k">cluster</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl"><span class="n">  transit_gateway_route_table_id</span> <span class="o">=</span> <span class="k">aws_ec2_transit_gateway_route_table</span><span class="p">.</span><span class="k">datastores</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Only attachments associated with <code>datastores</code> get a route to the RDS VPC.
Everything else on the gateway can still reach the cluster VPCs directly,
just not the datastore behind them.</p>
<p>This is also where the
<a href="https://omegion.dev/2026/04/how-to-reach-a-lambda-in-another-aws-region-without-vpc-peering/">Lambda cross-region PrivateLink setup</a>

I wrote about earlier stops applying. That one existed because the Lambda
needed to cross a region boundary we deliberately don&rsquo;t route through. A
Lambda in the same region calling a cluster service doesn&rsquo;t need a one-off
endpoint at all once its VPC is already an attachment on the gateway
everything else uses. It just needs a route, the same as any other
consumer.</p>
<h2 id="what-transit-gateway-costs">What Transit Gateway costs</h2>
<p><a href="https://aws.amazon.com/vpc/pricing/" target="_blank" rel="noopener noreferrer">VPC peering</a>
 connections themselves
are free. Data crossing a peering connection is billed like any other
cross-AZ traffic, $0.01 per GB, same region, in each direction.
<a href="https://aws.amazon.com/transit-gateway/pricing/" target="_blank" rel="noopener noreferrer">Transit Gateway</a>
 bills
differently: $0.05 per attachment per hour, whether or not it&rsquo;s carrying
anything, plus $0.02 per GB processed, twice the peering rate (us-east-1
figures, other regions vary slightly).</p>
<p>Take the 80 cluster VPCs from the redesign, plus the RDS VPC and the Lambda
VPC from the last section, moving a combined 20 TB in a month:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">VPC peering, full mesh
</span></span><span class="line"><span class="cl">  cluster to cluster   80 × 79 / 2           = 3,160 connections
</span></span><span class="line"><span class="cl">  RDS to each cluster  80 × 1                =    80 connections
</span></span><span class="line"><span class="cl">  Lambda to its cluster                      =     1 connection
</span></span><span class="line"><span class="cl">  connections total                          = 3,241
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  data transfer  20,000 GB × $0.01/GB        = $200/mo
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Transit Gateway
</span></span><span class="line"><span class="cl">  attachments    82 × $0.05 × 24h × 30d      = $2,952/mo
</span></span><span class="line"><span class="cl">  data transfer  20,000 GB × $0.02/GB        =   $400/mo
</span></span><span class="line"><span class="cl">  total                                      = $3,352/mo</span></span></code></pre></div>
</div>
<p>That extra $3,152 a month buys 82 attachments to one gateway instead of
3,241 pairwise connections nobody could keep straight, the RDS VPC and the
Lambda VPC included.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Peering doesn&rsquo;t fail loudly. Nothing breaks when you add one more VPC past
the point where the mesh stopped scaling, you just accumulate more manual
route table work than any one person can track correctly. The other cost
never shows up on a bill: try explaining a 3,241-connection peering
topology to an engineer who joined last week, versus pointing at one
gateway and saying everything goes through here. If I were redesigning
that networking again knowing we&rsquo;d end up with eighty VPCs, I&rsquo;d go straight
to a Transit Gateway instead of peering.</p>
]]></content:encoded></item></channel></rss>