<?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>Local-Ai on Mini Fish</title>
    <link>https://blog.minifish.org/tags/local-ai/</link>
    <description>Recent content in Local-Ai on Mini Fish</description>
    <image>
      <title>Mini Fish</title>
      <url>https://blog.minifish.org/android-chrome-512x512.png</url>
      <link>https://blog.minifish.org/android-chrome-512x512.png</link>
    </image>
    <generator>Hugo -- 0.165.0</generator>
    <language>en-US</language>
    <copyright>Mini Fish 2014-present. Licensed under CC-BY-NC</copyright>
    <lastBuildDate>Sat, 19 Sep 2026 18:00:00 +0800</lastBuildDate>
    <atom:link href="https://blog.minifish.org/tags/local-ai/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Inside My GMK and Lightsail Homelab</title>
      <link>https://blog.minifish.org/posts/gmk-lightsail-homelab-architecture/</link>
      <pubDate>Sat, 19 Sep 2026 18:00:00 +0800</pubDate>
      <guid>https://blog.minifish.org/posts/gmk-lightsail-homelab-architecture/</guid>
      <description>An architecture tour of my GMK and AWS Lightsail homelab: Tailscale, agentd, Tailgate, local-ai, Matrix, World Loom, Sentinel, Beszel, and NAS backups.</description>
      <content:encoded><![CDATA[<p>The projects on this blog often look independent: an agent runtime, a cycling route planner, an AI gateway, a browser game. In daily use, several of them share the same small infrastructure.</p>
<p>At home, a <strong>GMK M5 Ultra</strong> runs the applications that need memory, persistent storage, or local compute. A small <strong>AWS Lightsail instance in Singapore</strong> supplies the public entry point and runs lightweight network services. <strong>Tailscale</strong> connects them. A NAS stores encrypted backups, and a Cloudflare Worker watches the system from outside both machines.</p>
<p>That division lets me keep the cloud instance small while giving my projects a place to run continuously. The interesting part is how the pieces fit together: where requests enter, where state lives, which services remain private, and what happens when one machine disappears.</p>
<p>This is a snapshot of the running system on <strong>September 19, 2026</strong>, checked against the live hosts and deployment configuration. Repository links appear throughout; repositories marked <strong>private</strong> require access.</p>
<h2 id="the-architecture">The architecture</h2>
<p><a href="homelab-architecture.png"><img alt="Architecture of the Minifish homelab. Lightsail handles public ingress, transport adapters, and model routing. Tailscale connects it to GMK applications and local inference. Private clients reach GMK directly. Cloudflare hosts the World Loom frontend and the independent Sentinel monitor; encrypted backups go to a NAS." loading="lazy" src="/posts/gmk-lightsail-homelab-architecture/homelab-architecture.png"></a></p>
<p><a href="homelab-architecture.png">Open full resolution</a> · <a href="diagrams-source.zip">Download editable SVG diagrams</a></p>
<p>The diagram groups related services; it is not a map of every listening port. The two most important boundaries are the public/private network boundary and the separation between application state and its recovery copies.</p>
<table>
	<thead>
			<tr>
					<th>Location</th>
					<th>What lives there</th>
					<th>Why it belongs there</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>GMK M5 Ultra</td>
					<td>agentd, Canopy/GraphHopper, local-ai, Matrix/RTC, World Loom, Beszel</td>
					<td>Local compute, memory, and persistent application data</td>
			</tr>
			<tr>
					<td>Lightsail, Singapore</td>
					<td>Nginx ingress, Telegram/X adapters, Tailgate, SimpleX, Trojan-Go, Durvo server</td>
					<td>Public reachability and lightweight services that can stay up independently of GMK</td>
			</tr>
			<tr>
					<td>Tailscale network</td>
					<td>Host connectivity, private HTTPS endpoints, administration</td>
					<td>One private network across devices and locations</td>
			</tr>
			<tr>
					<td>Cloudflare</td>
					<td>World Loom static frontend; Sentinel Worker, D1, and Access</td>
					<td>Static delivery and an observation point outside the two hosts</td>
			</tr>
			<tr>
					<td>NAS</td>
					<td>Encrypted Restic snapshots</td>
					<td>Recovery data on a separate storage system</td>
			</tr>
	</tbody>
</table>
<p>The GMK has a Ryzen 7 7730U, 64 GB of installed RAM—about 62 GiB visible to Linux—and roughly 954 GiB of usable NVMe capacity. It runs native Debian 13 with Docker Compose. At inspection, it had eleven running application containers. The Lightsail instance has only about 442 MiB of memory visible to Linux and runs its services under systemd.</p>
<p>The memory-heavy services run at home, while small edge services can remain available independently of home power and connectivity.</p>
<h2 id="lightsail-is-the-public-edge">Lightsail is the public edge</h2>
<p>Public Matrix requests arrive at Lightsail. Nginx sends them across Tailscale to Tuwunel on GMK. MatrixRTC authentication and LiveKit signaling follow the same broad path.</p>
<p>There are two layers at the HTTPS entry point. The outer Nginx stream listener examines the TLS server name and selects a local backend. For Matrix, RTC, and Durvo, that backend is an HTTP server block that terminates TLS and proxies the request. SimpleX services have their own TLS endpoints. This uses Nginx&rsquo;s <a href="https://nginx.org/en/docs/stream/ngx_stream_ssl_preread_module.html"><code>ssl_preread</code></a> support: selecting a backend from the ClientHello does not itself terminate the TLS connection.</p>
<p>Media needs a separate path. My LiveKit deployment uses TCP 7881 and UDP 7882, which Lightsail forwards with nftables DNAT and masquerading to GMK over Tailscale. The HTTPS proxy handles signaling; the media forwarding rules handle the corresponding WebRTC traffic. LiveKit documents these as its <a href="https://docs.livekit.io/transport/self-hosting/ports-firewall/">ICE/TCP and UDP mux ports</a>.</p>
<table>
	<thead>
			<tr>
					<th>Traffic</th>
					<th>Lightsail&rsquo;s role</th>
					<th>GMK destination</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Matrix client/server API</td>
					<td>HTTPS reverse proxy</td>
					<td>Tuwunel</td>
			</tr>
			<tr>
					<td>MatrixRTC authorization</td>
					<td>HTTPS reverse proxy</td>
					<td>MatrixRTC Auth</td>
			</tr>
			<tr>
					<td>LiveKit signaling</td>
					<td>HTTPS/WebSocket reverse proxy</td>
					<td>LiveKit API</td>
			</tr>
			<tr>
					<td>LiveKit media</td>
					<td>TCP/UDP forwarding</td>
					<td>LiveKit media ports</td>
			</tr>
	</tbody>
</table>
<p>This distinction matters during debugging. An HTTP health check can pass while media forwarding is broken. The migration checks exercised the HTTP endpoints, TCP connectivity, and a UDP packet traversing the forwarding path; those checks alone do not prove that a complete two-party call works.</p>
<p>The upstream projects are <a href="https://github.com/matrix-construct/tuwunel">Tuwunel</a>, <a href="https://github.com/livekit/livekit">LiveKit</a>, and <a href="https://github.com/element-hq/lk-jwt-service">MatrixRTC Authorization Service</a>. My deployment configuration and recovery tooling live in <a href="https://github.com/minifish-org/infra"><code>infra</code></a> <strong>(private)</strong>.</p>
<h2 id="a-personal-agent-request-crosses-the-boundary-more-than-once">A personal agent request crosses the boundary more than once</h2>
<p>The agent stack shows how the two machines share a workload.</p>
<p><a href="agent-request-flow.png"><img alt="Personal agent request flow. Telegram and X adapters on Lightsail submit turns to agentd on GMK. agentd calls Tailgate on Lightsail, which selects local-ai on GMK or a hosted provider. agentd can call Canopy and GraphHopper for route planning. Results are committed with an optional delivery outbox, which the adapters claim and acknowledge." loading="lazy" src="/posts/gmk-lightsail-homelab-architecture/agent-request-flow.png"></a></p>
<p><a href="agent-request-flow.png">Open the request-flow diagram at full resolution</a>. A typical request follows this path:</p>
<ol>
<li>The <strong>Telegram adapter</strong> receives a webhook, or the <strong>X adapter</strong> polls an eligible mention.</li>
<li>The adapter submits a turn to <strong>agentd on GMK</strong> through the private HTTPS endpoint.</li>
<li>agentd calls <strong>Tailgate on Lightsail</strong> using the configured model route.</li>
<li>Tailgate forwards the request to <strong>local-ai on GMK</strong> or a configured hosted provider.</li>
<li>agentd runs any permitted tools and persists the result. When delivery was requested, finalization also makes that result available through its delivery outbox.</li>
<li>The adapter claims the delivery, sends it through the platform API, and acknowledges it.</li>
</ol>
<p>The adapters own platform-specific behavior. agentd owns execution and durable run state. The adapters do not reach into its database to find replies. This keeps Telegram webhook policy, X polling, media handling, and delivery retries out of the core runtime.</p>
<p>The outbox makes the handoff inspectable, but an external send and a local acknowledgment are still separate operations. It should not be read as a blanket exactly-once guarantee for a third-party API.</p>
<p>The public repositories are <a href="https://github.com/minifish-org/agentd"><code>agentd</code></a> and <a href="https://github.com/minifish-org/agentd-telegram-adapter"><code>agentd-telegram-adapter</code></a>. The <a href="https://github.com/minifish-org/agentd-x-adapter"><code>agentd-x-adapter</code></a> repository is <strong>private</strong>. I discuss the runtime boundary in more detail in <a href="/posts/agentd-transport-neutral-runtime/">agentd: A Transport-Neutral Runtime for Personal Agents</a>.</p>
<h3 id="why-the-model-gateway-stays-on-lightsail">Why the model gateway stays on Lightsail</h3>
<p><a href="https://github.com/minifish-org/tailgate"><code>Tailgate</code></a> <strong>(private)</strong> provides one OpenAI-compatible API with centralized provider credentials and explicit model routing. The running gateway is bound to the Lightsail Tailscale address; it is also available through Tailscale Serve. Living on a public VM does not make this API a public endpoint.</p>
<p>The configured upstreams currently include the GMK local-ai service, DeepSeek, and OpenRouter. A route such as <code>local/chat</code> identifies the local capability; other routes can select hosted models according to their configuration. Fallback behavior belongs to that policy—it should not silently turn every local request into a remote one.</p>
<p>This does create an extra trip: agentd on GMK calls Lightsail, which may call GMK again for local inference. I accept that for a shared policy point used by multiple clients. It also creates a real dependency: if Lightsail is down, agentd&rsquo;s configured model gateway is unavailable even when GMK is healthy. The direct local-ai endpoint is a separate access path, not an automatic agentd failover mechanism.</p>
<p>The earlier <a href="/posts/tailgate-private-ai-gateway/">Tailgate article</a> describes the gateway design. Its original local backend was <code>qwen-local</code> on an Apple Silicon Mac. The live Lightsail configuration now points to <strong>local-ai on GMK</strong>.</p>
<h3 id="local-inference-is-a-service-with-a-finite-budget">Local inference is a service, with a finite budget</h3>
<p><a href="https://github.com/minifish-org/local-ai"><code>local-ai</code></a> <strong>(private)</strong> exposes chat, embeddings, speech synthesis, transcription, and translation behind a compatible API. Its current deployment has an API container, a runtime container, and a separate resident chat server. The API and native chat webpage share that chat server.</p>
<p>The current chat setup runs on the CPU with six threads, one inference slot, and a 48 GiB container memory limit. Embeddings use the AMD Vulkan backend; the other capabilities retain their own backends. This is a mixed workload on one small machine, so the useful questions are concrete: what is resident, what is queued, and which requests contend for the same resources?</p>
<p>In particular, the direct chat webpage reaches the chat service without going through the API&rsquo;s cross-capability queue. It still uses the chat server&rsquo;s single slot, but that does not serialize every other workload on the host. Container memory limits are ceilings, not reservations; setting several limits does not create additional physical RAM.</p>
<h3 id="canopy-gives-the-agent-a-route-engine">Canopy gives the agent a route engine</h3>
<p><a href="https://github.com/minifish-org/canopy"><code>Canopy</code></a> <strong>(private)</strong> is an MCP service for Singapore cycling and walking routes. It uses a local GraphHopper instance for routing, with a model that prefers PCN-like and low-traffic paths. OneMap supplies geocoding and points of interest.</p>
<p>Canopy and GraphHopper run alongside agentd on GMK without host-published ports. The agent can ask a specialized tool to plan and audit a route, then explain the returned geometry, distance, and GPX output. The language model does not have to invent the route.</p>
<p>The deployment also distinguishes durable state from reproducible data: the GraphHopper graph cache is rebuildable and is excluded from the daily application-data backups. More on the tool itself: <a href="/posts/canopy-singapore-pcn-routing/">Canopy: Local MCP Routing for Singapore PCN Loops</a>. Its routing engine is <a href="https://github.com/graphhopper/graphhopper">GraphHopper</a>.</p>
<h2 id="a-public-frontend-can-have-a-private-backend">A public frontend can have a private backend</h2>
<p>World Loom is a small shared virtual world with a browser client and an authoritative Rust server. Its static client is served from Cloudflare Pages at <a href="https://wl.minifish.org/">wl.minifish.org</a>. The live world runs on GMK.</p>
<p>The browser downloads the client from Pages, then connects directly to the GMK&rsquo;s private HTTPS/WebSocket bridge through Tailscale. Cloudflare Pages does not proxy the gameplay connection. Opening the public webpage therefore does not grant access to the world; the client device still needs the private network connection.</p>
<p>The server owns world updates and persistence, including SQLite and Anvil region files. Its MCP interface lets a client inspect or edit the live world through the server&rsquo;s update loop. That endpoint is exposed through Tailscale Serve and also requires a bearer token. The browser bridge has an explicit origin allowlist.</p>
<p><a href="https://tailscale.com/docs/features/tailscale-serve">Tailscale Serve</a> supplies the private HTTPS entry points. The World Loom endpoints use Serve without Funnel; the same host also exposes private agentd, local-ai, and Beszel endpoints. Keeping an application behind the tailnet complements its own authentication and authorization.</p>
<p>The public browser code is in <a href="https://github.com/minifish-org/world-loom-client"><code>world-loom-client</code></a>. The <a href="https://github.com/minifish-org/world-loom-server"><code>world-loom-server</code></a> and integration workspace <a href="https://github.com/minifish-org/world-loom-stack"><code>world-loom-stack</code></a> are <strong>private</strong>.</p>
<h2 id="other-services-that-belong-at-the-edge">Other services that belong at the edge</h2>
<p>Lightsail also runs a few services with useful independent lifetimes:</p>
<ul>
<li><strong>SimpleX SMP and XFTP</strong> provide messaging relay and file-transfer services. Both come from <a href="https://github.com/simplex-chat/simplexmq">simplexmq</a>.</li>
<li><strong>Trojan-Go</strong> supplies an existing proxy service, from <a href="https://github.com/p4gefau1t/trojan-go">trojan-go</a>.</li>
<li><strong>Durvo</strong> runs its single-node control server behind Nginx. <a href="https://github.com/minifish-org/durvo"><code>Durvo</code></a> <strong>(private)</strong> is my experimental SQLite recovery project, with immutable recovery objects in managed S3 and restoration into isolated staging targets.</li>
</ul>
<p>Durvo is still a self-use experiment with unresolved capture-maturity limitations. The homelab recovery procedure described below relies on consistent snapshots and Restic; Durvo is not the protection mechanism for those application databases.</p>
<h2 id="observe-the-machine-from-inside-and-availability-from-outside">Observe the machine from inside, and availability from outside</h2>
<p>I use two monitoring layers because they answer different questions.</p>
<p><strong><a href="https://github.com/henrygd/beszel">Beszel</a></strong> runs on GMK: a containerized Hub and a native Agent. It shows CPU, memory, storage I/O, container activity, temperatures, and NVMe SMART information. The dashboard is available only through Tailscale. This is the view I use to understand whether inference is competing with other work or whether the host is running out of resources.</p>
<p><strong><a href="https://github.com/minifish-org/sentinel"><code>Sentinel</code></a> (private)</strong> has a different position. Short-lived probes on GMK and Lightsail send signed reports every fifteen minutes to a Cloudflare Worker. The Worker also checks public Matrix, RTC, and Durvo endpoints, stores compact history in D1, and serves a status page protected by Cloudflare Access.</p>
<p>Lightsail can observe GMK&rsquo;s reachability and Beszel&rsquo;s HTTPS endpoint from another host. If GMK loses power, its local dashboard disappears, but the Worker and Lightsail remain separate observation points. Missing reports become an explicit unknown state after 35 minutes rather than leaving an old green result on screen indefinitely.</p>
<p>Sentinel currently records incidents on its status page. It does not send push/email notifications, restart applications, or repair hosts. Its polling interval also means detection takes time.</p>
<p>The useful distinction is between a process running, an application responding, and a scheduled job completing successfully. A running container does not prove that yesterday&rsquo;s backup succeeded.</p>
<h2 id="recovery-is-part-of-the-architecture">Recovery is part of the architecture</h2>
<p>The daily backup job runs at <strong>03:30 Singapore time</strong>. It stops the main database writers—Tuwunel, World Loom, and agentd—creates consistent archives, verifies checksums, and starts those applications again before uploading to the NAS.</p>
<p>This introduces a deliberate service interruption during snapshot creation. For this personal workload, a straightforward consistency boundary is worth that interruption.</p>
<p><a href="https://github.com/restic/restic">Restic</a> stores encrypted snapshots on the NAS over SFTP. Retention is seven daily, four weekly, and twelve monthly snapshots, with seven days of local staging. Additional hooks capture Beszel&rsquo;s consistent database backup and local-ai&rsquo;s configuration and model manifest. Model weights and rebuildable caches are excluded from the routine backup; recovering them can require downloading or rebuilding them.</p>
<p>The schedule includes a weekly repository check and a monthly isolated restore drill. The automated restore drill covers the original application set. Beszel has integrity checks on each backup and a separately documented manual restore drill; adding a service to the backup archive does not automatically add it to every recovery test.</p>
<p>There is also an encrypted recovery bundle containing the captured configuration, operational tooling, consistent database archives, and application images from the core migration. That bundle has its own date and scope. It needs refreshing as services and deployment details change.</p>
<p>The NAS is a separate storage target, but that alone says nothing about geographic separation or every shared failure mode. Daily snapshots also leave changes since the last successful snapshot exposed to loss. Those are properties of the design, not things a successful checksum can fix.</p>
<h3 id="a-migration-made-the-recovery-path-real">A migration made the recovery path real</h3>
<p>On September 12, I moved the core services from a Mac/Colima deployment to native Debian on the GMK. Before switching traffic, I rehearsed restoration on isolated Docker networks and checked the restored applications.</p>
<p>At cutover, the source writers and timers were stopped, final data was captured, and the Lightsail upstreams and private client endpoints were changed. On the new host, the first NAS backup, repository check, isolated restore, and a production reboot were verified. The old Colima instance was then shut down with its frozen state retained.</p>
<p>The subtle part is rollback. Once the new host accepts writes, pointing traffic back at the frozen source can discard those writes. A retained machine is useful recovery material; it is not automatically a safe failback target.</p>
<h2 id="what-this-arrangement-gives-me">What this arrangement gives me</h2>
<p>The GMK gives the projects room to work. Lightsail gives them a stable public edge and a shared model gateway. Tailscale lets private services remain reachable from my devices without turning each one into a public web application. Cloudflare supplies static delivery and an independent monitoring location, while the NAS and restore procedures make host replacement practical.</p>
<p>There are still clear failure domains. Losing GMK stops its agents, local inference, Matrix backend, and shared world. Losing Lightsail breaks the public entry path and configured model gateway. Neither machine automatically takes over the other&rsquo;s work.</p>
<p>For my current workload, the value is that I can follow a request across the system, find the state it changes, and explain how that state can be recovered. Each new project has to fit into that same model: an entry point, an execution boundary, a place for durable data, a health signal, and a recovery path.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
