<?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>Multi-Tenancy on The Nested Lab</title>
    <link>https://thenestedlab.com/tags/multi-tenancy/</link>
    <description>Recent content in Multi-Tenancy on The Nested Lab</description>
    <generator>Hugo</generator>
    <language>en-gb</language>
    <lastBuildDate>Wed, 16 Sep 2026 08:00:00 +0100</lastBuildDate>
    <atom:link href="https://thenestedlab.com/tags/multi-tenancy/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Three datacenters, one IP plan: identical isolated pods with NSX VPCs</title>
      <link>https://thenestedlab.com/posts/three-datacenters-one-ip-plan/</link>
      <pubDate>Wed, 16 Sep 2026 08:00:00 +0100</pubDate>
      <guid>https://thenestedlab.com/posts/three-datacenters-one-ip-plan/</guid>
      <description>Three nested-ESXi pods, byte-identical addressing — same subnets, same VLANs, same host IPs, even the same MACs — with zero reachability between them. How overlapping VPC CIDRs and deterministic subnet realization turn cookie-cutter environments into a first-class feature.</description>
      <content:encoded><![CDATA[<p>Here are three hosts, all answering to <code>vmk0 = 172.30.0.40</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">ssh root@192.168.144.30  -&gt;  [root@esx01-a:~]  vmk0  172.30.0.40
</span></span><span class="line"><span class="cl">ssh root@192.168.144.32  -&gt;  [root@esx01-b:~]  vmk0  172.30.0.40
</span></span><span class="line"><span class="cl">ssh root@192.168.144.34  -&gt;  [root@esx01-c:~]  vmk0  172.30.0.40
</span></span></code></pre></div><p>Same IP. Same VLAN. Same gateway. Same <em>MAC address</em>, as it turns out. And
none of them can reach any of the others. This is the post where NSX VPCs
stop being a workaround for nested labs and become genuinely better than
the physical alternative.</p>
<p><img alt="Three pods, identical IP plans, no route between them" loading="lazy" src="/images/product-01-hook.jpg"></p>
<h2 id="why-identical-addressing-matters">Why identical addressing matters</h2>
<p>If you&rsquo;ve ever built training pods, cert-study labs, or per-team
reproduction environments, you know the pain: every copy needs a unique
address plan, so every runbook, every screenshot, every &ldquo;type this exact
command&rdquo; has to be parameterised per pod. Students in seat 7 see different
numbers from the slides. Reproductions drift from the original.</p>
<p>The fix is obvious and normally impossible: <strong>give every pod the same
addresses</strong>. On a physical fabric that means VRFs, per-pod NAT, and a
network team that stops answering your emails. In an NSX VPC it&rsquo;s the
default behaviour.</p>
<h2 id="the-mechanism-overlapping-privateips">The mechanism: overlapping privateIPs</h2>
<p>Each pod gets its own VPC, and every VPC declares the same private range:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">vpc.nsx.vmware.com/v1alpha1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">VPC</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w"> </span>{<span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nested-vpc-a}     </span><span class="w"> </span><span class="c"># then -b, then -c</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">privateIPs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;172.30.0.0/16&#34;</span><span class="p">]</span><span class="w">      </span><span class="c"># identical in all three</span><span class="w">
</span></span></span></code></pre></div><p>A <code>Private</code> subnet is never advertised beyond its VPC, so NSX has no
objection to three VPCs carving up the same /16. The pods aren&rsquo;t
&ldquo;firewalled from each other&rdquo; — there is simply no route between them.
Isolation by construction, not by policy.</p>
<p><img alt="NSX: four VPCs, four sn-mgmt subnets, same CIDR" loading="lazy" src="/images/ui/u11a-nsx-snmgmt-four-vpcs.jpg">
<em>NSX&rsquo;s subnet view filtered to <code>sn-mgmt</code>: four rows, four VPCs, one CIDR.</em></p>
<h2 id="the-trick-deterministic-realization">The trick: deterministic realization</h2>
<p>Identical <em>ranges</em> aren&rsquo;t enough — I want identical <em>subnets</em>, so the
management gateway is <code>.33</code> and the hosts are <code>.40</code>/<code>.41</code> in every pod.
NSX allocates subnets from <code>privateIPs</code> in creation order, and a fresh VPC
allocates deterministically. So the topology is applied in a <strong>fixed
order</strong> — trunk, mgmt, vMotion, vSAN — and every pod realizes the same
map:</p>
<table>
	<thead>
			<tr>
					<th>Subnet</th>
					<th>Realized</th>
					<th>VLAN</th>
					<th>Hosts</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>sn-trunk</td>
					<td>172.30.0.0/27</td>
					<td>—</td>
					<td>(carries the tags)</td>
			</tr>
			<tr>
					<td>sn-mgmt</td>
					<td>172.30.0.32/27</td>
					<td>1610</td>
					<td>.40 / .41, gw .33</td>
			</tr>
			<tr>
					<td>sn-vmotion</td>
					<td>172.30.0.64/27</td>
					<td>1611</td>
					<td>.70 / .71, gw .65</td>
			</tr>
			<tr>
					<td>sn-vsan</td>
					<td>172.30.0.96/27</td>
					<td>1612</td>
					<td>.100 / .101, gw .97</td>
			</tr>
	</tbody>
</table>
<p>In the catalog blueprint that order is enforced with <code>dependsOn</code> between
the subnet resources — the one place a declarative tool needs to be told
about sequence. Skip it and two pods can come out with mgmt and vMotion
swapped, which works perfectly and confuses everyone.</p>
<p><img alt="NSX: nested-vpc-a expanded, the /16 private block" loading="lazy" src="/images/ui/u11-nsx-vpc-a-cidr.jpg"></p>
<h2 id="the-door-one-vip-per-host">The door: one VIP per host</h2>
<p>Each pod is unreachable from outside by design, so each host gets a
<code>VirtualMachineService</code> of type <code>LoadBalancer</code> publishing SSH and HTTPS.
The VIPs come from the org&rsquo;s <em>external</em> block, and they&rsquo;re the only
addresses that differ between pods:</p>
<table>
	<thead>
			<tr>
					<th>Pod</th>
					<th>VPC</th>
					<th>esx01 VIP</th>
					<th>esx02 VIP</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>a</td>
					<td>nested-vpc-a</td>
					<td>192.168.144.30</td>
					<td>.31</td>
			</tr>
			<tr>
					<td>b</td>
					<td>nested-vpc-b</td>
					<td>192.168.144.32</td>
					<td>.33</td>
			</tr>
			<tr>
					<td>c</td>
					<td>nested-vpc-c</td>
					<td>192.168.144.34</td>
					<td>.35</td>
			</tr>
	</tbody>
</table>
<p>Which is how the opening transcript works: three VIPs, three hosts, one
inside address.</p>
<h2 id="proving-the-isolation">Proving the isolation</h2>
<p>Claims are cheap. The test matrix, from a VM in a <em>fourth</em> VPC (the org
default):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">ping 172.30.0.140 (own VPC)....... REACHABLE
</span></span><span class="line"><span class="cl">ping 172.30.0.40  (pod space)..... unreachable
</span></span><span class="line"><span class="cl">curl http://172.31.0.2/ (shared).. shared-svc repo01
</span></span></code></pre></div><p><img alt="Isolation matrix: own-VPC reachable, pod space unreachable, shared service reachable" loading="lazy" src="/images/demo-c7-isolation.jpg"></p>
<p>Its own VPC&rsquo;s <code>172.30.0.140</code>: reachable. <code>172.30.0.40</code> — an address that
exists in three other VPCs simultaneously: unreachable, because from here
there is no such route. (The third line is the shared-services VPC, which
is <a href="/series/the-vpc-pod-papers/">the next post</a>.)</p>
<p>Inside each pod, east-west is normal: <code>esx01 → esx02</code> vmkping passes on
all three VLANs, in all three pods. And the detail I didn&rsquo;t expect: the
nested-ESXi appliance derives vmk0&rsquo;s MAC deterministically from its
config, so <strong>the three hosts share a MAC as well as an IP</strong>. Harmless — each
VPC is its own L2 domain — but a nice demonstration of how complete the
separation is.</p>
<h2 id="what-this-replaces">What this replaces</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Physical / VLAN-based pods</th>
					<th>VPC pods</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Identical addressing</td>
					<td>VRF per pod + NAT, fabric change per pod</td>
					<td>default behaviour</td>
			</tr>
			<tr>
					<td>Adding a pod</td>
					<td>switch config, IPAM, firewall rules</td>
					<td>one API call for the VPC, one blueprint request</td>
			</tr>
			<tr>
					<td>Isolation guarantee</td>
					<td>policy (auditable, breakable)</td>
					<td>topology (no route exists)</td>
			</tr>
			<tr>
					<td>Tenant self-service</td>
					<td>no</td>
					<td>yes — the VPC is a tenant object</td>
			</tr>
	</tbody>
</table>
<h2 id="why-this-matters-outside-the-lab">Why this matters outside the lab</h2>
<p>&ldquo;Identical environments&rdquo; sounds like a lab nicety. It&rsquo;s actually one of the
most requested things in enterprise IT, usually asked for in other words:</p>
<ul>
<li><strong>Training at scale</strong> — every seat in the room sees the same addresses as
the slides, so material is written once and never parameterised per pod.</li>
<li><strong>Per-engineer or per-team replicas</strong> of a reference environment, for
development and testing that behaves exactly like the original.</li>
<li><strong>Regulatory or business-unit separation</strong> on shared infrastructure
without VRF sprawl or a bespoke firewall estate — isolation is a property
of the topology, which is the easiest kind to evidence to an auditor.</li>
<li><strong>Blue/green copies</strong> of an environment for change rehearsal, then
cut-over or discard.</li>
</ul>
<p>On a physical network each of these is a project. On VCF with NSX VPCs it&rsquo;s
a template.</p>
<h2 id="rules-learned">Rules learned</h2>
<ul>
<li>Overlapping <code>privateIPs</code> across VPCs is <strong>supported and intentional</strong>.
Identical pods are a feature, not a hack.</li>
<li>Fresh VPCs realize subnets <strong>deterministically in creation order</strong> —
fix the order (<code>dependsOn</code> in a blueprint) and every pod gets the same
map.</li>
<li>Pods are unreachable from outside by construction; publish exactly what
you mean to via <code>LoadBalancer</code> VIPs from the external block.</li>
<li>Prove isolation from a <em>different</em> VPC, with a positive control (own
VPC reachable) beside the negative.</li>
<li>Expect duplicate MACs across pods from appliance images. It&rsquo;s fine.</li>
</ul>
<p><em>Previously: <a href="/posts/the-lb-that-must-exist-first/">the LB that must exist first</a>.
Next: one WSUS for pods that can&rsquo;t see each other.</em></p>
<hr>
<p><em>Lab environment; opinions my own. Output captured live, trimmed for length,
never edited for outcome.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Shared services for isolated tenants: PrivateTGW subnets</title>
      <link>https://thenestedlab.com/posts/shared-services-for-isolated-tenants/</link>
      <pubDate>Wed, 16 Sep 2026 07:50:00 +0100</pubDate>
      <guid>https://thenestedlab.com/posts/shared-services-for-isolated-tenants/</guid>
      <description>Three pods with identical private addressing all need the same WSUS, repo and AD. One shared-services VPC with a PrivateTGW subnet serves all of them over the transit gateway — and can&amp;rsquo;t reach back into any of them. The directional test, and why SNAT is what makes it work.</description>
      <content:encoded><![CDATA[<p>The pods from <a href="/posts/three-datacenters-one-ip-plan/">the last post</a> are
perfectly isolated. That&rsquo;s the requirement — and immediately the problem.
Every one of them needs Windows updates, a package repo, DNS, maybe a
domain controller. Do I really run a WSUS <em>per pod</em>?</p>
<p>No. There&rsquo;s a third subnet access mode for exactly this, and the design it
enables is hub-and-spoke with a very specific property: <strong>spokes reach the
hub; the hub cannot reach the spokes; spokes never reach each other.</strong></p>
<h2 id="the-three-access-modes">The three access modes</h2>
<p>Everything in this series comes down to one field on a VPC subnet:</p>
<table>
	<thead>
			<tr>
					<th><code>accessMode</code></th>
					<th>Advertised to</th>
					<th>Use</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Private</code></td>
					<td>nobody outside the VPC</td>
					<td>workloads — isolation <em>and</em> overlapping CIDRs</td>
			</tr>
			<tr>
					<td><code>PrivateTGW</code></td>
					<td>every VPC attached to the org&rsquo;s transit gateway</td>
					<td>shared services</td>
			</tr>
			<tr>
					<td><code>Public</code></td>
					<td>the external network</td>
					<td>internet/corp-facing endpoints</td>
			</tr>
	</tbody>
</table>
<p><code>PrivateTGW</code> subnets draw their addresses from a <strong>separate transit
block</strong> (here <code>172.31.0.0/…</code>), not from the VPC&rsquo;s own <code>privateIPs</code>. That&rsquo;s
the key: the shared range can&rsquo;t collide with the pods&rsquo; <code>172.30.0.0/16</code>
because it comes from a different pool that all VPCs agree on.</p>
<h2 id="the-build">The build</h2>
<p>One more VPC, <code>shared-svc</code>, with the same ordering rules as any other
(VPC → VPCAttachment → LoadBalancer → namespace), then a single subnet:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">crd.nsx.vmware.com/v1alpha1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Subnet</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w"> </span>{<span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">sn-services}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w"> </span>{<span class="nt">accessMode</span><span class="p">:</span><span class="w"> </span><span class="nt">PrivateTGW, ipv4SubnetSize</span><span class="p">:</span><span class="w"> </span><span class="m">32</span>}<span class="w">
</span></span></span></code></pre></div><p>It realized as <code>172.31.0.0/27</code>, and a VM on it — <code>svc-repo01</code>,
<code>172.31.0.2</code>, serving HTTP — became the shared repo.</p>
<p><img alt="Architecture: VPC per pod, transit gateway, shared-services VPC" loading="lazy" src="/images/product-02-architecture.jpg"></p>
<h2 id="the-test-that-matters-is-directional">The test that matters is directional</h2>
<p>Reachability <em>to</em> the service is the easy claim. From <code>esx01</code> in each of
the three pods (ESXi ships python3, so <code>urllib</code> is the test client):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">pod-a esx01 -&gt; http://172.31.0.2/   200  shared-svc repo01
</span></span><span class="line"><span class="cl">pod-b esx01 -&gt; http://172.31.0.2/   200  shared-svc repo01
</span></span><span class="line"><span class="cl">pod-c esx01 -&gt; http://172.31.0.2/   200  shared-svc repo01
</span></span><span class="line"><span class="cl">default-vpc  -&gt; http://172.31.0.2/   200  shared-svc repo01
</span></span></code></pre></div><p>Three pods with <strong>identical source addresses</strong> (<code>172.30.0.40</code>) all hit
one service and all get answers. How does the reply find its way back to
the right pod when three of them claim <code>.40</code>? Because pod traffic crosses
the transit gateway <strong>SNAT&rsquo;d to a per-VPC transit address</strong>. The service
never sees <code>172.30.0.40</code>; it sees three distinct transit IPs. Ambiguity
never arises.</p>
<p>Now the other direction — from <code>svc-repo01</code> back toward a pod:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">svc-repo01 -&gt; 172.30.0.40   unreachable
</span></span><span class="line"><span class="cl">svc-repo01 -&gt; 172.30.0.41   unreachable
</span></span></code></pre></div><p>Not &ldquo;blocked&rdquo; — <em>unroutable</em>. Pod subnets are <code>Private</code>, so they were never
advertised to the transit gateway, and even if they had been, <code>172.30.0.40</code>
would be ambiguous across three VPCs. The hub literally cannot initiate
into a spoke. For a shared service that will one day be compromised,
that&rsquo;s the property you want.</p>
<h2 id="what-goes-in-the-hub">What goes in the hub</h2>
<p>Anything that&rsquo;s <em>consumed</em> by pods and <em>stateless about which pod is
asking</em>: WSUS/patch mirrors, OS and package repos, container registries,
NTP, DNS forwarders, license servers. Domain controllers work too, with
the usual caveat that identical hostnames across pods need per-pod domains
or a naming scheme.</p>
<p>What does <strong>not</strong> go in the hub: anything that needs to <em>reach into</em> a pod
(monitoring pollers, backup agents pulling, jump hosts). Those either live
in the pod, or the pod publishes a <code>LoadBalancer</code> VIP for them — the
deliberate door from <a href="/posts/whats-a-vpc-with-pacman/">part 0</a>.</p>
<h2 id="tightening-further">Tightening further</h2>
<p>The transit gateway gives you reachability; policy gives you precision. A
<code>VPCGatewayFirewallPolicy</code> on <code>shared-svc</code> can restrict inbound to
<code>tcp/80,443</code> from the transit range and nothing else, so the repo is a
repo and not a foothold. I left it open for the test; you shouldn&rsquo;t.</p>
<h2 id="why-this-matters-outside-the-lab">Why this matters outside the lab</h2>
<p>This is the pattern that makes isolated tenants <em>affordable</em>. Without it,
every isolated environment needs its own patch server, repository, DNS and
directory — cost and drift that quietly kill the idea. With it, a customer
runs one set of shared services for dozens of tenants, keeps them patched in
one place, and can still show a security reviewer that the shared service
has no path back into any tenant.</p>
<p>The same hub serves well beyond patching: central logging and monitoring
collectors, licence servers, artifact registries, build agents — anything
tenants consume but shouldn&rsquo;t be able to be reached <em>by</em>.</p>
<h2 id="rules-learned">Rules learned</h2>
<ul>
<li><code>PrivateTGW</code> is the shared-services mode: addresses from the <strong>transit
block</strong>, advertised to every attached VPC, no collision with pod space.</li>
<li>Access is <strong>one-way by construction</strong>: pods → service works (SNAT&rsquo;d
per VPC), service → pod has no route. Test both directions and write
down both results.</li>
<li>Identical pod addressing and shared services coexist <em>because</em> of the
SNAT — the hub sees per-VPC transit addresses, never the overlapping
private ones.</li>
<li>Same ordering rules apply to the hub VPC (VPC → attachment → LB →
namespace → subnets → wait for image sync → workloads).</li>
<li>Add a gateway firewall policy on the hub. Reachability is not
authorisation.</li>
</ul>
<p><em>Previously: <a href="/posts/three-datacenters-one-ip-plan/">three datacenters, one IP plan</a>.
Next: the whole pod as a single catalog item.</em></p>
<hr>
<p><em>Lab environment; opinions my own. Output captured live, trimmed for length,
never edited for outcome.</em></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
