<?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>Reverse Engineering on Vladimir Kuznichenkov | Engineer</title>
    <link>https://kuzaxak.dev/tags/reverse-engineering/</link>
    <description>Recent content in Reverse Engineering on Vladimir Kuznichenkov | Engineer</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <copyright>© Vladimir Kuznichenkov</copyright>
    <lastBuildDate>Tue, 26 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://kuzaxak.dev/tags/reverse-engineering/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Sixty Seconds to Nothing: The Missing Daemons Behind Containerized UniFi Access</title>
      <link>https://kuzaxak.dev/posts/2026/05/26/unifi-access-unvr-daemons/</link>
      <pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate>
      
      <guid>https://kuzaxak.dev/posts/2026/05/26/unifi-access-unvr-daemons/</guid>
      <description>How a Dockerized UniFi Access controller booted, adopted doors, and still failed cloud activation because the image was missing the UniFi OS daemons that make a container behave like a console.</description>
      <content:encoded><![CDATA[<h2 id="preamble-access-on-top-of-a-protect-container">Preamble: Access on Top of a Protect Container</h2>
<p>This did not start as a clean-room UniFi OS container project.</p>
<p>The starting point was much more practical: take the already working <a href="https://github.com/dciancu/unifi-protect-unvr-docker-arm64">dciancu/unifi-protect-unvr-docker-arm64</a> image shape and add Access on top of it.</p>
<p>That upstream project had already solved the boring but painful parts:</p>
<ol>
<li>extract UNVR firmware;</li>
<li>repack the required UniFi OS <code>.deb</code> packages;</li>
<li>install them into an ARM64 Debian image;</li>
<li>run systemd as PID 1 inside a privileged container;</li>
<li>fake enough UNVR hardware identity and storage state for UniFi OS to boot;</li>
<li>run Protect from the firmware bundle.</li>
</ol>
<p>So the first Access attempt was intentionally small. Keep the Protect container shape, install the Access package and its obvious dependencies, add the launcher/nginx bits, and see how far it gets.</p>
<p>The annoying part was that it got very far.</p>
<p>The UniFi OS wizard completed. Access opened. After the network work from Vol 1, the Hub Mini and G3 reader adopted. Door unlock worked. Logs were ugly in places, but nothing looked fundamentally dead.</p>
<p>That made the project feel mostly finished.</p>
<p>Then we started adding capabilities.</p>
<p>Not just “can the local door open”, but:</p>
<ul>
<li>can the console sign in with a Ubiquiti account;</li>
<li>can ownership state sync back from UI cloud;</li>
<li>can Access and Identity agree on users;</li>
<li>can the log noise be reduced instead of hidden;</li>
<li>can the image carry Protect and Access in one UNVR-shaped userspace;</li>
<li>can the repo become something reproducible instead of a one-off patched container.</li>
</ul>
<p>That is where the first image stopped being enough.</p>
<p>The lesson of this post is not “how to start Access once”. The first image already did that. The lesson is what changed when the goal moved from an Access app running inside a Protect-style container to a fuller UniFi OS console shape.</p>
<hr>
<h2 id="it-booted-it-adopted-doors-then-it-tried-to-phone-home">It Booted. It Adopted Doors. Then It Tried to Phone Home.</h2>
<p><a href="/posts/2026/05/24/unifi-access-vxlan-over-ipsec/">Vol 1</a> was the network story.</p>
<p>Access adoption did not care that the controller was reachable over a normal routed tunnel. It cared that discovery looked local. The final shape was VXLAN-over-IPSec, a Linux bridge on the AWS side, and one Docker macvlan interface that made the controller appear as <code>10.255.255.200</code> on the office LAN.</p>
<p>That solved the physical part of the problem. The Hub Mini and G3 reader could discover the controller, adopt, receive config, and stay online.</p>
<p>The image looked solved too.</p>
<p>The controller booted. The UniFi OS wizard completed. Access opened. The door hardware adopted. Door unlocks worked.</p>
<p>Then I clicked <strong>Sign in with Ubiquiti Account</strong>.</p>
<p>The browser waited for one minute and returned:</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-text" data-lang="text"><span style="display:flex;"><span>408 Request Timeout
</span></span></code></pre></div><p>Not five seconds. Not “somewhere around a minute”.</p>
<p>Almost exactly sixty seconds.</p>
<p>The obvious logs did not say “wrong package”, “missing daemon”, or “cloud rejected this fake console”. The browser SSO flow itself finished. The failure happened after that, when UniFi Core tried to bind the signed-in UI account to the local console.</p>
<p>That became the second half of the project.</p>
<p>Vol 1 made the network look like the office LAN. Vol 2 is about making Docker look enough like a UniFi console that UniFi Core, Access, Identity, and UI cloud could agree on what the console was.</p>
<p>Ubiquiti does not support this deployment model. Their self-hosting docs allow UniFi OS Server for Network and some UniFi OS features, but state that other applications such as Protect and Access must run on a compatible UniFi Console. Their Access docs also say setup requires a UniFi Console that supports the Access application. This post is about an unsupported lab build, not a production recommendation.</p>
<hr>
<h2 id="the-pattern-we-copied">The Pattern We Copied</h2>
<p>The upstream Protect image was the parent pattern, not a random reference.</p>
<p>It already showed that a UNVR-shaped userspace could run in Docker if the image kept enough of UniFi OS intact. It downloaded UNVR firmware, extracted UniFi-maintained packages, carried <code>unifi-core</code>, <code>ulp-go</code>, <code>uos*</code>, <code>unifi-directory</code>, <code>unifi-assets-unvr</code>, Node packages, and installed Protect from the firmware bundle.</p>
<p>My first mental model was simple:</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-text" data-lang="text"><span style="display:flex;"><span>Protect-in-Docker
</span></span><span style="display:flex;"><span>    -&gt; add Access package
</span></span><span style="display:flex;"><span>    -&gt; add Access dependencies
</span></span><span style="display:flex;"><span>    -&gt; patch launcher / nginx
</span></span><span style="display:flex;"><span>    -&gt; keep the same UNVR identity
</span></span><span style="display:flex;"><span>    -&gt; done
</span></span></code></pre></div><p>That was a reasonable starting point because the local app path really did work.</p>
<p>It was also incomplete. Protect and Access share the same UniFi OS shell, but they do not exercise the same internal workflows. Protect could be useful with the smaller upstream daemon set. Access cloud ownership pushed deeper into the Identity and credential side of UniFi OS.</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/26/unifi-access-unvr-daemons/protect-pattern.png" alt="Firmware extraction pattern"  />
</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-go" data-lang="go"><span style="display:flex;"><span><span style="color:#a6e22e">flowchart</span> <span style="color:#a6e22e">LR</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">A</span>[<span style="color:#a6e22e">dciancu</span> <span style="color:#a6e22e">Protect</span> <span style="color:#a6e22e">image</span> <span style="color:#a6e22e">shape</span>] <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">B</span>[<span style="color:#a6e22e">UNVR</span> <span style="color:#a6e22e">firmware</span> <span style="color:#a6e22e">extraction</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">B</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">C</span>[<span style="color:#a6e22e">Debian</span> <span style="color:#ae81ff">11</span> <span style="color:#a6e22e">arm64</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">systemd</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">C</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">D</span>[<span style="color:#a6e22e">UniFi</span> <span style="color:#a6e22e">Core</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">ULP</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">D</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">E</span>[<span style="color:#a6e22e">Protect</span> <span style="color:#a6e22e">from</span> <span style="color:#a6e22e">firmware</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">E</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">F</span>[<span style="color:#a6e22e">Storage</span> <span style="color:#f92672">/</span> <span style="color:#a6e22e">identity</span> <span style="color:#f92672">/</span> <span style="color:#a6e22e">nginx</span> <span style="color:#a6e22e">shims</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">F</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">G</span>[<span style="color:#a6e22e">Access</span> <span style="color:#f92672">package</span> <span style="color:#a6e22e">added</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">G</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">H</span>[<span style="color:#a6e22e">Local</span> <span style="color:#a6e22e">Access</span> <span style="color:#a6e22e">works</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">H</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">I</span>[<span style="color:#a6e22e">Cloud</span> <span style="color:#a6e22e">bind</span> <span style="color:#a6e22e">exposes</span> <span style="color:#a6e22e">missing</span> <span style="color:#a6e22e">daemons</span>]
</span></span></code></pre></div>
</details>

<hr>
<h2 id="the-first-image-looked-better-than-it-was">The First Image Looked Better Than It Was</h2>
<p>The first Access-on-Protect image was good enough to be deceptive.</p>
<p>It had the usual console shims:</p>
<table>
  <thead>
      <tr>
          <th>Patch</th>
          <th>Why it existed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ustorage</code> sed patch in <code>service.js</code></td>
          <td>Push one storage path away from a missing local gRPC service and into a shell-command fallback.</td>
      </tr>
      <tr>
          <td><code>/usr/bin/ustorage</code> shim</td>
          <td>Return plausible disk and space JSON when UniFi Core shells out.</td>
      </tr>
      <tr>
          <td><code>/sbin/mdadm</code> and <code>/usr/sbin/smartctl</code> shims</td>
          <td>Fake RAID and SMART responses that real console hardware would normally provide.</td>
      </tr>
      <tr>
          <td><code>/sbin/ubnt-tools</code> identity shim</td>
          <td>Report a UNVR-shaped console identity instead of whatever container hardware actually looks like.</td>
      </tr>
      <tr>
          <td>nginx setup hostname patch</td>
          <td>Allow reverse-proxied hostnames instead of redirecting every non-<code>unifi</code> host to <code>https://unifi</code>.</td>
      </tr>
      <tr>
          <td>PostgreSQL relocation</td>
          <td>Persist UniFi Core and Access databases under mounted <code>/data</code>, not ephemeral image paths.</td>
      </tr>
      <tr>
          <td>timesync overrides</td>
          <td>Let systemd time sync run in a container. Cloud auth and bad clocks do not mix.</td>
      </tr>
  </tbody>
</table>
<p>None of that was wasted work. Those patches got the image far enough to pass setup, run Access, and operate a door.</p>
<p>But “the door opens” was only the first capability.</p>
<p>The next capability was cloud ownership. That required more than the Access app and more than the Protect-era shims. It required local UniFi OS daemons that could consume Identity and credential messages from UniFi Core.</p>
<p>The bug only appeared when the local console tried to become a cloud-managed console.</p>
<p>That is a nastier class of failure. Local services can look mostly fine while one internal request/reply chain is missing a subscriber.</p>
<hr>
<h2 id="the-symptom-exactly-sixty-seconds">The Symptom: Exactly Sixty Seconds</h2>
<p>The user-visible failure was boring:</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-text" data-lang="text"><span style="display:flex;"><span>POST /api/cloud/register
</span></span><span style="display:flex;"><span>408 Request Timeout
</span></span></code></pre></div><p>The useful part was the timing.</p>
<p>The nginx-side request time was basically one minute:</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-text" data-lang="text"><span style="display:flex;"><span>POST /api/cloud/register
</span></span><span style="display:flex;"><span>status=408
</span></span><span style="display:flex;"><span>request_time=60.001
</span></span><span style="display:flex;"><span>upstream_response_time=-
</span></span></code></pre></div><p>The <code>u_rt=-</code> part matters. This was not a normal upstream HTTP service returning an error. From nginx's point of view, there was no useful upstream response.</p>
<p>That sent me down two wrong paths first.</p>
<p>The wrong paths are worth keeping in the story because they teach the difference between loud logs and blocking logs.</p>
<hr>
<h2 id="dead-end-1-the-storage-grpc-error-was-loud-not-causal">Dead End 1: The Storage gRPC Error Was Loud, Not Causal</h2>
<p>The loudest error was 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-text" data-lang="text"><span style="display:flex;"><span>[grpc] Failed to connect to the gRPC server:
</span></span><span style="display:flex;"><span>14 UNAVAILABLE: No connection established.
</span></span><span style="display:flex;"><span>Last error: Error: connect ECONNREFUSED 127.0.0.1:11052
</span></span></code></pre></div><p>It repeated every few seconds.</p>
<p>That looked guilty. A local gRPC service was missing. A cloud-register API was timing out. Easy conclusion: fix storage and cloud sign-in will work.</p>
<p>But the timing did not match.</p>
<p>The <code>11052</code> error happened before the cloud-register attempt, during the cloud-register attempt, and after the cloud-register attempt. It was background noise from an incomplete console image, not the exact thing holding <code>/api/cloud/register</code> open for sixty seconds.</p>
<p>The repo still carries the inherited storage workaround because it is needed for setup paths:</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>grep -c <span style="color:#e6db74">&#39;return at(),!0&#39;</span> /usr/share/unifi-core/app/service.js
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 1</span>
</span></span></code></pre></div><p>The sed patch forces one UniFi Core storage path into a shell-command fallback. The <code>/usr/bin/ustorage</code> shim then returns disk, space, and config JSON.</p>
<p>But that patch never meant “all storage/state calls are fixed”. Other state paths still tried to talk to a real console daemon on <code>127.0.0.1:11052</code>.</p>
<p>The later UNVR rebuild did fix this noise by including <code>ustate-exporter</code> and <code>ustd</code>. That was good, but it was not the root cause of the 408.</p>
<table>
  <thead>
      <tr>
          <th>Signal</th>
          <th>What it proved</th>
          <th>What it did not prove</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ECONNREFUSED 127.0.0.1:11052</code></td>
          <td>A local UniFi OS storage/state daemon was missing.</td>
          <td>It did not explain the exact 60-second cloud-register timeout.</td>
      </tr>
      <tr>
          <td><code>ustorage</code> sed patch present</td>
          <td>One setup-breaking storage path was redirected to the shell shim.</td>
          <td>It did not cover every storage/state RPC.</td>
      </tr>
      <tr>
          <td>Wizard completed</td>
          <td>The storage failure was survivable for setup.</td>
          <td>It did not mean the console service graph was complete.</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="dead-end-2-ulp-go-was-alive">Dead End 2: <code>ulp-go</code> Was Alive</h2>
<p>The next suspect was <code>ulp-go</code>, the Users/Login Platform service.</p>
<p>The request looked like a UI account bind operation, so <code>ulp-go</code> seemed like the obvious place to look.</p>
<p>But <code>ulp-go</code> was not dead.</p>
<p>It was running. It had local connections to UniFi Core's IPC port. Its status showed it was configured. Its log had the normal startup lines:</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-text" data-lang="text"><span style="display:flex;"><span>init wsClient
</span></span><span style="display:flex;"><span>Connected to server
</span></span><span style="display:flex;"><span>Subscribe event success
</span></span><span style="display:flex;"><span>controllerStatus: READY
</span></span><span style="display:flex;"><span>isConfigured: true
</span></span></code></pre></div><p>During the exact sixty seconds where <code>/api/cloud/register</code> hung, <code>ulp-go</code> did not log a failed outbound request. It did not log a token rejection. It did not crash.</p>
<p>It was just quiet.</p>
<p>That broke my first model of the flow. I had assumed something 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-text" data-lang="text"><span style="display:flex;"><span>unifi-core
</span></span><span style="display:flex;"><span>    -&gt; HTTP request
</span></span><span style="display:flex;"><span>    -&gt; ulp-go
</span></span><span style="display:flex;"><span>    -&gt; UI cloud
</span></span></code></pre></div><p>The actual shape was closer to 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-text" data-lang="text"><span style="display:flex;"><span>unifi-core handler
</span></span><span style="display:flex;"><span>    -&gt; publish internal message
</span></span><span style="display:flex;"><span>    -&gt; wait for local subscriber reply
</span></span><span style="display:flex;"><span>    -&gt; no subscriber replies
</span></span><span style="display:flex;"><span>    -&gt; timeout
</span></span></code></pre></div><p>A healthy <code>ulp-go</code> process did not prove that every <code>ulp.*</code> or cloud-binding topic had a consumer.</p>
<hr>
<h2 id="the-number-that-explained-the-timeout">The Number That Explained the Timeout</h2>
<p>The useful clue was a config value, not a stack trace.</p>
<p>Inside UniFi Core:</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">messageBox</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">maxSubscriptionBacklog</span>: <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">subscriptionReaperInterval</span>: <span style="color:#ae81ff">30000</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">longPollTimeout</span>: <span style="color:#ae81ff">60000</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">keepAliveTimeout</span>: <span style="color:#ae81ff">60000</span>
</span></span></code></pre></div><p>The API call waited sixty seconds because the handler was waiting on a messageBox request/reply path.</p>
<p>The chain 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-text" data-lang="text"><span style="display:flex;"><span>POST /api/cloud/register
</span></span><span style="display:flex;"><span>    -&gt; unifi-core handler
</span></span><span style="display:flex;"><span>    -&gt; publish(&#34;ulp.bindSsoAccount&#34;, payload)
</span></span><span style="display:flex;"><span>    -&gt; wait for response
</span></span><span style="display:flex;"><span>    -&gt; no local subscriber answers
</span></span><span style="display:flex;"><span>    -&gt; messageBox.longPollTimeout
</span></span><span style="display:flex;"><span>    -&gt; 408
</span></span></code></pre></div><p>That was the important inversion.</p>
<p>The browser-visible failure did not mean “UI cloud rejected the console”. It meant UniFi Core published an internal message and nobody in the local container answered it.</p>
<p>Reverse-engineering this out of <code>service.js</code> was ugly. In this UniFi Core build, <code>service.js</code> is a large minified blob. Every diagnostic involves grepping for strings around anonymous variables and then trying to reconstruct intent from call shape.</p>
<p>But the timeout made the architecture visible.</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/26/unifi-access-unvr-daemons/messagebox-timeout.png" alt="MessageBox timeout path"  />
</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 Browser
</span></span><span style="display:flex;"><span>    participant Nginx as nginx
</span></span><span style="display:flex;"><span>    participant Core as unifi<span style="color:#f92672">-</span>core
</span></span><span style="display:flex;"><span>    participant MB as messageBox <span style="color:#f92672">/</span> IPC broker<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span><span style="color:#ae81ff">127.0</span><span style="color:#f92672">.</span><span style="color:#ae81ff">0.1</span>:<span style="color:#ae81ff">11081</span>
</span></span><span style="display:flex;"><span>    participant Missing as missing local subscriber
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Browser<span style="color:#f92672">-&gt;&gt;</span>Nginx: POST <span style="color:#f92672">/</span>api<span style="color:#f92672">/</span>cloud<span style="color:#f92672">/</span>register
</span></span><span style="display:flex;"><span>    Nginx<span style="color:#f92672">-&gt;&gt;</span>Core: proxy request
</span></span><span style="display:flex;"><span>    Core<span style="color:#f92672">-&gt;&gt;</span>MB: publish(<span style="color:#e6db74">&#34;ulp.bindSsoAccount&#34;</span>, payload)
</span></span><span style="display:flex;"><span>    MB<span style="color:#f92672">--</span>xMissing: no daemon subscribed to finish the request
</span></span><span style="display:flex;"><span>    Note over Core,MB: waits <span style="color:#66d9ef">for</span> messageBox<span style="color:#f92672">.</span>longPollTimeout<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span><span style="color:#ae81ff">60000</span> ms
</span></span><span style="display:flex;"><span>    Core<span style="color:#f92672">--&gt;&gt;</span>Nginx: <span style="color:#ae81ff">408</span>
</span></span><span style="display:flex;"><span>    Nginx<span style="color:#f92672">--&gt;&gt;</span>Browser: Request Timeout
</span></span></code></pre></div>
</details>

<p>The missing piece was not another nginx route. It was not a firewall rule. It was not the public cloud endpoint.</p>
<p>It was the local UniFi OS daemon graph.</p>
<hr>
<h2 id="the-package-set-was-the-real-bug">The Package Set Was the Real Bug</h2>
<p>The firmware family was not the problem anymore. We were already using the right family: UNVR.</p>
<p>The problem was that the first Access image treated the upstream Protect package selection as enough of UniFi OS.</p>
<p>For Protect, that selection is practical: core OS packages, <code>ulp-go</code>, directory, assets, UOS helpers, Node packages, and the Protect application from the firmware bundle. That is enough to make Protect useful inside the UNVR-shaped container.</p>
<p>Access raised the bar.</p>
<p>The 408 showed that local Access was not the same as a complete console identity flow. UniFi Core published an internal message for SSO binding, then waited for a local daemon to answer. The app was present. The web UI was present. The door device path was present. The local subscriber for the cloud-ownership path was not.</p>
<p>So the rebuild did not mean “switch away from the Protect pattern”. It meant extend the Protect pattern in the direction it was already pointing:</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-text" data-lang="text"><span style="display:flex;"><span>UNVR firmware is still the source
</span></span><span style="display:flex;"><span>UNVR identity is still the runtime lie
</span></span><span style="display:flex;"><span>Protect still comes from the firmware bundle
</span></span><span style="display:flex;"><span>Access is installed on top
</span></span><span style="display:flex;"><span>but the extracted daemon set must be larger
</span></span></code></pre></div><p>That changed the target from:</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-text" data-lang="text"><span style="display:flex;"><span>make Access start
</span></span></code></pre></div><p>to:</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-text" data-lang="text"><span style="display:flex;"><span>make UniFi OS complete enough for Access, Identity, and cloud ownership
</span></span></code></pre></div><p>Those are not the same thing.</p>
<hr>
<h2 id="the-daemons-that-made-it-a-console">The Daemons That Made It a Console</h2>
<p>The rebuild stayed with UNVR firmware, but pulled in the missing UniFi OS support packages instead of only the smaller Protect-era selection.</p>
<p>The critical additions were not application packages. They were the daemons around the application.</p>
<table>
  <thead>
      <tr>
          <th>Package / daemon</th>
          <th>Role</th>
          <th>Why it mattered</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ucs-agent</code></td>
          <td>UniFi Credential Server agent</td>
          <td>Handles credential and SSO ownership workflows that the cloud-bind path depends on.</td>
      </tr>
      <tr>
          <td><code>uid-agent</code></td>
          <td>UniFi Identity agent</td>
          <td>Connects local identity state, users, and UID flows.</td>
      </tr>
      <tr>
          <td><code>unifi-identity-update</code></td>
          <td>Identity update worker</td>
          <td>Keeps local identity state synchronized after setup and sign-in.</td>
      </tr>
      <tr>
          <td><code>ucore-setup-listener</code></td>
          <td>Post-setup hook listener</td>
          <td>Handles “console configured” transitions after the wizard.</td>
      </tr>
      <tr>
          <td><code>ustate-exporter</code></td>
          <td>Local state exporter</td>
          <td>Provides the gRPC endpoint UniFi Core was polling on <code>127.0.0.1:11052</code>.</td>
      </tr>
      <tr>
          <td><code>ustd</code></td>
          <td>Storage/status daemon</td>
          <td>Part of the local state and storage path.</td>
      </tr>
      <tr>
          <td><code>ubnt-rpsd</code></td>
          <td>RPC subprocess / hardware-related daemon</td>
          <td>Comes with the console graph; may be cosmetic or hardware-only in Docker.</td>
      </tr>
      <tr>
          <td><code>ubnt-ucp4cpp</code></td>
          <td>UCP4 protocol library</td>
          <td>Shared device protocol dependency used by UniFi apps.</td>
      </tr>
      <tr>
          <td><code>unifi-directory</code>, <code>unifi-hal</code>, <code>ubnt-common</code>, <code>libubnt*</code></td>
          <td>Shared platform libraries/services</td>
          <td>Support the daemon graph above.</td>
      </tr>
  </tbody>
</table>
<p>The current repo extracts the selected UNVR packages explicitly instead of copying every <code>.deb</code> blindly:</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-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span>cp ubnt-archive-keyring_* ubnt-tools_* ubnt-ucp4cpp_* ubnt-rpsd_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   ubnt-common_* ubnt-binmecpp_* ubnt-disk-smart-mon_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   unifi-core_* ulp-go_* unifi-assets-unvr_* unifi-directory_* unifi-hal_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   unifi-email-templates-all_* unifi-identity-update_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   uos_* uos-agent_* uos-discovery-client_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   ucs-agent_* uid-agent_* ucore-setup-listener_* uled-control_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   ustate-exporter_* ustd_* ubnd_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   c2lib_* libcurlpp_* libsodiumpp_* libubnt_* libuled_* simple-pid_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   analytic-report-go_* ble-http-transport_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   python3-unifi-console-protos_* <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>   node* ../debs/<span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>This manifest is intentionally explicit. If Ubiquiti renames or removes a package, I want the build to fail loudly.</p>
<p>There is one practical footnote: the line between “hardware-only” and “required dependency” is not always clean. Some packages look like they should be skipped in a container but still satisfy service dependencies. That is why the validation suite matters more than trying to reason about every package name in isolation.</p>
<hr>
<h2 id="why-the-protect-pattern-worked-anyway">Why the Protect Pattern Worked Anyway</h2>
<p>This is not a criticism of the Protect container pattern.</p>
<p>The parent project solved a different problem. It built a UNVR-shaped userspace for Protect, and Protect's normal paths were satisfied enough for that application.</p>
<p>Access made the gap visible because its cloud sign-in path exercised more of the Identity and credential side of UniFi OS.</p>
<p>So the lesson is not:</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-text" data-lang="text"><span style="display:flex;"><span>Protect-in-Docker is incomplete.
</span></span></code></pre></div><p>The lesson 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-text" data-lang="text"><span style="display:flex;"><span>Access uses more of the console identity stack than the first copied image included.
</span></span></code></pre></div><p>The first build could adopt doors because the local Access service worked. It could not finish cloud ownership because the local message-bus consumer chain was incomplete.</p>
<hr>
<h2 id="the-rebuild-protect-plus-access-with-the-missing-daemons">The Rebuild: Protect Plus Access, With the Missing Daemons</h2>
<p>The fix was not to abandon the upstream image shape.</p>
<p>The fix was to make the image more honest about what it wanted to be: a UNVR-shaped UniFi OS userspace with both Protect and Access, plus the support daemons that Access needs for Identity and cloud ownership.</p>
<p>The repo now builds in two stages:</p>
<ol>
<li><code>build-firmware.sh</code> downloads or uses a selected UNVR firmware image, extracts the firmware, and writes generated artifacts under <code>firmware/&lt;series&gt;/</code>.</li>
<li><code>build-os.sh</code> installs the extracted packages, plus Protect and Access, into a Debian 11 ARM64 systemd image.</li>
</ol>
<p>There is also a single-file multi-stage <code>Dockerfile</code> for users who want <code>docker build .</code>, but the split scripts are easier to debug.</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/26/unifi-access-unvr-daemons/unvr-rebuild.png" alt="Protect plus Access rebuild 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-go" data-lang="go"><span style="display:flex;"><span><span style="color:#a6e22e">flowchart</span> <span style="color:#a6e22e">LR</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">A</span>[<span style="color:#a6e22e">UNVR</span> <span style="color:#a6e22e">firmware</span>] <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">B</span>[<span style="color:#a6e22e">build</span><span style="color:#f92672">-</span><span style="color:#a6e22e">firmware</span>.<span style="color:#a6e22e">sh</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">B</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">C</span>[<span style="color:#a6e22e">binwalk</span> <span style="color:#a6e22e">extraction</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">C</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">D</span>[<span style="color:#a6e22e">dpkg</span><span style="color:#f92672">-</span><span style="color:#a6e22e">repack</span> <span style="color:#a6e22e">selected</span> <span style="color:#a6e22e">UniFi</span> <span style="color:#a6e22e">OS</span> <span style="color:#a6e22e">packages</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">D</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">E</span>[<span style="color:#a6e22e">base</span> <span style="color:#a6e22e">Protect</span><span style="color:#f92672">-</span><span style="color:#a6e22e">era</span> <span style="color:#f92672">package</span> <span style="color:#a6e22e">set</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">D</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">F</span>[<span style="color:#a6e22e">extra</span> <span style="color:#a6e22e">Identity</span> <span style="color:#f92672">/</span> <span style="color:#a6e22e">state</span> <span style="color:#a6e22e">daemons</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">D</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">G</span>[<span style="color:#a6e22e">firmware</span><span style="color:#f92672">-</span><span style="color:#a6e22e">bundled</span> <span style="color:#a6e22e">Protect</span> <span style="color:#a6e22e">debs</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">H</span>[<span style="color:#a6e22e">build</span><span style="color:#f92672">-</span><span style="color:#a6e22e">os</span>.<span style="color:#a6e22e">sh</span>] <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">I</span>[<span style="color:#a6e22e">Debian</span> <span style="color:#ae81ff">11</span> <span style="color:#a6e22e">arm64</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">E</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">I</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">F</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">I</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">G</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">I</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">J</span>[<span style="color:#a6e22e">Access</span> <span style="color:#f92672">package</span> <span style="color:#a6e22e">from</span> <span style="color:#a6e22e">fw</span><span style="color:#f92672">-</span><span style="color:#a6e22e">update</span>] <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">I</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">K</span>[<span style="color:#a6e22e">Access</span> <span style="color:#a6e22e">deps</span> <span style="color:#f92672">/</span> <span style="color:#a6e22e">user</span> <span style="color:#a6e22e">assets</span> <span style="color:#f92672">/</span> <span style="color:#a6e22e">media</span> <span style="color:#a6e22e">services</span>] <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">I</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">I</span> <span style="color:#f92672">--</span>&gt; <span style="color:#a6e22e">L</span>[<span style="color:#a6e22e">unifi</span><span style="color:#f92672">-</span><span style="color:#a6e22e">os</span><span style="color:#f92672">-</span><span style="color:#a6e22e">docker</span><span style="color:#f92672">-</span><span style="color:#a6e22e">arm64</span>:<span style="color:#a6e22e">stable</span> <span style="color:#a6e22e">or</span> :<span style="color:#a6e22e">edge</span>]
</span></span></code></pre></div>
</details>

<p>The stable build uses pinned Access and AI-feature package URLs where needed, and installs Protect from the firmware bundle. The edge build resolves newer app packages from Ubiquiti's firmware update endpoints. That gives two useful modes:</p>
<table>
  <thead>
      <tr>
          <th>Build mode</th>
          <th>Use it when</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>BUILD_STABLE=1</code></td>
          <td>You want a known baseline and fewer moving parts.</td>
      </tr>
      <tr>
          <td><code>BUILD_EDGE=1</code></td>
          <td>You are testing newer Access/Protect packages or chasing a bug fixed upstream.</td>
      </tr>
  </tbody>
</table>
<p>Access is downloaded on top because UNVR firmware does not bundle it in the same way it bundles Protect. Protect comes from the UNVR firmware extraction, so the repo installs both apps into one image.</p>
<p>That gives the image a clearer identity:</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-text" data-lang="text"><span style="display:flex;"><span>UNVR-shaped UniFi OS userspace
</span></span><span style="display:flex;"><span>    + Protect from UNVR firmware
</span></span><span style="display:flex;"><span>    + Access from fw-update package
</span></span><span style="display:flex;"><span>    + extra Identity and state daemons Access needs
</span></span></code></pre></div><p>The important design choice is that the repo keeps the upstream Protect inheritance visible. This is not “my own UniFi OS from scratch”. It is the Protect container pattern expanded until Access cloud ownership stopped timing out.</p>
<hr>
<h2 id="the-swap-that-proved-the-theory">The Swap That Proved the Theory</h2>
<p>I did not replace the old container blindly.</p>
<p>The old image stayed around as rollback:</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 rename unifi-access unifi-access-v0.2.0
</span></span></code></pre></div><p>The persistent state stayed mounted:</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-text" data-lang="text"><span style="display:flex;"><span>/srv/unifi-access/srv
</span></span><span style="display:flex;"><span>/srv/unifi-access/data
</span></span><span style="display:flex;"><span>/srv/unifi-access/persistent
</span></span></code></pre></div><p>The new image started with the same state and the same office-facing network identity from Vol 1.</p>
<p>The first surprise was that cloud activation was already true after restart:</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-os systemctl show ulp-go -p StatusText | <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  grep -oE <span style="color:#e6db74">&#39;standardActivated&#34;:[a-z]+|ucsAgentVersion&#34;:&#34;[^&#34;]+&#34;&#39;</span>
</span></span></code></pre></div><p>Expected shape:</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-text" data-lang="text"><span style="display:flex;"><span>standardActivated&#34;:true
</span></span><span style="display:flex;"><span>ucsAgentVersion&#34;:&#34;1.6.98+733&#34;
</span></span></code></pre></div><p>I had not clicked the cloud sign-in button again.</p>
<p>That changed the interpretation of the earlier 408.</p>
<p>The browser-visible <code>/api/cloud/register</code> request had timed out locally, but the server-side cloud claim had apparently completed. The missing part was local state convergence. The old image had no daemon to consume the internal message and flip the local state. The new one did.</p>
<p>The evidence table looked much better:</p>
<table>
  <thead>
      <tr>
          <th>Check</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ulp-go</code> status</td>
          <td><code>standardActivated: true</code></td>
      </tr>
      <tr>
          <td><code>ucsAgentVersion</code></td>
          <td>Present</td>
      </tr>
      <tr>
          <td><code>ulp-go-app</code> outbound TLS</td>
          <td>Established to UI cloud</td>
      </tr>
      <tr>
          <td><code>uid-agent-app</code> outbound TLS</td>
          <td>Established to UI cloud</td>
      </tr>
      <tr>
          <td><code>unifi-protect</code> outbound TLS</td>
          <td>Established to UI cloud</td>
      </tr>
      <tr>
          <td><code>127.0.0.1:11052</code></td>
          <td>Listening</td>
      </tr>
      <tr>
          <td>repeated <code>ECONNREFUSED 127.0.0.1:11052</code></td>
          <td>Gone</td>
      </tr>
      <tr>
          <td>Access device sessions</td>
          <td>Still established on <code>12443</code> / <code>12812</code></td>
      </tr>
  </tbody>
</table>
<p>Example checks:</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-os ss -tnp state established | grep <span style="color:#e6db74">&#39;:443&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>docker exec unifi-os ss -ltnp | grep <span style="color:#ae81ff">11052</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>docker exec unifi-os tail -n <span style="color:#ae81ff">20</span> /data/unifi-core/logs/grpc.log
</span></span></code></pre></div><p>The log line that mattered:</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-text" data-lang="text"><span style="display:flex;"><span>call cloud api https://cell3.api.identity.ui.com/standard/api/v1/public/console/ownership/check [POST] success
</span></span></code></pre></div><p>That was the answer to the title.</p>
<p>The sixty-second timeout was not “UI cloud hates Docker”. It was “UniFi Core is waiting for a local console daemon that is missing”.</p>
<hr>
<h2 id="the-last-mile-registered-is-not-connected">The Last Mile: Registered Is Not Connected</h2>
<p>The daemon rebuild fixed the sixty-second bind timeout, but it exposed one more trap.</p>
<p>Cloud registration and cloud connection are different states.</p>
<p>After the Ubiquiti account flow completed, UniFi Core had clearly registered the console:</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-text" data-lang="text"><span style="display:flex;"><span>2026-05-26T16:38:03+03: registering device for cloud access
</span></span><span style="display:flex;"><span>2026-05-26T16:38:04+03: registered device for cloud access: deviceId=&lt;device-id&gt;, ownerId=&lt;owner-id&gt;
</span></span></code></pre></div><p>But the console still did not appear in the cloud list, and cloud-backed operations failed locally:</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-text" data-lang="text"><span style="display:flex;"><span>Can&#39;t publish to &#34;api/v1/create-auth-token/...&#34;, cloud connection not established
</span></span><span style="display:flex;"><span>Can&#39;t publish to &#34;api/v1/create-invitation/&lt;device-id&gt;&#34;, cloud connection not established
</span></span></code></pre></div><p>That made the state machine clearer:</p>
<table>
  <thead>
      <tr>
          <th>State</th>
          <th>What it means</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cloud account bind succeeded</td>
          <td>Ubiquiti accepted the ownership request.</td>
      </tr>
      <tr>
          <td><code>standardActivated: true</code></td>
          <td>Local ULP state knows the console is owned.</td>
      </tr>
      <tr>
          <td>Cloud connection established</td>
          <td>UniFi Core has a live cloud transport and can publish operations.</td>
      </tr>
  </tbody>
</table>
<p>The broken value was hiding in UniFi Core's settings:</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">anonymous_device_id</span>: <span style="color:#e6db74">&#34;Ubiquiti system tools\nCopyright 2006-2024, Ubiquiti, Inc. &lt;support@ui.com&gt;\n...\thwaddr&#34;</span>
</span></span></code></pre></div><p>That value should have been a stable UUID. Instead, a failed identity-helper path had persisted <code>ubnt-tools</code> banner/help text as the anonymous console ID. <code>/data/uuid.txt</code> already had a valid UUID; UniFi Core was just not using it.</p>
<p>The durable fix had two parts:</p>
<ol>
<li><code>ubnt-tools id &lt;file&gt;</code> now writes the same generated board identity to the requested file that <code>ubnt-tools id</code> prints to stdout.</li>
<li><code>init_console.sh</code> now repairs missing, <code>null</code>, or non-UUID <code>anonymous_device_id</code> values back to the stable <code>/data/uuid.txt</code> value.</li>
</ol>
<p>After repairing the live settings file and restarting <code>uos-agent</code> plus <code>unifi-core</code>, the cloud log changed shape:</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-text" data-lang="text"><span style="display:flex;"><span>2026-05-27T00:09:10+03: Device connected to cloud
</span></span><span style="display:flex;"><span>subscribe device/&lt;device-id&gt;
</span></span><span style="display:flex;"><span>subscribe $aws/things/&lt;device-id&gt;/shadow/name/+/update/delta
</span></span><span style="display:flex;"><span>subscribe device/&lt;device-id&gt;/client/#
</span></span><span style="display:flex;"><span>subscribe d/&lt;device-id&gt;/c/#
</span></span><span style="display:flex;"><span>subscribe device/&lt;device-id&gt;/ping
</span></span><span style="display:flex;"><span>subscribe device/&lt;device-id&gt;/application/+
</span></span><span style="display:flex;"><span>subscribe d/proxy/&lt;device-id&gt;/#
</span></span><span style="display:flex;"><span>Successful sync owner to cloud
</span></span></code></pre></div><p>At that point <code>unifi-core</code> also had an established MQTT/TLS connection to <code>:8883</code>, and the log stopped producing fresh <code>cloud connection not established</code> errors.</p>
<p>That is the distinction I wish I had checked earlier: a successful account bind proves ownership, not necessarily a usable cloud transport.</p>
<hr>
<h2 id="local-repo-defaults-vs-the-cloud-topology">Local Repo Defaults vs the Cloud Topology</h2>
<p>The public repo defaults to host networking:</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">network_mode</span>: <span style="color:#ae81ff">host</span>
</span></span></code></pre></div><p>That is the right default for most Linux users. If the container runs on a local ARM64 host on the same LAN as the Access devices, host networking is the simplest way to make discovery and adoption work.</p>
<p>The cloud deployment from Vol 1 is different. There the container does not live on the office LAN, so host networking on the EC2 instance would only expose AWS interfaces. The cloud version still needs the macvlan/VXLAN shape:</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-text" data-lang="text"><span style="display:flex;"><span>Office LAN
</span></span><span style="display:flex;"><span>    -&gt; VXLAN-over-IPSec
</span></span><span style="display:flex;"><span>    -&gt; br-office on EC2
</span></span><span style="display:flex;"><span>    -&gt; Docker macvlan parent=br-office
</span></span><span style="display:flex;"><span>    -&gt; unifi-os container IP 10.255.255.200
</span></span></code></pre></div><p>The repo README documents both ideas:</p>
<table>
  <thead>
      <tr>
          <th>Deployment</th>
          <th>Network mode</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Local Linux host on the real LAN</td>
          <td><code>network_mode: host</code></td>
      </tr>
      <tr>
          <td>macOS Docker Desktop lab UI</td>
          <td>NATed VM, UI-only; no real hardware adoption</td>
      </tr>
      <tr>
          <td>Cloud or routed deployment</td>
          <td>Explicit L2 extension, bridge, and macvlan-only container</td>
      </tr>
  </tbody>
</table>
<p>The warning from Vol 1 still applies: do not attach both the default Docker bridge and the office macvlan. UniFi OS can see the bridge address, select it as controller identity, and generate device or invitation URLs pointing at an unreachable <code>172.x.x.x</code> address.</p>
<p>One identity is boring. Boring is good.</p>
<hr>
<h2 id="the-network-repair-timer-stayed">The Network Repair Timer Stayed</h2>
<p>The rebuilt image still carries the Access network repair timer.</p>
<p>This is a Vol 1 lesson that became repo code.</p>
<p>The timer runs once shortly after boot and then every minute:</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:#66d9ef">[Timer]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">OnBootSec</span><span style="color:#f92672">=</span><span style="color:#e6db74">20s</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">OnUnitActiveSec</span><span style="color:#f92672">=</span><span style="color:#e6db74">1min</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">AccuracySec</span><span style="color:#f92672">=</span><span style="color:#e6db74">10s</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Persistent</span><span style="color:#f92672">=</span><span style="color:#e6db74">true</span>
</span></span></code></pre></div><p>The service calls:</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-text" data-lang="text"><span style="display:flex;"><span>/usr/sbin/configure_unifi_access_network.sh
</span></span></code></pre></div><p>The script resolves the Access management interface in this order:</p>
<ol>
<li>explicit <code>ACCESS_MNGT_NETWORK_ID</code>;</li>
<li>interface holding <code>ACCESS_MNGT_NETWORK_IP</code>;</li>
<li>default-route interface.</li>
</ol>
<p>Then it lowers the interface MTU if it is larger than <code>ACCESS_DEVICE_MTU</code>, defaulting to <code>1200</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>device_mtu<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>ACCESS_DEVICE_MTU<span style="color:#66d9ef">:-</span>1200<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>Finally, if the Access API is up, it checks <code>/api/v2/networks</code> and <code>/api/v2/settings</code>, then updates <code>mngt_network_id</code> when needed.</p>
<p>That timer exists because discovery success was not enough. Small UDP discovery packets worked even when larger post-adoption TLS/MQTT flows did not. Docker could recreate the macvlan interface with an unsafe MTU, and Access could drift back to the wrong management network selection.</p>
<p>So the image keeps repairing the two things that made the reader stable:</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-text" data-lang="text"><span style="display:flex;"><span>correct device-facing interface
</span></span><span style="display:flex;"><span>safe device-facing MTU
</span></span></code></pre></div><hr>
<h2 id="the-ipc-map-i-wish-i-had-first">The IPC Map I Wish I Had First</h2>
<p>This is the model that made the failure understandable.</p>
<p>It is not a full UniFi OS spec. It is the map that was useful for debugging this container.</p>
<p><p>
  <img src="https://kuzaxak.dev/posts/2026/05/26/unifi-access-unvr-daemons/ipc-map.png" alt="UniFi OS IPC map"  />
</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 TB
</span></span><span style="display:flex;"><span>    Browser[Browser <span style="color:#f92672">/</span> setup UI]
</span></span><span style="display:flex;"><span>    Nginx[nginx<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span><span style="color:#ae81ff">443</span>]
</span></span><span style="display:flex;"><span>    Core[unifi<span style="color:#f92672">-</span>core<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span><span style="color:#a6e22e">Node</span><span style="color:#f92672">.</span>js API]
</span></span><span style="display:flex;"><span>    MB[messageBox <span style="color:#f92672">/</span> IPC broker<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span><span style="color:#ae81ff">127.0</span><span style="color:#f92672">.</span><span style="color:#ae81ff">0.1</span>:<span style="color:#ae81ff">11081</span>]
</span></span><span style="display:flex;"><span>    ULP[ulp<span style="color:#f92672">-</span>go<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>users <span style="color:#f92672">/</span> login platform]
</span></span><span style="display:flex;"><span>    Sock[<span style="color:#e6db74">&#34;/run/ulp-go/jsonrpc.sock&#34;</span>]
</span></span><span style="display:flex;"><span>    UCS[ucs<span style="color:#f92672">-</span>agent]
</span></span><span style="display:flex;"><span>    UID[uid<span style="color:#f92672">-</span>agent]
</span></span><span style="display:flex;"><span>    Dir[unifi<span style="color:#f92672">-</span>directory]
</span></span><span style="display:flex;"><span>    Update[unifi<span style="color:#f92672">-</span>identity<span style="color:#f92672">-</span>update]
</span></span><span style="display:flex;"><span>    State[ustate<span style="color:#f92672">-</span>exporter <span style="color:#f92672">/</span> ustd<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span>gRPC <span style="color:#ae81ff">127.0</span><span style="color:#f92672">.</span><span style="color:#ae81ff">0.1</span>:<span style="color:#ae81ff">11052</span>]
</span></span><span style="display:flex;"><span>    Access[unifi<span style="color:#f92672">-</span>access<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;</span><span style="color:#ae81ff">12080</span> <span style="color:#f92672">/</span> <span style="color:#ae81ff">12443</span> <span style="color:#f92672">/</span> <span style="color:#ae81ff">12812</span>]
</span></span><span style="display:flex;"><span>    Protect[unifi<span style="color:#f92672">-</span>protect]
</span></span><span style="display:flex;"><span>    Cloud[UI cloud<span style="color:#f92672">&lt;</span>br<span style="color:#f92672">/&gt;*.</span>ui<span style="color:#f92672">.</span>com HTTPS]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Browser <span style="color:#f92672">--&gt;</span> Nginx
</span></span><span style="display:flex;"><span>    Nginx <span style="color:#f92672">--&gt;</span> Core
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Core <span style="color:#f92672">&lt;--&gt;</span> MB
</span></span><span style="display:flex;"><span>    ULP <span style="color:#f92672">&lt;--&gt;</span> MB
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    ULP <span style="color:#f92672">&lt;--&gt;</span> Sock
</span></span><span style="display:flex;"><span>    UCS <span style="color:#f92672">&lt;--&gt;</span> Sock
</span></span><span style="display:flex;"><span>    UID <span style="color:#f92672">&lt;--&gt;</span> Sock
</span></span><span style="display:flex;"><span>    Dir <span style="color:#f92672">&lt;--&gt;</span> Sock
</span></span><span style="display:flex;"><span>    Update <span style="color:#f92672">&lt;--&gt;</span> Sock
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Core <span style="color:#f92672">&lt;--&gt;</span> State
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Core <span style="color:#f92672">--&gt;</span> Access
</span></span><span style="display:flex;"><span>    Core <span style="color:#f92672">--&gt;</span> Protect
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    ULP <span style="color:#f92672">--&gt;</span> Cloud
</span></span><span style="display:flex;"><span>    UCS <span style="color:#f92672">--&gt;</span> Cloud
</span></span><span style="display:flex;"><span>    UID <span style="color:#f92672">--&gt;</span> Cloud
</span></span><span style="display:flex;"><span>    Protect <span style="color:#f92672">--&gt;</span> Cloud
</span></span></code></pre></div>
</details>

<p>The split that matters:</p>
<table>
  <thead>
      <tr>
          <th>Path</th>
          <th>What it proves when healthy</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Browser → nginx → <code>unifi-core</code></td>
          <td>The UI/API entrypoint works.</td>
      </tr>
      <tr>
          <td><code>unifi-core</code> → messageBox <code>:11081</code></td>
          <td>Core can publish internal requests.</td>
      </tr>
      <tr>
          <td><code>ulp-go</code> connected to <code>:11081</code></td>
          <td>ULP is alive, but not necessarily the handler for every topic.</td>
      </tr>
      <tr>
          <td><code>ulp-go</code> / agents → <code>/run/ulp-go/jsonrpc.sock</code></td>
          <td>Identity daemons can share local state.</td>
      </tr>
      <tr>
          <td><code>unifi-core</code> → <code>127.0.0.1:11052</code></td>
          <td>Storage/state exporter path works.</td>
      </tr>
      <tr>
          <td>agents → <code>*.ui.com:443</code></td>
          <td>Local console identity stack can reach UI cloud.</td>
      </tr>
  </tbody>
</table>
<p>The trap was assuming that one healthy service meant the whole chain was healthy.</p>
<p><code>ulp-go</code> was alive. It just was not enough.</p>
<hr>
<h2 id="current-patch-catalog">Current Patch Catalog</h2>
<p>The UNVR rebuild reduced the number of things that had to be faked, but it did not remove all patches.</p>
<p>This is still Docker pretending to be a console.</p>
<table>
  <thead>
      <tr>
          <th>Patch / shim</th>
          <th>Where</th>
          <th>Without it</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ustorage</code> sed patch</td>
          <td><code>os.Dockerfile</code>, <code>/usr/share/unifi-core/app/service.js</code></td>
          <td>Setup can hit storage paths that expect real console hardware.</td>
      </tr>
      <tr>
          <td><code>/usr/bin/ustorage</code> shim</td>
          <td><code>files/usr/bin/ustorage</code></td>
          <td>Shell fallback has nothing useful to call.</td>
      </tr>
      <tr>
          <td><code>mdadm</code> shim</td>
          <td><code>files/sbin/mdadm</code></td>
          <td>RAID probes fail or return nonsense.</td>
      </tr>
      <tr>
          <td><code>smartctl</code> shim</td>
          <td><code>files/usr/sbin/smartctl</code></td>
          <td>SMART checks fail or block on missing hardware.</td>
      </tr>
      <tr>
          <td><code>ubnt-tools id</code> shim</td>
          <td><code>files/sbin/ubnt-tools</code></td>
          <td>UniFi OS sees the wrong board identity, and file-output callers can poison persistent identity state with non-UUID text.</td>
      </tr>
      <tr>
          <td>nginx setup hostname patch</td>
          <td><code>/usr/share/unifi-core/http/site-setup.conf</code></td>
          <td>Reverse-proxied setup hostnames redirect to <code>https://unifi</code>.</td>
      </tr>
      <tr>
          <td>PostgreSQL cluster handling</td>
          <td><code>patch_db.sh</code>, <code>dbpermissions.service</code></td>
          <td>Core/app state can land in the wrong place or wrong ownership after restart.</td>
      </tr>
      <tr>
          <td>systemd time sync overrides</td>
          <td>systemd drop-ins</td>
          <td>Time sync can refuse to run in a container.</td>
      </tr>
      <tr>
          <td><code>init_console</code> / <code>init_device</code></td>
          <td>oneshot services</td>
          <td>Device identity and env-driven console settings are not persisted cleanly; a bad <code>anonymous_device_id</code> can survive restarts and block cloud transport.</td>
      </tr>
      <tr>
          <td><code>fix_hosts.service</code></td>
          <td><code>fix_hosts.sh</code></td>
          <td>Docker rewrites <code>/etc/hosts</code>; local resolution can drift.</td>
      </tr>
      <tr>
          <td>apt source repair</td>
          <td><code>fix_apt_ubiquiti_sources.sh</code></td>
          <td>Ubiquiti apt metadata can lag package expectations during app installs or updates.</td>
      </tr>
      <tr>
          <td><code>pg-cluster-upgrade</code> <code>rm -f</code> → <code>rm -rf</code></td>
          <td>sed in <code>os.Dockerfile</code></td>
          <td>Cluster upgrade can leave directories that block re-init.</td>
      </tr>
      <tr>
          <td><code>uled-ctrl</code> stub</td>
          <td>touched executable in <code>os.Dockerfile</code></td>
          <td>LED-control calls can fail on hardware that does not exist.</td>
      </tr>
      <tr>
          <td>Access nginx route installer</td>
          <td><code>install_unifi_access_route.sh</code></td>
          <td>Access can run but fail to appear or proxy correctly through the UniFi OS launcher in some rollback/core combinations.</td>
      </tr>
      <tr>
          <td>Access network timer</td>
          <td><code>unifi-access-network.timer</code></td>
          <td>Wrong management interface or unsafe MTU can return after boot.</td>
      </tr>
  </tbody>
</table>
<p>Some of these patches are ugly.</p>
<p>That is fine.</p>
<p>The dangerous thing is not an ugly patch. The dangerous thing is a silent patch that keeps applying after upstream code moved.</p>
<p>Every sed should fail loudly when its anchor disappears:</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:#66d9ef">if</span> ! sed -i <span style="color:#e6db74">&#39;s|old-pattern|new-pattern|&#39;</span> /path/to/file; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;ERROR: expected patch pattern not found&#34;</span>
</span></span><span style="display:flex;"><span>    exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>The repo follows this pattern for the storage and nginx setup patches. That should stay non-negotiable.</p>
<hr>
<h2 id="validation-suite">Validation Suite</h2>
<p>A clean boot is not proof.</p>
<p>For this image, validation has to prove the app, the console service graph, cloud ownership, and device traffic.</p>
<h3 id="1-package-inventory">1. Package inventory</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-bash" data-lang="bash"><span style="display:flex;"><span>docker run --rm unifi-os-docker-arm64:stable dpkg -l | <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  grep -iE <span style="color:#e6db74">&#39;unifi|ulp|ucs|uid|uos|ubnt|ucore|ustd|ustate&#39;</span>
</span></span></code></pre></div><p>Expected shape:</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-text" data-lang="text"><span style="display:flex;"><span>unifi-core
</span></span><span style="display:flex;"><span>ulp-go
</span></span><span style="display:flex;"><span>ucs-agent
</span></span><span style="display:flex;"><span>uid-agent
</span></span><span style="display:flex;"><span>unifi-identity-update
</span></span><span style="display:flex;"><span>ucore-setup-listener
</span></span><span style="display:flex;"><span>ustate-exporter
</span></span><span style="display:flex;"><span>ustd
</span></span><span style="display:flex;"><span>unifi-access
</span></span><span style="display:flex;"><span>unifi-protect
</span></span><span style="display:flex;"><span>unifi-assets-unvr
</span></span></code></pre></div><p>Do not pin every version in the article. Pin versions in the repo or release notes.</p>
<h3 id="2-systemd-health">2. Systemd health</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-bash" data-lang="bash"><span style="display:flex;"><span>docker exec unifi-os systemctl list-units <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --type<span style="color:#f92672">=</span>service <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --state<span style="color:#f92672">=</span>running <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-pager | grep -iE <span style="color:#e6db74">&#39;ulp|ucs|uid|unifi|uos|ustate|ustd|ucore&#39;</span>
</span></span></code></pre></div><p>Expected: the identity and app daemons are running.</p>
<p>Not every hardware daemon matters in Docker. Treat hardware-only failures separately from identity/app failures.</p>
<h3 id="3-cloud-activation-state">3. Cloud activation state</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-bash" data-lang="bash"><span style="display:flex;"><span>docker exec unifi-os systemctl show ulp-go -p StatusText | <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  grep -oE <span style="color:#e6db74">&#39;standardActivated&#34;:[a-z]+|ucsAgentVersion&#34;:&#34;[^&#34;]+&#34;&#39;</span>
</span></span></code></pre></div><p>Expected:</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-text" data-lang="text"><span style="display:flex;"><span>standardActivated&#34;:true
</span></span><span style="display:flex;"><span>ucsAgentVersion&#34;:&#34;...&#34;
</span></span></code></pre></div><p>If <code>standardActivated</code> stays false after a successful UI-account sign-in, the identity chain is still broken.</p>
<h3 id="4-cloud-connectivity">4. Cloud connectivity</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-bash" data-lang="bash"><span style="display:flex;"><span>docker exec unifi-os grep <span style="color:#e6db74">&#39;^anonymous_device_id:&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /data/unifi-core/config/settings.yaml
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>docker exec unifi-os ss -tnp state established | grep -E <span style="color:#e6db74">&#39;:(443|8883)&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>docker exec unifi-os grep -E <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;Device connected to cloud|Successful sync owner|cloud connection not established&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /data/unifi-core/logs/cloud.log | tail -n <span style="color:#ae81ff">40</span>
</span></span></code></pre></div><p>Expected: <code>anonymous_device_id</code> is a UUID, identity-related processes have outbound TLS sessions to UI cloud endpoints, <code>unifi-core</code> has cloud MQTT/TLS on <code>:8883</code>, and there are no fresh <code>cloud connection not established</code> errors after connection.</p>
<p>This proves connectivity, not supportability.</p>
<h3 id="5-storagestate-grpc-path">5. Storage/state gRPC path</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-bash" data-lang="bash"><span style="display:flex;"><span>docker exec unifi-os ss -ltnp | grep <span style="color:#ae81ff">11052</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>docker exec unifi-os tail -n <span style="color:#ae81ff">20</span> /data/unifi-core/logs/grpc.log
</span></span></code></pre></div><p>Expected: <code>ustate-exporter</code> or the relevant state daemon is listening, and the endless <code>ECONNREFUSED 127.0.0.1:11052</code> loop is gone.</p>
<h3 id="6-access-api-and-device-ports">6. Access API and device ports</h3>
<p>Ubiquiti documents these Access ports:</p>
<table>
  <thead>
      <tr>
          <th style="text-align: right">Port</th>
          <th>Proto</th>
          <th>Purpose</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: right"><code>10001</code></td>
          <td>UDP</td>
          <td>device discovery</td>
      </tr>
      <tr>
          <td style="text-align: right"><code>8080</code></td>
          <td>TCP/HTTPS</td>
          <td>device adoption</td>
      </tr>
      <tr>
          <td style="text-align: right"><code>12080</code></td>
          <td>HTTP</td>
          <td>localhost Access API</td>
      </tr>
      <tr>
          <td style="text-align: right"><code>12443</code></td>
          <td>HTTPS</td>
          <td>Access device communication</td>
      </tr>
      <tr>
          <td style="text-align: right"><code>12455</code></td>
          <td>HTTPS</td>
          <td>OpenAPI when enabled</td>
      </tr>
      <tr>
          <td style="text-align: right"><code>12812</code></td>
          <td>MQTT</td>
          <td>Access device messaging</td>
      </tr>
  </tbody>
</table>
<p>Check device sessions:</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-os ss -tnp state established | grep -E <span style="color:#e6db74">&#39;:(12443|12812)&#39;</span>
</span></span></code></pre></div><p>Check local API:</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-os curl -sS http://127.0.0.1:12080/api/v2/settings | jq .
</span></span></code></pre></div><h3 id="7-network-identity">7. Network identity</h3>
<p>For local host-network deployments, the expected address is the host LAN address.</p>
<p>For the Vol 1 cloud/macvlan deployment, the expected address is the office-LAN macvlan address:</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-os ip -brief addr
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>docker exec unifi-os curl -sS http://127.0.0.1:12080/api/v2/info | <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  jq -c <span style="color:#e6db74">&#39;{host:.data.host.ip, wan:.data.host.wan_ip}&#39;</span>
</span></span></code></pre></div><p>Expected cloud/macvlan shape:</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-json" data-lang="json"><span style="display:flex;"><span>{<span style="color:#f92672">&#34;host&#34;</span>:<span style="color:#e6db74">&#34;10.255.255.200&#34;</span>,<span style="color:#f92672">&#34;wan&#34;</span>:<span style="color:#e6db74">&#34;10.255.255.200&#34;</span>}
</span></span></code></pre></div><p>There should be no Docker bridge <code>172.x.x.x</code> address for UniFi OS to advertise.</p>
<h3 id="8-mtu-repair">8. MTU repair</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-bash" data-lang="bash"><span style="display:flex;"><span>docker exec unifi-os sh -c <span style="color:#e6db74">&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">iface=$(ip -o -4 addr show | awk &#34;$4 ~ /^10\\.255\\.255\\.200\\// {print \$2; exit}&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">cat /sys/class/net/$iface/mtu
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#39;</span>
</span></span></code></pre></div><p>Expected cloud/VXLAN shape:</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-text" data-lang="text"><span style="display:flex;"><span>1200
</span></span></code></pre></div><p>Small discovery packets are not enough. The acceptance test is discovery, adoption, config push, firmware push, and long-lived MQTT.</p>
<hr>
<h2 id="what-this-design-commits-you-to">What This Design Commits You To</h2>
<p>This is not a small “Access container”.</p>
<p>It is closer to 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-text" data-lang="text"><span style="display:flex;"><span>UNVR-shaped UniFi OS userspace inside Docker
</span></span></code></pre></div><p>That has consequences.</p>
<p>You now depend on a pinned UNVR firmware family. Every UniFi OS bump means extracting packages again, rebuilding, and checking whether package names or minified JavaScript anchors moved.</p>
<p>You inherit services you may not care about. Some are required by Identity or Access. Some exist because firmware packages depend on them. Some expect hardware that a container will never have.</p>
<p>You must disable UniFi OS and app auto-updates after setup. A normal console update can replace patched files, move dependencies, or make the sed anchors invalid.</p>
<p>You must keep backups of all persistent paths:</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-text" data-lang="text"><span style="display:flex;"><span>/srv
</span></span><span style="display:flex;"><span>/data
</span></span><span style="display:flex;"><span>/persistent
</span></span></code></pre></div><p>Or in the repo's default local compose layout:</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-text" data-lang="text"><span style="display:flex;"><span>./storage/srv
</span></span><span style="display:flex;"><span>./storage/data
</span></span><span style="display:flex;"><span>./storage/persistent
</span></span></code></pre></div><p>You must treat downgrades as unsafe. Access, Protect, PostgreSQL clusters, device firmware, and UniFi Core state do not all roll back cleanly.</p>
<p>The practical split:</p>
<table>
  <thead>
      <tr>
          <th>Failure</th>
          <th>Treat as</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>rpsd</code> failed because no RPS hardware exists</td>
          <td>Usually cosmetic; mask or ignore after confirming no dependency.</td>
      </tr>
      <tr>
          <td><code>ustate-exporter</code> missing on <code>127.0.0.1:11052</code></td>
          <td>Real image defect.</td>
      </tr>
      <tr>
          <td><code>ucs-agent</code> missing</td>
          <td>Real image defect.</td>
      </tr>
      <tr>
          <td><code>uid-agent</code> missing</td>
          <td>Real image defect.</td>
      </tr>
      <tr>
          <td><code>standardActivated: false</code> after cloud sign-in</td>
          <td>Real cloud/identity defect.</td>
      </tr>
      <tr>
          <td>Docker bridge IP appears as controller identity</td>
          <td>Real network/design defect.</td>
      </tr>
      <tr>
          <td>Access device sessions on <code>12443</code> / <code>12812</code> are missing</td>
          <td>Real device-management defect.</td>
      </tr>
      <tr>
          <td><code>ACCESS_DEVICE_MTU</code> not applied on VXLAN/IPSec path</td>
          <td>Real transport defect.</td>
      </tr>
  </tbody>
</table>
<p>The final lesson is simple:</p>
<blockquote>
<p>A UniFi application is not the same thing as a UniFi console.</p>
</blockquote>
<p>Vol 1 made the controller look local to the door hardware.</p>
<p>Vol 2 made the container look complete enough that UniFi Core, Identity, Access, and UI cloud could agree on the same console.</p>
<hr>
<h2 id="references">References</h2>
<ul>
<li><a href="/posts/2026/05/24/unifi-access-vxlan-over-ipsec/">Vol 1: Doors Don't Route</a></li>
<li><a href="https://github.com/kuzaxak/unifi-access-unvr-docker-arm64">Source repo: kuzaxak/unifi-access-unvr-docker-arm64</a></li>
<li><a href="https://github.com/dciancu/unifi-protect-unvr-docker-arm64">Parent repo: dciancu/unifi-protect-unvr-docker-arm64</a></li>
<li><a href="https://help.ui.com/hc/en-us/articles/34210126298775-Self-Hosting-UniFi">Ubiquiti: Self-Hosting UniFi</a></li>
<li><a href="https://help.ui.com/hc/en-us/articles/22230509487639-UniFi-Consoles-with-UniFi-Access-Support">Ubiquiti: UniFi Consoles with UniFi Access Support</a></li>
<li><a href="https://help.ui.com/hc/en-us/articles/17452334269975-Getting-Started-with-UniFi-Access">Ubiquiti: Getting Started with UniFi Access</a></li>
<li><a href="https://github.com/ConnorsApps/unifi-os-helm/blob/main/SERVICES.md">ConnorsApps/unifi-os-helm SERVICES.md</a></li>
</ul>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
