<?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>Mikrotik on Vladimir Kuznichenkov | Engineer</title>
    <link>https://kuzaxak.dev/tags/mikrotik/</link>
    <description>Recent content in Mikrotik on Vladimir Kuznichenkov | Engineer</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <copyright>© Vladimir Kuznichenkov</copyright>
    <lastBuildDate>Sun, 24 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://kuzaxak.dev/tags/mikrotik/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Doors Don&#39;t Route: Cloud-Hosting UniFi Access with VXLAN-over-IPSec</title>
      <link>https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/</link>
      <pubDate>Sun, 24 May 2026 00:00:00 +0000</pubDate>
      
      <guid>https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/</guid>
      <description>A practical story about running UniFi Access on AWS arm64, extending office L2 over VXLAN-in-IPSec to a Mikrotik gateway, and the assumptions that broke along the way.</description>
      <content:encoded><![CDATA[<h2 id="the-constraint">The Constraint</h2>
<p>I had a G3 Starter Kit on the desk: one UA-G3 Door Hub Mini, one UA-G3 Wall reader. The plan was to run the UniFi Access controller in Docker on the office Synology NAS, keep everything on the local LAN, and let the door hardware adopt there.</p>
<p>Ubiquiti officially does not support this. They sell consoles with Access pre-installed and tell you to run it there. The <a href="https://github.com/dciancu/unifi-protect-unvr-docker-arm64">dciancu/unifi-protect-unvr-docker-arm64</a> repository proves the application layer can be lifted off a console image; nobody has done it for Access, and the device adoption story is materially different from Protect.</p>
<p>That was the constraint I started from: a docker controller, a door hub that had to adopt through L2 discovery, and a NAS that looked like the right host until the architecture requirement broke it. AWS only entered the story after the local Synology path failed.</p>
<p>The examples below use real values from the deployment, sanitized only where they would not help a reader reproduce the design.</p>
<hr>
<h2 id="what-adoption-actually-means-for-access">What &quot;Adoption&quot; Actually Means for Access</h2>
<p>UniFi Access bootstrapping starts with local discovery: devices broadcast on UDP <code>10001</code>, and in this unsupported container setup the Hub Mini only offered itself to a controller that answered from the same L2 broadcast domain. Ubiquiti's own port list calls <code>10001/udp</code> the Access device discovery path (<a href="https://help.ui.com/hc/en-us/articles/17452334269975-Getting-Started-with-UniFi-Access">docs</a>).</p>
<p>That sounds the same as Network adoption, but it is not. UniFi Network firmware honors <strong>DHCP option 43</strong> as a fallback (a controller IP encoded as <code>01 04 &lt;ip-bytes&gt;</code>), and the devices have a <code>set-inform</code> CLI over SSH. Access devices have neither. A community-confirmed <a href="https://www.reddit.com/r/UNIFI/comments/16v6js7/is_dhcp_option_43_supported_by_unifi_door_access/">thread</a> documents this directly:</p>
<blockquote>
<p>&quot;Access needs to see the other device in the same VLAN (broadcast domain).&quot;</p>
</blockquote>
<p>I tested option 43 anyway. The MikroTik sent it, the lease included the encoded controller IP, the Hub Mini ignored it. No <code>set-inform</code>, no SSH, no fallback. The device only adopted once the controller could participate in the L2 discovery exchange. After adoption, normal controller-to-device traffic can be L3-routed; the first discovery step was the hard constraint.</p>
<p>That single fact controls every networking decision in this story.</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/access-adoption.png" alt="Access discovery adoption flow"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>sequenceDiagram
</span></span><span style="display:flex;"><span>    participant Device as G3 Door Hub Mini&lt;br/&gt;10.255.255.230
</span></span><span style="display:flex;"><span>    participant LAN as Office LAN&lt;br/&gt;broadcast domain
</span></span><span style="display:flex;"><span>    participant Controller as UniFi Access&lt;br/&gt;Controller
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Note over Device,Controller: Boots fresh from box
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Device-&gt;&gt;LAN: UDP broadcast&lt;br/&gt;10.255.255.255:10001&lt;br/&gt;&#34;I am MAC 84:78:48:...&#34;
</span></span><span style="display:flex;"><span>    LAN--&gt;&gt;Controller: ✓ delivered (same L2)
</span></span><span style="display:flex;"><span>    Controller-&gt;&gt;Device: UDP unicast&lt;br/&gt;:10001 &#34;I am your controller&#34;
</span></span><span style="display:flex;"><span>    Device-&gt;&gt;Controller: TCP 12443 + MQTT 12812&lt;br/&gt;establish session
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Note over Device,Controller: Adoption complete
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    rect rgb(255, 230, 230)
</span></span><span style="display:flex;"><span>        Note over Device,LAN: ❌ Broadcast does NOT cross&lt;br/&gt;L3 routers / IPSec tunnels
</span></span><span style="display:flex;"><span>        Note over Device,LAN: ❌ No DHCP option 43 fallback&lt;br/&gt;(unlike UniFi Network)
</span></span><span style="display:flex;"><span>        Note over Device,LAN: ❌ No SSH / set-inform CLI
</span></span><span style="display:flex;"><span>    end
</span></span></code></pre></div>
</details>

<hr>
<h2 id="wrong-assumption-1-run-it-on-the-synology">Wrong Assumption 1: Run It on the Synology</h2>
<p>The first idea was the obvious one. We had a DSM 7.2.2 box on the office LAN, with Portainer, Traefik, the existing service mesh, and plenty of CPU. Why deploy anywhere else?</p>
<p>Ubiquiti only publishes Access for arm64. Synology is x86_64. The expected workaround was QEMU user-mode emulation through <code>binfmt_misc</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker run --privileged --rm tonistiigi/binfmt --install arm64
</span></span><span style="display:flex;"><span>docker run --platform<span style="color:#f92672">=</span>linux/arm64 alpine uname -m
</span></span><span style="display:flex;"><span><span style="color:#75715e"># expected: aarch64</span>
</span></span></code></pre></div><p>The first command failed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>installing: arm64 cannot register &#34;/usr/bin/qemu-aarch64&#34; to
</span></span><span style="display:flex;"><span>/proc/sys/fs/binfmt_misc/register: write /proc/sys/fs/binfmt_misc/register:
</span></span><span style="display:flex;"><span>invalid argument
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>supported:
</span></span><span style="display:flex;"><span>  - linux/amd64
</span></span><span style="display:flex;"><span>  - linux/amd64/v2
</span></span><span style="display:flex;"><span>  - linux/amd64/v3
</span></span><span style="display:flex;"><span>  - linux/386
</span></span></code></pre></div><p>Probing the kernel directly showed why:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker run --rm --privileged --platform<span style="color:#f92672">=</span>linux/amd64 alpine <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  sh -c <span style="color:#e6db74">&#39;ls -la /proc/sys/fs/binfmt_misc/; cat /proc/sys/fs/binfmt_misc/status&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># dr-xr-xr-x    2 root     root             0 May 23 19:36 .</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># (no &#39;register&#39; or &#39;status&#39; files)</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># cat: can&#39;t open &#39;/proc/sys/fs/binfmt_misc/status&#39;: No such file or directory</span>
</span></span></code></pre></div><p>The <code>binfmt_misc</code> filesystem is mounted, but it is read-only and the <code>register</code>/<code>status</code> interface is gone. Synology's kernel (4.4.302+) is built with <code>binfmt_misc</code> deliberately locked down. No userspace can register a new format, with or without privileges, with or without IAM equivalents.</p>
<p>VMM was the next instinct: run a Linux ARM VM on the Synology. VMM uses KVM, which can only virtualize the host architecture. Without nested full-system QEMU (which DSM VMM does not expose), an arm64 VM on Intel Synology is not an option.</p>
<p>The Synology had to come out of the picture as the controller host.</p>
<hr>
<h2 id="wrong-assumption-2-aws-arm64--ipsec-l3--dhcp-option-43">Wrong Assumption 2: AWS arm64 + IPSec L3 + DHCP Option 43</h2>
<p>The next plan was cleaner architecturally. We have AWS, we have arm64 instances (<code>t4g.medium</code>), and we have a Mikrotik RB5009 with a static public IP that already terminates one IPSec tunnel.</p>
<p>The design looked like this:</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/ipsec-l3-design.png" alt="L3 IPsec design that did not solve adoption"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>flowchart LR
</span></span><span style="display:flex;"><span>    subgraph Office[&#34;Office LAN 10.255.255.0/24&#34;]
</span></span><span style="display:flex;"><span>        Hub[Door Hub Mini&lt;br/&gt;10.255.255.230]
</span></span><span style="display:flex;"><span>        Reader[G3 Reader&lt;br/&gt;10.255.255.229]
</span></span><span style="display:flex;"><span>        Mikrotik[Mikrotik RB5009&lt;br/&gt;10.255.255.1]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    subgraph AWS[&#34;AWS eu-north-1&#34;]
</span></span><span style="display:flex;"><span>        Controller[unifi-access&lt;br/&gt;EC2 t4g.medium&lt;br/&gt;172.31.26.37]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Hub --&gt; Mikrotik
</span></span><span style="display:flex;"><span>    Reader --&gt; Mikrotik
</span></span><span style="display:flex;"><span>    Mikrotik -- &#34;IPSec IKEv2&lt;br/&gt;10.255.255.0/24 ↔ 172.31.26.37/32&#34; --&gt; Controller
</span></span></code></pre></div>
</details>

<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/option43-fails.png" alt="DHCP option 43 does not replace L2 Access discovery"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>sequenceDiagram
</span></span><span style="display:flex;"><span>    participant Hub as Door Hub Mini&lt;br/&gt;10.255.255.230
</span></span><span style="display:flex;"><span>    participant MT as Mikrotik&lt;br/&gt;10.255.255.1
</span></span><span style="display:flex;"><span>    participant Tunnel as IPSec L3 Tunnel
</span></span><span style="display:flex;"><span>    participant EC2 as Controller&lt;br/&gt;172.31.26.37
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Note over Hub: DHCP lease arrives&lt;br/&gt;with option 43 = 172.31.26.37
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    rect rgb(255, 230, 230)
</span></span><span style="display:flex;"><span>        Hub-&gt;&gt;Hub: Ignore option 43&lt;br/&gt;(Access firmware does not honor it)
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Hub-&gt;&gt;MT: Broadcast UDP 10001&lt;br/&gt;dst: 255.255.255.255
</span></span><span style="display:flex;"><span>    Note over MT: Broadcasts do not cross L3
</span></span><span style="display:flex;"><span>    MT--xTunnel: ❌ Dropped at routing
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Note over Hub,EC2: Controller never learns about the Hub Mini.&lt;br/&gt;Tunnel works for ICMP/TCP unicast, but useless&lt;br/&gt;for L2 adoption broadcasts.
</span></span></code></pre></div>
</details>

<p>The IPSec tunnel came up cleanly. From Mikrotik with an explicit source address, pings reached the controller at 10ms:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>SEQ HOST                                     SIZE TTL TIME       STATUS
</span></span><span style="display:flex;"><span>  0 172.31.26.37                               56  64 10ms260us
</span></span><span style="display:flex;"><span>  1 172.31.26.37                               56  64 10ms404us
</span></span><span style="display:flex;"><span>sent=3 received=3 packet-loss=0%
</span></span></code></pre></div><p>I configured DHCP option 43 on the Mikrotik to hand out the controller IP to UniFi devices:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>/ip dhcp-server option add name=unifi-controller code=43 value=0x0104ac1f1a25
</span></span><span style="display:flex;"><span>/ip dhcp-server network set [find address=10.255.255.0/24] dhcp-option=unifi-controller
</span></span></code></pre></div><p><code>0x01 04 ac 1f 1a 25</code> decodes as sub-option 1, length 4, controller IP <code>172.31.26.37</code>. The lease confirmed the option was offered. The Hub Mini took the lease and ignored the option.</p>
<p>Several controller-side captures showed the same thing: the Hub Mini was reachable, the IPSec tunnel was healthy, and no device traffic ever reached the controller. The community thread was right.</p>
<p>The discovery model was the architectural constraint, not a knob to tune. Either the controller had to be on the same L2 broadcast domain as the door hardware, or the broadcast domain had to be extended to where the controller was.</p>
<hr>
<h2 id="the-shape-that-matched-the-protocol">The Shape That Matched the Protocol</h2>
<p>The fix had to bring the controller container into the office broadcast domain at L2, while keeping its lifecycle in AWS. The shape that worked:</p>
<ol>
<li>Keep the IPSec tunnel as the underlay - it already exists, it is encrypted, and it terminates at known endpoints.</li>
<li>Build a VXLAN tunnel between Mikrotik and the EC2 host, with VXLAN packets riding inside the IPSec tunnel.</li>
<li>Bridge the VXLAN endpoint into the office LAN on the Mikrotik side, and into a Linux bridge on the EC2 side.</li>
<li>Attach the docker container to the Linux bridge through a <code>macvlan</code> network, giving it a real <code>10.255.255.x</code> address.</li>
</ol>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/vxlan-over-ipsec.png" alt="VXLAN over IPsec architecture"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>flowchart TB
</span></span><span style="display:flex;"><span>    subgraph Office[&#34;Office LAN 10.255.255.0/24&#34;]
</span></span><span style="display:flex;"><span>        Hub[Door Hub Mini&lt;br/&gt;10.255.255.230]
</span></span><span style="display:flex;"><span>        Reader[G3 Reader&lt;br/&gt;10.255.255.229]
</span></span><span style="display:flex;"><span>        Synology[Synology Traefik&lt;br/&gt;10.255.255.245]
</span></span><span style="display:flex;"><span>        Mikrotik[Mikrotik bridge&lt;br/&gt;10.255.255.1]
</span></span><span style="display:flex;"><span>        VXLANmt[vxlan-aws&lt;br/&gt;VNI 100, UDP 4789]
</span></span><span style="display:flex;"><span>        Mikrotik --- VXLANmt
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    subgraph Tunnel[&#34;IPSec ESP / UDP 4500&#34;]
</span></span><span style="display:flex;"><span>        ipsec[encrypted underlay]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    subgraph AWS[&#34;AWS eu-north-1&#34;]
</span></span><span style="display:flex;"><span>        ens5[EC2 ens5&lt;br/&gt;172.31.26.37]
</span></span><span style="display:flex;"><span>        vxlan0[vxlan0&lt;br/&gt;VNI 100, dev=ens5]
</span></span><span style="display:flex;"><span>        broffice[br-office&lt;br/&gt;10.255.255.50]
</span></span><span style="display:flex;"><span>        macvlan[docker macvlan&lt;br/&gt;parent=br-office]
</span></span><span style="display:flex;"><span>        Container[unifi-access container&lt;br/&gt;eth1=10.255.255.200]
</span></span><span style="display:flex;"><span>        ens5 --- vxlan0
</span></span><span style="display:flex;"><span>        vxlan0 --- broffice
</span></span><span style="display:flex;"><span>        broffice --- macvlan
</span></span><span style="display:flex;"><span>        macvlan --- Container
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    VXLANmt --&gt; ipsec
</span></span><span style="display:flex;"><span>    ipsec --&gt; ens5
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Hub -. &#34;broadcast UDP 10001&#34; .-&gt; Mikrotik
</span></span><span style="display:flex;"><span>    Reader -. &#34;broadcast UDP 10001&#34; .-&gt; Mikrotik
</span></span><span style="display:flex;"><span>    Container -. &#34;discovery probes&lt;br/&gt;233.89.188.1:10001&lt;br/&gt;255.255.255.255:10001&#34; .-&gt; macvlan
</span></span></code></pre></div>
</details>

<p>The mental model that helped was this:</p>
<blockquote>
<p>The controller does not need to be in AWS. It needs to be unreachable from anywhere except the office LAN, and to look to the door hardware exactly like a host on <code>10.255.255.0/24</code>.</p>
</blockquote>
<p>The L2 bridge made that true. AWS became invisible from the protocol's point of view.</p>
<hr>
<h2 id="building-the-container-image">Building the Container Image</h2>
<p>The application layer was the dciancu Protect approach with Access surgery on top.</p>
<p>The base layer comes from extracting a UCG-Max firmware binary - which is publicly downloadable and <code>binwalk</code>able - through <code>dpkg-repack</code> against the unpacked rootfs:</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/image-build.png" alt="Container image build flow"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-gdscript3" data-lang="gdscript3"><span style="display:flex;"><span>flowchart LR
</span></span><span style="display:flex;"><span>    A[fw<span style="color:#f92672">-</span>update<span style="color:#f92672">.</span>ubnt<span style="color:#f92672">.</span>com<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>UCG<span style="color:#f92672">-</span>Max <span style="color:#f92672">.</span>bin] <span style="color:#f92672">--&gt;|</span>wget<span style="color:#f92672">|</span> B[binwalk <span style="color:#f92672">-</span>e]
</span></span><span style="display:flex;"><span>    B <span style="color:#f92672">--&gt;</span> C[squashfs<span style="color:#f92672">-</span>root]
</span></span><span style="display:flex;"><span>    C <span style="color:#f92672">--&gt;|</span>dpkg<span style="color:#f92672">-</span>repack<span style="color:#f92672">|</span> D[<span style="color:#f92672">*.</span>deb per package]
</span></span><span style="display:flex;"><span>    D <span style="color:#f92672">--&gt;</span> E[base layer<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>unifi<span style="color:#f92672">-</span>core, ulp<span style="color:#f92672">-</span>go,<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>uos<span style="color:#f92672">*</span>, ubnt<span style="color:#f92672">-</span>tools,<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>unifi<span style="color:#f92672">-</span>assets<span style="color:#f92672">-</span>ucgmax,<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>node<span style="color:#f92672">*</span>]
</span></span><span style="display:flex;"><span>    E <span style="color:#f92672">--&gt;</span> F[debian:<span style="color:#ae81ff">11</span> arm64<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;+</span> apt<span style="color:#f92672">-</span>get install]
</span></span><span style="display:flex;"><span>    F <span style="color:#f92672">--&gt;</span> G[unifi<span style="color:#f92672">-</span>access<span style="color:#f92672">.</span>deb<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;+</span> ms<span style="color:#f92672">.</span>deb<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;+</span> unifi<span style="color:#f92672">-</span>user<span style="color:#f92672">-</span>assets<span style="color:#f92672">.</span>deb<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;+</span> unifi<span style="color:#f92672">-</span>face<span style="color:#f92672">-</span>shared<span style="color:#f92672">-</span>lib<span style="color:#f92672">.</span>deb]
</span></span><span style="display:flex;"><span>    G <span style="color:#f92672">--&gt;</span> H[final image]
</span></span></code></pre></div>
</details>

<p>The Protect repo's pattern handles most of the heavy lifting. Access required a small number of additional patches inside the image, baked in at build time. These exist as <code>if ! sed -i ... exit 1</code> guards so a future firmware version that drifts from the pattern fails the build loudly instead of silently breaking at runtime.</p>
<h3 id="patch-1-ustorage-grpc-fallback">Patch 1: ustorage gRPC fallback</h3>
<p>UniFi Core polls a gRPC server on <code>127.0.0.1:11052</code> for storage information. That server only exists on real UniFi consoles. Without intervention, the setup wizard fails with:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>[grpc] Failed to connect to the gRPC server: 14 UNAVAILABLE:
</span></span><span style="display:flex;"><span>No connection established. Last error: Error: connect ECONNREFUSED 127.0.0.1:11052.
</span></span><span style="display:flex;"><span>   at ServiceClientImpl.storageSettings (...)
</span></span></code></pre></div><p>Protect's sed flips a JS conditional so the application takes the &quot;shell ustorage&quot; path instead:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sed -i <span style="color:#e6db74">&#39;/return at()?s.push/{s//return at(),!0?s.push/;h};${x;/./{x;q0};x;q1}&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    /usr/share/unifi-core/app/service.js
</span></span></code></pre></div><p>The patched path calls <code>/usr/bin/ustorage</code> and <code>/sbin/mdadm</code>, which I ship as fake responses generating plausible disk/RAID JSON.</p>
<h3 id="patch-2-pre-setup-nginx-hostname-allowlist">Patch 2: pre-setup nginx hostname allowlist</h3>
<p>UniFi Core ships with a pre-setup nginx that redirects anything except <code>unifi</code>, <code>localhost</code>, or a raw IP to <code>https://unifi</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#e6db74">(</span>$host <span style="color:#e6db74">!~*</span> <span style="color:#e6db74">^(unifi|localhost|[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+|\[[a-f0-9:]+\])</span>$<span style="color:#e6db74">)</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">return</span> <span style="color:#ae81ff">302</span> $scheme://unifi;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A reverse-proxied hostname like <code>access.example.com</code> hits this regex and bounces. The build relaxes it so any non-empty <code>$host</code> passes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sed -i <span style="color:#e6db74">&#39;s|host !~\* \^(unifi|host !~* ^(.+|&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    /usr/share/unifi-core/http/site-setup.conf
</span></span></code></pre></div><p>The file is then in <code>/usr/share/...</code>, which is the template directory. After a factory reset, UniFi Core copies the template into <code>/data/unifi-core/config/http/</code>, preserving the patch.</p>
<h3 id="patch-3-console-identity">Patch 3: console identity</h3>
<p>The hardware identity layer in UniFi OS is a script at <code>/sbin/ubnt-tools</code>. The original <code>ubnt-tools id</code> reports the actual board sysid. My shim writes a configurable identity based on the <code>DEVICE</code> env var (<code>UNVR</code>, <code>UCG_MAX</code>, <code>UDM_PRO_MAX</code>, etc).</p>
<p>The first attempt used <code>UCG_MAX</code> because the extracted firmware was UCG-Max. The setup wizard immediately broke with &quot;Unexpected error during setup&quot; because UCG-Max has <code>hasGateway: true</code> in its features and the wizard tried to run gateway-only steps (WAN probe, UDAPI client) that fail in a container. Switching the identity to <code>UNVR</code> (sysid <code>0xea16</code>, no gateway features) made the wizard skip those steps. That matches what Protect users do in docker.</p>
<h3 id="what-the-post-install-block-actually-applies">What the post-install block actually applies</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">RUN</span> echo <span style="color:#e6db74">&#39;exit 0&#39;</span> &gt; /usr/sbin/policy-rc.d <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    <span style="color:#f92672">&amp;&amp;</span> <span style="color:#66d9ef">if</span> ! sed -i <span style="color:#e6db74">&#39;/return at()?s.push/{s//return at(),!0?s.push/;h};${x;/./{x;q0};x;q1}&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>         /usr/share/unifi-core/app/service.js; <span style="color:#66d9ef">then</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>         echo <span style="color:#e6db74">&#39;ERROR: ustorage sed pattern not found&#39;</span> <span style="color:#f92672">&amp;&amp;</span> exit 1; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fi</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    <span style="color:#f92672">&amp;&amp;</span> <span style="color:#66d9ef">if</span> ! sed -i <span style="color:#e6db74">&#39;s|host !~\* \^(unifi|host !~* ^(.+|&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>         /usr/share/unifi-core/http/site-setup.conf; <span style="color:#66d9ef">then</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>         echo <span style="color:#e6db74">&#39;ERROR: site-setup.conf hostname patch failed&#39;</span> <span style="color:#f92672">&amp;&amp;</span> exit 1; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fi</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    <span style="color:#f92672">&amp;&amp;</span> mv /sbin/mdadm /sbin/mdadm.orig <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    <span style="color:#f92672">&amp;&amp;</span> mv /sbin/ubnt-tools /sbin/ubnt-tools.orig <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    <span style="color:#f92672">&amp;&amp;</span> systemctl enable storage_disk dbpermissions fix_hosts <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>         fix_apt_ubiquiti_sources init_console init_device <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    <span style="color:#f92672">&amp;&amp;</span> echo -e <span style="color:#e6db74">&#39;\n\nexport PGHOST=127.0.0.1\n&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>         &gt;&gt; /usr/lib/ulp-go/scripts/envs.sh<span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>That covers it. Image is ~2.4 GB, runs as the Protect-pattern Debian-11 systemd container, with <code>unifi-access.service</code>, <code>unifi-core.service</code>, and two PostgreSQL clusters (<code>main</code> and <code>access</code> on ports 5432 and 5435).</p>
<hr>
<h2 id="vxlan-underlay">VXLAN Underlay</h2>
<p>The VXLAN tunnel uses UDP 4789. With both endpoints having explicit unicast addresses, the inner Ethernet frames travel inside outer UDP packets between <code>10.255.255.1</code> (Mikrotik LAN side) and <code>172.31.26.37</code> (EC2 private IP).</p>
<p>The existing IPSec policy is <code>10.255.255.0/24 ↔ 172.31.26.37/32</code>. VXLAN outer packets between those two addresses match the policy, so they ride encrypted inside ESP. No new IPSec policy was needed; the L2 extension reuses the existing L3 tunnel as its transport.</p>
<h3 id="mikrotik-side">Mikrotik side</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>/interface vxlan
</span></span><span style="display:flex;"><span>add name=vxlan-aws vni=100 port=4789 mtu=1380 local-address=10.255.255.1
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>/interface vxlan vteps
</span></span><span style="display:flex;"><span>add interface=vxlan-aws remote-ip=172.31.26.37
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>/interface bridge port
</span></span><span style="display:flex;"><span>add bridge=bridge interface=vxlan-aws
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>/ip firewall filter
</span></span><span style="display:flex;"><span>add chain=input action=accept protocol=udp dst-port=4789 \
</span></span><span style="display:flex;"><span>    src-address=172.31.26.37/32 place-before=0 \
</span></span><span style="display:flex;"><span>    comment=&#34;VXLAN from AWS via IPSec&#34;
</span></span></code></pre></div><p>The <code>local-address=10.255.255.1</code> is important. Without it, Mikrotik picks the source IP for VXLAN outer packets based on the egress interface (the WAN), and the resulting tuple does not match the IPSec policy. Forcing the LAN IP keeps everything inside the policy's transform set.</p>
<h3 id="ec2-side">EC2 side</h3>
<p>The first attempt looked like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ip link add vxlan0 type vxlan id <span style="color:#ae81ff">100</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    remote 10.255.255.1 local 172.31.26.37 dstport <span style="color:#ae81ff">4789</span> nolearning
</span></span><span style="display:flex;"><span>ip link set vxlan0 master br-office
</span></span></code></pre></div><p>VXLAN packets <em>arrived</em> from Mikrotik. Nothing went the other way. The kernel was silently dropping outbound encapsulation.</p>
<p>Two issues:</p>
<p><strong><code>nolearning</code> plus an empty FDB.</strong> With learning off and no manual entries, the kernel had no idea where to send broadcasts and unknown unicast. The fix is to add a default flood entry:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>bridge fdb append 00:00:00:00:00:00 dev vxlan0 dst 10.255.255.1
</span></span></code></pre></div><p><strong>No explicit <code>dev</code>.</strong> Without <code>dev ens5</code>, the kernel does a generic route lookup for the outer destination <code>10.255.255.1</code> and finds that <code>10.255.255.0/24</code> is reachable through <code>br-office</code>. The VXLAN outer packet routes back into the bridge, where it loops. The fix is to bind the underlay to a specific egress device:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ip link add vxlan0 type vxlan id <span style="color:#ae81ff">100</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    remote 10.255.255.1 local 172.31.26.37 dstport <span style="color:#ae81ff">4789</span> dev ens5
</span></span></code></pre></div><p>With both fixes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ip link add br-office type bridge
</span></span><span style="display:flex;"><span>ip link set br-office mtu <span style="color:#ae81ff">1380</span> up
</span></span><span style="display:flex;"><span>ip addr add 10.255.255.50/24 dev br-office
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ip link add vxlan0 type vxlan id <span style="color:#ae81ff">100</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    remote 10.255.255.1 local 172.31.26.37 dstport <span style="color:#ae81ff">4789</span> dev ens5
</span></span><span style="display:flex;"><span>ip link set vxlan0 mtu <span style="color:#ae81ff">1380</span> master br-office up
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>bridge fdb append 00:00:00:00:00:00 dev vxlan0 dst 10.255.255.1
</span></span></code></pre></div><p>A <code>tcpdump</code> on <code>ens5</code> after this is more honest than any documentation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>22:24:52.110815 IP 172.31.26.37.4500 &gt; 194.126.118.207.4500:
</span></span><span style="display:flex;"><span>    UDP-encap: ESP(spi=0x00607012,seq=0x80), length 120
</span></span><span style="display:flex;"><span>22:24:52.252695 IP 194.126.118.207.4500 &gt; 172.31.26.37.4500:
</span></span><span style="display:flex;"><span>    UDP-encap: ESP(spi=0xc291d279,seq=0x186), length 136
</span></span><span style="display:flex;"><span>22:24:52.252695 IP 10.255.255.1.50999 &gt; 172.31.26.37.4789:
</span></span><span style="display:flex;"><span>    VXLAN, flags [I] (0x08), vni 100
</span></span><span style="display:flex;"><span>    STP 802.1w, Rapid STP, ...
</span></span><span style="display:flex;"><span>22:24:55.861464 IP 10.255.255.1.48863 &gt; 172.31.26.37.4789:
</span></span><span style="display:flex;"><span>    VXLAN, flags [I] (0x08), vni 100
</span></span><span style="display:flex;"><span>    ARP, Request who-has 10.255.255.230 tell 10.255.255.230
</span></span></code></pre></div><p>STP frames and the Hub Mini's gratuitous ARP both arrive over the encrypted VXLAN tunnel. The L2 extension is alive.</p>
<p>A useful red herring during this debugging: the IPSec tunnel uses NAT-T because the EC2 instance lives behind AWS's network address translation. So encrypted traffic shows up as <code>udp port 4500</code>, not raw ESP. A <code>tcpdump</code> filter that only matches <code>esp</code> returns nothing.</p>
<h3 id="persistence">Persistence</h3>
<p>VXLAN created with <code>ip link</code> does not survive a reboot. A small systemd unit owns the lifecycle:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e"># /etc/systemd/system/vxlan-office.service</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Unit]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Description</span><span style="color:#f92672">=</span><span style="color:#e6db74">VXLAN office-bridge to Mikrotik (L2 extension over IPSec)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">After</span><span style="color:#f92672">=</span><span style="color:#e6db74">network.target strongswan-starter.service</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Wants</span><span style="color:#f92672">=</span><span style="color:#e6db74">strongswan-starter.service</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Service]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Type</span><span style="color:#f92672">=</span><span style="color:#e6db74">oneshot</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">RemainAfterExit</span><span style="color:#f92672">=</span><span style="color:#e6db74">yes</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ExecStart</span><span style="color:#f92672">=</span><span style="color:#e6db74">/usr/local/sbin/vxlan-office-up.sh</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ExecStop</span><span style="color:#f92672">=</span><span style="color:#e6db74">/usr/local/sbin/vxlan-office-down.sh</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Install]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">WantedBy</span><span style="color:#f92672">=</span><span style="color:#e6db74">multi-user.target</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># /usr/local/sbin/vxlan-office-up.sh</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#!/bin/bash</span>
</span></span><span style="display:flex;"><span>set -e
</span></span><span style="display:flex;"><span>ip link show br-office &gt;/dev/null 2&gt;&amp;<span style="color:#ae81ff">1</span> <span style="color:#f92672">||</span> ip link add br-office type bridge
</span></span><span style="display:flex;"><span>ip link set br-office mtu <span style="color:#ae81ff">1380</span> up
</span></span><span style="display:flex;"><span>ip addr add 10.255.255.50/24 dev br-office 2&gt;/dev/null <span style="color:#f92672">||</span> true
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ip link del vxlan0 2&gt;/dev/null <span style="color:#f92672">||</span> true
</span></span><span style="display:flex;"><span>ip link add vxlan0 type vxlan id <span style="color:#ae81ff">100</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    remote 10.255.255.1 local 172.31.26.37 dstport <span style="color:#ae81ff">4789</span> dev ens5
</span></span><span style="display:flex;"><span>ip link set vxlan0 mtu <span style="color:#ae81ff">1380</span> master br-office up
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>bridge fdb append 00:00:00:00:00:00 dev vxlan0 dst 10.255.255.1
</span></span></code></pre></div><p><code>Wants=strongswan-starter.service</code> ensures the IPSec tunnel comes up first. Without that ordering, the VXLAN socket is open before the underlay can carry packets, and Mikrotik silently drops the first batch.</p>
<hr>
<h2 id="mtu-accounting">MTU Accounting</h2>
<p>The encapsulation chain adds bytes at three layers. Counting them honestly:</p>
<table>
  <thead>
      <tr>
          <th>Layer</th>
          <th>Overhead</th>
          <th>Notes</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Outer Ethernet</td>
          <td>14</td>
          <td>Standard</td>
      </tr>
      <tr>
          <td>Outer IP</td>
          <td>20</td>
          <td>IPv4</td>
      </tr>
      <tr>
          <td>Outer UDP (VXLAN)</td>
          <td>8</td>
          <td>dstport 4789</td>
      </tr>
      <tr>
          <td>VXLAN header</td>
          <td>8</td>
          <td>VNI</td>
      </tr>
      <tr>
          <td>Inner Ethernet</td>
          <td>14</td>
          <td>The frame the bridge actually carries</td>
      </tr>
      <tr>
          <td>IPSec NAT-T overhead</td>
          <td>~30</td>
          <td>UDP 4500 + ESP header + IV + ICV</td>
      </tr>
  </tbody>
</table>
<p>A 1500-byte underlay leaves 1500 − (20+8+8+14+30) ≈ <strong>1420</strong> bytes for the inner payload that the macvlan-attached container can use. I chose <strong>1380</strong> as the bridge/VXLAN MTU to leave headroom and avoid path-MTU surprises.</p>
<p>If you do not set MTU on both <code>br-office</code> and <code>vxlan-aws</code>, TCP traffic over the L2 bridge will hang under load when packets fragment and the IPSec path drops the fragments.</p>
<hr>
<h2 id="docker-multi-network-trap">Docker Multi-Network Trap</h2>
<p>Two networks were needed:</p>
<ul>
<li>A <strong>default bridge</strong> for outbound traffic and Internet access (image pulls, ACME, NTP, etc).</li>
<li>The <strong>office-lan macvlan</strong> with parent=<code>br-office</code>, so the container gets a 10.255.255.x address on the bridged L2.</li>
</ul>
<p>The natural Compose definition for that is:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">unifi-access</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">unifi-access-docker-arm64:stable</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">default</span>: {}
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">office-lan</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">ipv4_address</span>: <span style="color:#ae81ff">10.255.255.200</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;443:443/tcp&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;80:80/tcp&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8080:8080/tcp&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;12442-12443:12442-12443/tcp&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">default</span>: {}
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">office-lan</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>This deploys. The container starts, gets both NICs, listens on the right ports inside the container. From <code>docker ps</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>NAMES          PORTS
</span></span><span style="display:flex;"><span>unifi-access   80/tcp, 443/tcp, 8080/tcp, 12442-12443/tcp
</span></span></code></pre></div><p>That output is wrong. There is no <code>0.0.0.0:443-&gt;443/tcp</code>. The ports are <em>exposed</em> but not <em>published</em>. On the host, <code>ss -tlnp</code> shows no docker-proxy listening anywhere.</p>
<p>This is the docker-compose-v2 multi-network port-publish bug. When a service has both a bridge and a macvlan network declared in the same Compose service, the <code>ports:</code> block silently drops to &quot;expose only&quot;.</p>
<p>The workaround is a two-phase deploy: Compose declares only the bridge with ports, and the macvlan is attached post-up:</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/compose-two-phase.png" alt="Two-phase Docker network attach"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>flowchart LR
</span></span><span style="display:flex;"><span>    A[Compose file&lt;br/&gt;default bridge + ports] --&gt; B[docker compose up -d]
</span></span><span style="display:flex;"><span>    B --&gt; C[Ports published&lt;br/&gt;through docker-proxy]
</span></span><span style="display:flex;"><span>    C --&gt; D[docker network connect&lt;br/&gt;--ip 10.255.255.200 office-lan]
</span></span><span style="display:flex;"><span>    D --&gt; E[Container has two NICs&lt;br/&gt;eth0 bridge + eth1 macvlan]
</span></span></code></pre></div>
</details>

<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose -f docker-compose.cloud.yml up -d
</span></span><span style="display:flex;"><span>docker network connect --ip 10.255.255.200 office-lan unifi-access
</span></span></code></pre></div><p>After this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>NAMES          PORTS
</span></span><span style="display:flex;"><span>unifi-access   0.0.0.0:443-&gt;443/tcp, 0.0.0.0:80-&gt;80/tcp, ...
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>$ docker exec unifi-access ip -brief addr
</span></span><span style="display:flex;"><span>eth0@if31  UP  172.17.0.2/16
</span></span><span style="display:flex;"><span>eth1@if15  UP  10.255.255.200/24
</span></span></code></pre></div><p>UniFi Core's nginx listens on <code>0.0.0.0:443</code> inside the container. It binds on both NICs. Cloudflare can reach the EIP through the bridge NIC via docker-proxy. The Hub Mini at <code>10.255.255.230</code> can reach the controller directly through the macvlan NIC because both are on the same L2 broadcast domain.</p>
<hr>
<h2 id="then-i-broke-it">Then I Broke It</h2>
<p>The Cloudflare proxy returned 521 (&quot;origin unreachable&quot;) a little later. The web path through the EIP had stopped working.</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/docker-proxy-dnat.png" alt="Docker port publish DNAT conflict"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>flowchart TB
</span></span><span style="display:flex;"><span>    subgraph Internet
</span></span><span style="display:flex;"><span>        CF[Cloudflare&lt;br/&gt;proxying access.example.com]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>    subgraph EC2[&#34;EC2 host network&#34;]
</span></span><span style="display:flex;"><span>        ens5[ens5&lt;br/&gt;172.31.26.37&lt;br/&gt;EIP-mapped]
</span></span><span style="display:flex;"><span>        iptables[iptables PREROUTING&lt;br/&gt;DNAT tcp/443 → 172.17.0.2:443]
</span></span><span style="display:flex;"><span>        docker0[docker0&lt;br/&gt;172.17.0.1]
</span></span><span style="display:flex;"><span>        broffice[br-office&lt;br/&gt;10.255.255.50]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>    subgraph Container[&#34;unifi-access container&#34;]
</span></span><span style="display:flex;"><span>        eth0[eth0&lt;br/&gt;172.17.0.2]
</span></span><span style="display:flex;"><span>        eth1[eth1&lt;br/&gt;10.255.255.200]
</span></span><span style="display:flex;"><span>        nginx[nginx :443&lt;br/&gt;0.0.0.0]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>    subgraph Office
</span></span><span style="display:flex;"><span>        LAN[Office host&lt;br/&gt;e.g. Synology]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    CF -- &#34;TLS to EIP&#34; --&gt; ens5
</span></span><span style="display:flex;"><span>    ens5 --&gt; iptables
</span></span><span style="display:flex;"><span>    iptables --&gt; docker0
</span></span><span style="display:flex;"><span>    docker0 --&gt; eth0
</span></span><span style="display:flex;"><span>    eth0 --&gt; nginx
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    LAN -- &#34;via VXLAN-over-IPSec&#34; --&gt; broffice
</span></span><span style="display:flex;"><span>    broffice -- &#34;expected path&#34; --&gt; eth1
</span></span><span style="display:flex;"><span>    broffice -. &#34;iptables PREROUTING wins:&lt;br/&gt;also DNATs to docker0!&#34; .-&gt; iptables
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    style iptables fill:#f66
</span></span></code></pre></div>
</details>

<p>The cause was docker-proxy itself. When <code>-p 443:443</code> publishes a port, docker installs an iptables DNAT rule that matches <strong>destination port</strong> without constraining the destination IP. The rule looks roughly like:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>DNAT  tcp  *  *  0.0.0.0/0  0.0.0.0/0  tcp dpt:443  to:172.17.0.2:443
</span></span></code></pre></div><p>That matches traffic destined for the EIP and also matches traffic destined for the macvlan IP <code>10.255.255.200</code>. From the office, a request to <code>https://10.255.255.200</code> arrives on <code>br-office</code>, gets DNATed to <code>172.17.0.2:443</code>, and the kernel forwards it to the docker bridge instead of delivering it to the macvlan NIC that the LAN client expected.</p>
<p>The two access paths conflict. The docker-proxy DNAT wins because PREROUTING runs before the bridge forwarding decision.</p>
<p>The fix had two parts:</p>
<p><strong>Remove docker-proxy port publishing entirely.</strong> No <code>-p</code> flags, no <code>ports:</code> in compose. The container is reachable only through its macvlan IP on the office LAN.</p>
<p><strong>Front the web tier through the existing Synology Traefik instead.</strong> The Synology lives on the same office LAN as the macvlan. From Traefik's perspective, the controller looks like any other LAN host; it just happens to be answered by something in AWS:</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/traefik-access-path.png" alt="Traefik reaches Access through the office LAN"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>flowchart LR
</span></span><span style="display:flex;"><span>    Browser --&gt; CF[Cloudflare&lt;br/&gt;access.example.com]
</span></span><span style="display:flex;"><span>    CF --&gt; Synology[Synology Traefik&lt;br/&gt;10.255.255.245]
</span></span><span style="display:flex;"><span>    Synology -. &#34;via office LAN&#34; .-&gt; Container[unifi-access&lt;br/&gt;10.255.255.200&lt;br/&gt;via macvlan/VXLAN]
</span></span></code></pre></div>
</details>

<p>The Security Group on the EC2 also got much smaller. Web access does not enter through the EIP anymore, so 80/443 from the Internet are not needed. Access ports do not need direct allowlists from the office subnet either, because that traffic now arrives encapsulated as VXLAN inside IPSec - the SG only sees the outer IPSec/UDP-4500 packets.</p>
<p>Final inbound rules:</p>
<table>
  <thead>
      <tr>
          <th>Proto</th>
          <th>Port</th>
          <th>Source</th>
          <th>Purpose</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UDP</td>
          <td>500</td>
          <td>Mikrotik public IP</td>
          <td>IKE</td>
      </tr>
      <tr>
          <td>UDP</td>
          <td>4500</td>
          <td>Mikrotik public IP</td>
          <td>IPSec NAT-T</td>
      </tr>
      <tr>
          <td>ESP (50)</td>
          <td>-</td>
          <td>Mikrotik public IP</td>
          <td>ESP</td>
      </tr>
      <tr>
          <td>ICMP</td>
          <td>-</td>
          <td>10.255.255.0/24</td>
          <td>Diagnostics over L2</td>
      </tr>
  </tbody>
</table>
<p>Everything else is denied. The controller has no Internet-exposed application surface. Its public identity is mediated by Cloudflare and Synology Traefik, not by an Internet-facing socket on the controller itself.</p>
<hr>
<h2 id="the-discovery-handshake-live">The Discovery Handshake, Live</h2>
<p>After the L2 bridge and macvlan attachment, <code>tcpdump</code> on <code>vxlan0</code> from the EC2 side captured the full UniFi discovery dance:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>22:30:58.954333 IP 10.255.255.200.35510 &gt; 233.89.188.1.10001:
</span></span><span style="display:flex;"><span>    UDP, length 4
</span></span><span style="display:flex;"><span>22:30:58.954387 IP 10.255.255.200.35510 &gt; 10.255.255.255.10001:
</span></span><span style="display:flex;"><span>    UDP, length 4
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>22:30:58.965433 IP 10.255.255.230.10001 &gt; 10.255.255.200.35510:
</span></span><span style="display:flex;"><span>    UDP, length 241
</span></span><span style="display:flex;"><span>22:30:58.976442 IP 10.255.255.229.10001 &gt; 10.255.255.200.35510:
</span></span><span style="display:flex;"><span>    UDP, length 212
</span></span><span style="display:flex;"><span>22:30:58.977665 IP 10.255.255.229.44119 &gt; 255.255.255.255.10001:
</span></span><span style="display:flex;"><span>    UDP, length 212
</span></span></code></pre></div><p>The first two lines are the controller (10.255.255.200) sending its discovery probes to UniFi's multicast group <code>233.89.188.1:10001</code> and the LAN broadcast <code>255.255.255.255:10001</code>. The next three are the Hub Mini at <code>.230</code> and the G3 Reader at <code>.229</code> responding directly with their identity payloads.</p>
<p>This is the protocol working as Ubiquiti designed it. Nothing about the controller's actual location in AWS is visible from this trace; the L2 extension makes the topology look local.</p>
<p>Sessions established cleanly after that:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>$ docker exec unifi-access ss -tnp state established | grep 10.255
</span></span><span style="display:flex;"><span>tcp 0  26400  10.255.255.200:12443  10.255.255.230:51802  unifi-access-ap
</span></span><span style="display:flex;"><span>tcp 0  0      10.255.255.200:12812  10.255.255.230:51799  unifi-access-ap
</span></span></code></pre></div><p>The Hub Mini holds two TCP sessions to the controller: 12443 for the Access UI/API channel and 12812 for MQTT control commands. Real-time door operations flow over 12812.</p>
<hr>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/discovery-handshake.png" alt="Live UniFi Access discovery handshake"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-gdscript3" data-lang="gdscript3"><span style="display:flex;"><span>sequenceDiagram
</span></span><span style="display:flex;"><span>    participant <span style="color:#a6e22e">Container</span> as unifi<span style="color:#f92672">-</span>access<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span><span style="color:#ae81ff">10.255</span><span style="color:#f92672">.</span><span style="color:#ae81ff">255.200</span><span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>(macvlan eth1)
</span></span><span style="display:flex;"><span>    participant Bridge as br<span style="color:#f92672">-</span>office bridge
</span></span><span style="display:flex;"><span>    participant VXLAN as vxlan0 <span style="color:#f92672">/</span> IPSec
</span></span><span style="display:flex;"><span>    participant Hub as Door Hub Mini<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span><span style="color:#ae81ff">10.255</span><span style="color:#f92672">.</span><span style="color:#ae81ff">255.230</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Note over <span style="color:#a6e22e">Container</span>,Hub: Controller side periodic discovery
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Container</span><span style="color:#f92672">-&gt;&gt;</span>Bridge: UDP src<span style="color:#f92672">=</span><span style="color:#ae81ff">10.255</span><span style="color:#f92672">.</span><span style="color:#ae81ff">255.200</span>:<span style="color:#ae81ff">35510</span><span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>dst<span style="color:#f92672">=</span><span style="color:#ae81ff">233.89</span><span style="color:#f92672">.</span><span style="color:#ae81ff">188.1</span>:<span style="color:#ae81ff">10001</span> (multicast)
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Container</span><span style="color:#f92672">-&gt;&gt;</span>Bridge: UDP src<span style="color:#f92672">=</span><span style="color:#ae81ff">10.255</span><span style="color:#f92672">.</span><span style="color:#ae81ff">255.200</span>:<span style="color:#ae81ff">35510</span><span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>dst<span style="color:#f92672">=</span><span style="color:#ae81ff">255.255</span><span style="color:#f92672">.</span><span style="color:#ae81ff">255.255</span>:<span style="color:#ae81ff">10001</span> (broadcast)
</span></span><span style="display:flex;"><span>    Bridge<span style="color:#f92672">-&gt;&gt;</span>VXLAN: encap, encrypt, send
</span></span><span style="display:flex;"><span>    VXLAN<span style="color:#f92672">-&gt;&gt;</span>Hub: arrives on office L2
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Note over Hub: Hub Mini recognizes UniFi probe
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Hub<span style="color:#f92672">-&gt;&gt;</span>VXLAN: UDP src<span style="color:#f92672">=</span><span style="color:#ae81ff">10.255</span><span style="color:#f92672">.</span><span style="color:#ae81ff">255.230</span>:<span style="color:#ae81ff">10001</span><span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>dst<span style="color:#f92672">=</span><span style="color:#ae81ff">10.255</span><span style="color:#f92672">.</span><span style="color:#ae81ff">255.200</span>:<span style="color:#ae81ff">35510</span><span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>length <span style="color:#ae81ff">241</span> (identity payload)
</span></span><span style="display:flex;"><span>    VXLAN<span style="color:#f92672">-&gt;&gt;</span>Bridge: decap on AWS
</span></span><span style="display:flex;"><span>    Bridge<span style="color:#f92672">-&gt;&gt;</span><span style="color:#a6e22e">Container</span>: delivered to eth1
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Note over <span style="color:#a6e22e">Container</span>,Hub: Three more replies follow (Hub Mini retries)<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>then G3 Reader at <span style="color:#f92672">.</span><span style="color:#ae81ff">229</span> also responds
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Container</span><span style="color:#f92672">-&gt;&gt;</span>Hub: TCP <span style="color:#ae81ff">12443</span> SYN (Access UI<span style="color:#f92672">/</span>API channel)
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Container</span><span style="color:#f92672">-&gt;&gt;</span>Hub: MQTT <span style="color:#ae81ff">12812</span> SYN (control plane)
</span></span><span style="display:flex;"><span>    Hub<span style="color:#f92672">--&gt;&gt;</span><span style="color:#a6e22e">Container</span>: <span style="color:#960050;background-color:#1e0010">✓</span> both sessions established
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    rect rgb(<span style="color:#ae81ff">230</span>, <span style="color:#ae81ff">255</span>, <span style="color:#ae81ff">230</span>)
</span></span><span style="display:flex;"><span>        Note over <span style="color:#a6e22e">Container</span>,Hub: Device adopted<span style="color:#f92672">.</span> Doors operate<span style="color:#f92672">.</span>
</span></span><span style="display:flex;"><span>    end
</span></span></code></pre></div>
</details>

<h2 id="mikrotik-bridge-counters-lie">Mikrotik Bridge Counters Lie</h2>
<p>A late-night debugging detour cost a real hour:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>/interface print stats where name=vxlan-aws
</span></span><span style="display:flex;"><span>0 RS vxlan-aws   RX-BYTE=0   TX-BYTE=28894   TX-PACKET=410
</span></span></code></pre></div><p>The RX counter on vxlan-aws was zero. The TX counter was incrementing rapidly. By all visible evidence, traffic was leaving Mikrotik for AWS but nothing was coming back.</p>
<p>The bridge host table told a different story:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>/interface bridge host print where on-interface=vxlan-aws
</span></span><span style="display:flex;"><span>#  MAC-ADDRESS         BRIDGE  REMOTE-IP
</span></span><span style="display:flex;"><span>0  22:0C:29:0C:2F:11   bridge  (local)
</span></span><span style="display:flex;"><span>1  4A:51:CF:46:03:91   bridge  172.31.26.37
</span></span><span style="display:flex;"><span>2  62:F0:60:4C:FB:10   bridge  172.31.26.37
</span></span></code></pre></div><p>The bridge had clearly learned the AWS-side MAC addresses, including the docker container's macvlan MAC, with REMOTE-IP correctly pointing at the EC2 private address. That can only happen from incoming VXLAN packets.</p>
<p>On RouterOS 7.19.1 in this setup, <code>/interface print stats</code> lied for the VXLAN interface. The bridge-level statistics were correct. Trust <code>/interface bridge host</code> and <code>/interface bridge port print stats</code>, not <code>/interface print stats</code> when troubleshooting VXLAN.</p>
<hr>
<h2 id="why-the-container-has-two-nics">Why the Container Has Two NICs</h2>
<p>The container ends up with two interfaces inside it, and both matter for different reasons:</p>
<table>
  <thead>
      <tr>
          <th>Interface</th>
          <th>Subnet</th>
          <th>Used by</th>
          <th>Why it exists</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>eth0</code></td>
          <td>172.17.0.0/16 (docker bridge)</td>
          <td>outbound HTTPS, ACME-ish lookups, container's own Internet</td>
          <td>Default route, gives the container Internet access</td>
      </tr>
      <tr>
          <td><code>eth1</code></td>
          <td>10.255.255.0/24 (macvlan / VXLAN bridge)</td>
          <td>UDP 10001 discovery, MQTT 12812, Access UI 12443, ARP</td>
          <td>The actual L2 identity that door hardware sees</td>
      </tr>
  </tbody>
</table>
<p>The application binds on <code>0.0.0.0</code> so it answers on both. The L2 broadcast traffic only arrives on <code>eth1</code>. Outbound DNS, cert refreshes, ulp-go cloud chatter all leave through <code>eth0</code>'s default route, get NATed by docker bridge to the host's ens5, and reach the Internet without crossing the IPSec tunnel.</p>
<p>That separation also means a Synology Traefik route, talking to the controller as <code>10.255.255.200</code>, never touches AWS billing for egress. Everything in the office LAN ↔ controller direction stays on LAN-equivalent ports of the office bridge, which is just a VXLAN tunnel that happens to encrypt over the AWS NAT-T path.</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/24/unifi-access-vxlan-over-ipsec/container-nics.png" alt="Container interfaces and traffic split"  />
</p>
</p>
<details>
  <summary>mermaid</summary>
  <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>flowchart LR
</span></span><span style="display:flex;"><span>    subgraph Container[&#34;unifi-access container&#34;]
</span></span><span style="display:flex;"><span>        direction TB
</span></span><span style="display:flex;"><span>        Apps[unifi-core + unifi-access&lt;br/&gt;bind 0.0.0.0]
</span></span><span style="display:flex;"><span>        eth0[eth0&lt;br/&gt;172.17.0.2/16&lt;br/&gt;docker bridge]
</span></span><span style="display:flex;"><span>        eth1[eth1&lt;br/&gt;10.255.255.200/24&lt;br/&gt;macvlan office-lan]
</span></span><span style="display:flex;"><span>        Apps --&gt; eth0
</span></span><span style="display:flex;"><span>        Apps --&gt; eth1
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    subgraph Internet[&#34;Outbound flows&#34;]
</span></span><span style="display:flex;"><span>        NAT[docker NAT&lt;br/&gt;default via 172.17.0.1]
</span></span><span style="display:flex;"><span>        FW[fw-update.ubnt.com&lt;br/&gt;NTP, DNS]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    subgraph Office[&#34;Office L2 via VXLAN/IPSec&#34;]
</span></span><span style="display:flex;"><span>        Hub[Door Hub Mini&lt;br/&gt;10.255.255.230]
</span></span><span style="display:flex;"><span>        Reader[G3 Reader&lt;br/&gt;10.255.255.229]
</span></span><span style="display:flex;"><span>        Synology[Synology Traefik&lt;br/&gt;10.255.255.245]
</span></span><span style="display:flex;"><span>    end
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    eth0 --&gt; NAT --&gt; FW
</span></span><span style="display:flex;"><span>    eth1 --&gt; Hub
</span></span><span style="display:flex;"><span>    eth1 --&gt; Reader
</span></span><span style="display:flex;"><span>    Synology --&gt; eth1
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    linkStyle 2 stroke:#999,stroke-dasharray:4 3
</span></span><span style="display:flex;"><span>    linkStyle 3 stroke:#999,stroke-dasharray:4 3
</span></span><span style="display:flex;"><span>    linkStyle 4 stroke:#999,stroke-dasharray:4 3
</span></span></code></pre></div>
</details>

<hr>
<h2 id="what-i-would-validate-before-trusting-it">What I Would Validate Before Trusting It</h2>
<p>Working in this layered topology means there are several places where a single configuration error gives the wrong-but-plausible answer. I would not trust the design until all of these pass on a clean restart:</p>
<p><strong>IPSec underlay:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ip xfrm state | grep -A2 reqid
</span></span><span style="display:flex;"><span>ipsec statusall | grep -E <span style="color:#e6db74">&#34;ESTABLISHED|INSTALLED&#34;</span>
</span></span></code></pre></div><p><strong>VXLAN encapsulation:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># On EC2: outer encrypted packets should be flowing both directions</span>
</span></span><span style="display:flex;"><span>tcpdump -ni ens5 <span style="color:#e6db74">&#34;udp port 4500 or udp port 4789&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Inside the tunnel: bridge-relevant frames</span>
</span></span><span style="display:flex;"><span>tcpdump -ni vxlan0 <span style="color:#e6db74">&#34;udp port 10001 or arp&#34;</span>
</span></span></code></pre></div><p><strong>Bridge state, both ends:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># AWS side</span>
</span></span><span style="display:flex;"><span>bridge fdb show dev vxlan0
</span></span><span style="display:flex;"><span>ip neigh show dev br-office
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Mikrotik side</span>
</span></span><span style="display:flex;"><span>/interface bridge host print where on-interface<span style="color:#f92672">=</span>vxlan-aws
</span></span></code></pre></div><p><strong>End-to-end L2 reachability:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># From AWS host</span>
</span></span><span style="display:flex;"><span>ping -I br-office 10.255.255.230   <span style="color:#75715e"># the Hub Mini</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># From Mikrotik</span>
</span></span><span style="display:flex;"><span>/ping address<span style="color:#f92672">=</span>10.255.255.200 src-address<span style="color:#f92672">=</span>10.255.255.1
</span></span></code></pre></div><p><strong>UniFi-level adoption:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker exec unifi-access ss -tnp state established | grep -E <span style="color:#e6db74">&#34;:(12443|12812)&#34;</span>
</span></span><span style="display:flex;"><span>docker exec unifi-access journalctl -u unifi-access --since <span style="color:#e6db74">&#34;5 minutes ago&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>    | grep -iE <span style="color:#e6db74">&#34;adopt|inform|84:78&#34;</span>
</span></span></code></pre></div><p>If any of these regress, the layer that broke is usually obvious from where the trace stops.</p>
<hr>
<h2 id="what-this-design-commits-you-to">What This Design Commits You To</h2>
<p>This design gives a cloud-hosted Access controller adoption parity with a local one, at the cost of explicit network plumbing.</p>
<p>The useful properties:</p>
<ul>
<li>The G3 Door Hub Mini adopts and operates exactly as it would on a Ubiquiti console - no fork of its firmware, no SSH, no manual <code>set-inform</code>.</li>
<li>The controller container is unreachable from the Internet directly. The only public surface is what Cloudflare and Synology Traefik expose.</li>
<li>Door operations continue for already-synced credentials if AWS becomes unavailable. The controller is needed for new changes and sync, not for every local unlock decision.</li>
<li>The architecture is reproducible. The VXLAN/bridge/macvlan bits are vanilla Linux primitives; the Mikrotik bits are stock RouterOS.</li>
</ul>
<p>The explicit constraints:</p>
<ul>
<li>MTU has to be set consistently at three layers (bridge, vxlan, container interface). Forget one and TCP flows hang under load.</li>
<li>The IPSec tunnel is on the critical path. Lose IPSec, the VXLAN encapsulation has nowhere to go, all adoption traffic stops within DPD timeout.</li>
<li>Multi-network Compose with <code>macvlan</code> plus <code>ports:</code> silently breaks port publishing. The two-phase <code>compose up + docker network connect</code> pattern works around it but the failure mode is invisible until you check <code>docker ps</code>.</li>
<li>Docker's port-publish DNAT competes with macvlan direct access. You can have one or the other; combining them through the same iptables PREROUTING chain leaves at least one path broken.</li>
<li>In my RouterOS 7.19.1 run, VXLAN interface counters lied. Use bridge-level stats.</li>
<li>The Synology kernel does not let you run <code>binfmt_misc</code>-registered foreign-arch binaries, regardless of privileges. arm64 workloads need a real arm64 host.</li>
</ul>
<p>For a homelab access control system that should not depend on Ubiquiti consoles or on bringing the controller indoors, this is the simplest design I could find that respects the actual protocol constraints instead of fighting them.</p>
<hr>
<h2 id="references">References</h2>
<ul>
<li><a href="https://github.com/dciancu/unifi-protect-unvr-docker-arm64">dciancu/unifi-protect-unvr-docker-arm64</a> - the foundation pattern this work extends</li>
<li><a href="https://help.ui.com/hc/en-us/articles/22230509487639-UniFi-Consoles-with-UniFi-Access-Support">UniFi Access on UniFi Consoles - support article</a></li>
<li><a href="https://help.ui.com/hc/en-us/articles/17452334269975-Getting-Started-with-UniFi-Access">Getting Started with UniFi Access - ports and communication model</a></li>
<li><a href="https://www.reddit.com/r/UNIFI/comments/16v6js7/is_dhcp_option_43_supported_by_unifi_door_access/">DHCP Option 43 for UniFi Access devices - r/UNIFI thread</a></li>
<li><a href="https://www.man7.org/linux/man-pages/man8/ip-link.8.html">Linux VXLAN man page</a></li>
<li><a href="https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN">RouterOS 7 VXLAN documentation</a></li>
<li><a href="https://docs.docker.com/network/drivers/macvlan/">Docker macvlan driver</a></li>
<li><a href="https://docs.strongswan.org/docs/5.9/features/natTraversal.html">strongSwan NAT traversal</a></li>
<li><a href="https://www.rfc-editor.org/rfc/rfc7348">VXLAN: RFC 7348</a></li>
</ul>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
