Unraid Plugin
August 5, 2026 · View on GitHub
unraid/ packages labby as a classic Unraid webGUI plugin (.plg) — a
native, rc.d-managed process by default, with an optional Incus-backed mode
for stdio MCP workloads. No Docker, no systemd on the host (Unraid does not
run one). This is a separate deployment target from INCUS.md
(recommended self-hosted gateway runtime) and the Docker Compose stack
(docker-compose.prod.yml); pick whichever fits the host, they are not
mutually exclusive.
Why native instead of Docker
Unraid has first-class Docker support, and the released image
(ghcr.io/dinglebear-ai/labby) already runs there. The .plg path exists because
"installable as an Unraid plugin" specifically means the classic
.plg/Plugins-tab mechanism, and because labby is a single, dynamically
linked (glibc) binary with no bundled shared libraries and its own embedded
admin web UI — it does not need container isolation or a companion frontend
service the way a plugin like ~/workspace/incus-unraid (packaging Incus,
a system-container manager with real host-library dependencies) does.
Empirically verified on real hardware (Unraid 7.3.1, glibc 2.43): the
lab-x86_64-unknown-linux-gnu.tar.gz release binary runs unmodified —
ldd resolves cleanly (libc, libgcc_s, libm only) and labby serve fully
bootstraps. The binary's max required symbol version is GLIBC_2.39, well
under Unraid's 2.43. No musl/static build is needed for this to work.
(This was a one-off binary-compatibility check on a production NAS, done
before plugin development started. All later end-to-end plugin testing —
install/start/stop/settings-form/OAuth-login flows — moved to a dedicated
test box, tower (Unraid 7.3.2); see "Validated end-to-end" below.)
Two runtime modes
labby.cfg's RUNTIME_MODE selects how the gateway actually runs:
native(default) — the bare rc.d process described above. It has no dependency on Incus and is the lowest-risk install path, but it cannot run most stdio MCP servers: Unraid's bare host ships neithernpx/Node noruv/Python, which is what most community MCP servers are distributed as. Use this mode for the core gateway API, registry, and Code Mode surfaces that do not need a host toolchain.incus— runs labby inside an Incus system container using labby's own pre-builtlabby-incusrelease image. The image bakes in Node, uv-managed Python, Rust, Go, the agent CLIs, and Tailscale; see INCUS.md for the full toolchain floor. This is the capability-complete mode for real stdio MCP server workloads.
incus mode has a hard dependency: the ~/workspace/incus-unraid plugin
must already be installed, enabled (SERVICE=enabled in its own
incus.cfg), and running after the array starts. This plugin does not bundle
a second Incus daemon. scripts/labby-incus-env.sh points the Incus CLI at
incus-unraid's private prefix (/usr/local/incus) and daemon state
(INCUS_DIR=/mnt/user/appdata/incus), and every Incus operation in
scripts/labby-incus-init.sh goes through that environment.
The Incus container layout is intentionally separate from incus-unraid's own
agent-jail defaults. labby-incus-init.sh creates a dedicated labby-dir
storage pool, a dedicated labby-gateway profile, and a dedicated bridge named
by INCUS_BRIDGE_NAME (labbybr0 by default). It never touches
incus-unraid's agentbr0, its default profile, its storage pool, or its daemon
lifecycle, and the labby container launches with only the dedicated profile.
The bridge CIDR comes from INCUS_BRIDGE_SUBNET (10.99.99.1/24 by default)
and is checked for host-route collisions before creation. If the bridge already
exists but is not Incus-managed, startup fails closed instead of deleting or
reusing an unmanaged host interface.
Bridge egress is explicit but deliberately narrow. INCUS_EGRESS_POLICY="block-lan"
is the default and installs verified host FORWARD rejects for traffic entering
from the Incus bridge and targeting private IPv4/Cgnat ranges (10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10). This blocks
bridge-forwarded private destinations over eth0; it is not a full tailnet
or in-container firewall policy. Tailscale traffic originates inside the
container, so use a tagged Tailscale auth key and tailnet ACLs for tailnet
egress control. Set INCUS_EGRESS_POLICY="allow-lan" to remove those reject
rules and allow normal bridge-forwarded NAT egress from the container. The
plugin also removes the rules it owns when Incus mode stops or the plugin is
uninstalled.
Reachability for incus mode is via Tailscale running inside the container,
not host port forwarding. INCUS_TS_AUTHKEY is a write-only, one-shot
Settings > Labby field: the value is never echoed back into the form, is
written into the container only as a mode-0600 temporary file, is removed
after tailscale up, and is cleared/redacted from both labby.cfg and
labby.cfg.bak after an attempted consumption. Leaving the password field
blank when saving also clears any previously stored key, which is how to
recover from an expired or failed preauth key.
The Incus image pin is independent of the plugin package version and the
bundled native labbyVersion. INCUS_IMAGE_VERSION defaults to "1.2.0"
because that tag currently publishes
labby-incus-x86_64-unknown-linux-gnu.tar.xz; v1.3.0 does not. The
known-good v1.2.0 SHA256 from the published .sha256 asset is:
dfb57f59b52a84db5b14ac71588b676d7135d4b24916628006aaaed8f022c25d
INCUS_IMAGE_SHA256 must be set to the digest for the configured
INCUS_IMAGE_VERSION before incus mode can start. Cached image bytes live
under ${LABBY_DIR}/incus-images, include the image version in the filename,
and are verified against the configured SHA256 before import; corrupt caches
are removed and redownloaded once, then fail closed if verification still does
not match. Imported Incus images and launched containers are stamped with the
configured image version and SHA256; if an existing alias or container does not
match the configured pin, startup fails with explicit delete/recreate guidance
instead of silently reusing stale runtime bytes.
Known release gap: the labby-incus-x86_64-unknown-linux-gnu.tar.xz release
asset has not published successfully since v1.2.0 (gh release view v1.3.0 --repo dinglebear-ai/labby --json assets shows only the plain binary archives and
SHA256SUMS). Confirm the latest tag before assuming a newer
INCUS_IMAGE_VERSION will work, and bump both INCUS_IMAGE_VERSION and
INCUS_IMAGE_SHA256 together once the release-asset CI gap is fixed.
Layout
icon.svg repo-root brand mark (dinglebear-ai mark system) — the CA repository icon
ca_profile.xml points at, and the vector source for both PNGs below
ca_profile.xml repo-root Community Applications repository profile
unraid/
labby.plg plugin manifest (installed via Unraid's Plugins tab)
ca/labby.xml Community Applications plugin template (see "Community Applications" below)
source/usr/local/emhttp/plugins/labby/
labby.cfg default config template (flash-persisted copy is the source of truth once installed)
images/labby.png 460px brand avatar — Plugins page (PLUGIN icon attr), Settings panel grid (Icon=), and the CA <Icon> URL
icons/labby.png 64px favicon cut — webGUI tab_title() renders Tag=".png" from icons/ at 18px
Labby.page status + settings form (SERVICE/LABBY_DIR/HTTP_HOST/HTTP_PORT/RUNTIME_MODE/INCUS_IMAGE_SHA256/...) plus native gateway reload/upstream controls
LabbyDashboard.page dependency-free Main/Dashboard tile for live aggregate gateway health
include/dashboard-status.php authenticated native/Incus status endpoint used only by the dashboard tile
scripts/rc.labby start/stop/restart/status, branches on RUNTIME_MODE between the native rc.d path and the Incus container path
scripts/labby-preflight.sh read-only glibc/binary sanity check for native mode; rc.labby refuses to start if this fails
scripts/labby-incus-env.sh points the Incus CLI at incus-unraid's private-prefixed daemon — incus mode only
scripts/labby-incus-init.sh idempotent Incus-mode converger: storage pool, bridge, egress policy, profile, image import, container launch, in-container provisioning — incus mode only
incus/labby-gateway-profile.yaml vendored copy of config/incus/labby-gateway-profile.yaml
event/disks_mounted array-start hook — calls rc.labby start, which is RUNTIME_MODE-aware
event/unmounting_disks array-stop hook — calls rc.labby stop, which is RUNTIME_MODE-aware
labby.plg does not bundle a .txz/Slackware package the way
incus-unraid does — that exists there because Incus ships multiple
binaries plus host libraries Unraid doesn't provide. labby is one binary
with no extra libs, so the .plg downloads the existing GitHub Release
tarball directly (the same asset scripts/install.sh consumes) plus each
small companion file under source/, each pinned by its own <MD5> entity.
Persistence model (Unraid boots into RAM from a flash drive)
- Persistent config:
/boot/config/plugins/labby/labby.cfg(flash). Seeded once at install, never overwritten if already present. EditSERVICE=enabledhere to autostart on array start. - One-shot Incus Tailscale key:
/boot/config/plugins/labby/incus-ts-authkey(flash, best-effort mode0600). The settings page writes it separately from the bash-sourceable config;labby-incus-init.shconsumes it, deletes it, and redacts any legacyINCUS_TS_AUTHKEYvalue fromlabby.cfgandlabby.cfg.bakafter every attempted use. - Runtime OS files:
/usr/local/emhttp/plugins/labby/*(RAM). Rebuilt fresh from the flash-cached tarball +source/files on every boot. - Gateway state (
auth.db,registry.db,config.toml, the MCP bearer token — everything labby normally writes under$HOME/XDG dirs):LABBY_DIRinlabby.cfg, default/mnt/user/appdata/labby(array, survives reboots — the same convention every Unraid Docker app's appdata mount already uses).rc.labbyexportsHOME/XDG_*to point there instead of root's RAM-only/root.
Settings page conventions
Labby.page is the complete Labby-for-Unraid control plane, rendered
directly inside Settings > Labby. Its Overview, Gateway, and Settings tabs
match the supplied Labby-for-Unraid design while remaining a native .page;
there is no iframe, separate route, mock dataset, or standalone settings
application. Status cards and counts come from rc.labby plus the live
gateway catalog. Gateway controls expose reload, filter,
add-HTTP/add-stdio, enable/disable, remove, and stale-process cleanup
actions. Settings owns SERVICE, LABBY_DIR, HTTP_HOST, HTTP_PORT,
RUNTIME_MODE, and all Incus image/network/Tailscale fields from
labby.cfg, with backup-first atomic persistence.
LabbyDashboard.page adds a native Main > Dashboard tile. It polls
include/dashboard-status.php every 15 seconds for service/runtime state,
connected versus enabled upstream counts, exposed tool totals, and connection
errors. The endpoint is served behind the normal webGUI session and returns
aggregates only: upstream names, URLs, commands, environment keys, credentials,
logs, and tool arguments are never included. Set
DASHBOARD_WIDGET_ENABLE="false" in Settings to hide the tile without
disabling the gateway.
In native mode gateway actions run the host plugin binary against
LABBY_DIR; in Incus mode the same actions execute
labby --json gateway ... inside the gateway container as the labby user
so they operate on the live container-owned gateway state. The visual shell
uses the same role patterns and theme contract as the components under
~/workspace/upstream/unraid-api/unraid-ui, adapted to the classic Unraid
PHP/Markdown runtime that actually hosts third-party plugin pages.
The markup conventions were reverse-engineered from a live Unraid 7.3.x
install's own pages (/usr/local/emhttp/webGui/DateTime.page,
dynamix.my.servers/Connect.page) and cross-checked against
~/workspace/upstream/unraid-api (which shares the same theme-token
contract with the classic webGUI via Theme--white/black/gray/azure, even
though its Tailwind/Vue tokens aren't directly usable from a classic
.page):
- Fields: the
_(Label)_:/: <input>markdown definition-list idiom on a<form markdown="1">— Unraid's own page renderer (PageBuilder.php/MainContent.php) runs the whole page body throughMarkdown(), which turns this into a real<dl>. No custom wrapper divs. - Selects:
mk_option($current, $value, $label)(a core webGUI global, loaded unconditionally viawebGui/template.php) — used for every enum-valued field (SERVICE, HTTP_HOST, RUNTIME_MODE, INCUS_EGRESS_POLICY), matching how every real first-party page handles booleans/enums (no switchbutton widget; that's a real but unconfirmed-markup asset pulled from a separateunraid/webguirepo at dev time, not worth the risk of hand-rolling incorrectly). - Help text:
<blockquote class="inline_help">immediately after a field'sdt/dd— wired automatically to the toolbar's "?" help toggle by core JS (DefaultPageLayout.php) that scans for this class, no extra markup needed on the label side. - Status color:
.green-text/.red-text(core classes backed by--green-800/--red-600), not hand-rolled hex values. - Buttons: bare
<input type="submit">— no.btn-style class exists in any genuine first-party.page; the core stylesheet styles submit/ button inputs automatically. - Icon:
Icon="labby.png"+Tag="labby.png"— the real Labby brand mark (dinglebear-ai mark system, generated from the repo-rooticon.svg). Verified against a live 7.3.x install's core code: these are two different resolution paths.Icon=".png"(Settings panel grid, viaMainContent.php'sprocess_icon()) and the plugin manager'siconattribute (ShowPlugins.php) both resolve fromplugins/labby/images/, whileTag=".png"(the 18px tab-title icon, viaPageBuilder.php'stab_title()) resolves fromplugins/labby/icons/. Hence two shipped assets:images/labby.png(460px avatar, dark background) andicons/labby.png(64px transparent favicon cut — the brand system's designated below-20px variant). A bare FontAwesome name (the pre-1.4.0Icon="server"interim) remains the fallback pattern if the PNGs are ever missing.
Persistence stays custom, deliberately: settings do not go through
Unraid's generic Dispatcher.php (POST /update.htm with a #cfg field),
even though that's the more "native" save mechanism for classic pages.
Dispatcher.php requires an INI [section]-headed config file
(parse_ini_file($file, true)), but labby.cfg is a flat, [section]-free
KEY="value" file so rc.labby/the event hooks can keep bash-source-ing
it unmodified. Labby.page POSTs back to itself instead and regenerates
the whole file from a template, preserving the explanatory comments.
Because rc.labby bash-sources labby.cfg verbatim, every field is
validated server-side against a strict allowlist before being written —
an unvalidated value containing a ", $, backtick, or newline would be
interpreted as shell syntax the next time the file is sourced, not just a
bad config value. SERVICE/HTTP_HOST are checked against an exact-match
enum (not just constrained by the <select>, which a crafted raw POST can
bypass), RUNTIME_MODE and INCUS_EGRESS_POLICY against exact enums,
INCUS_CONTAINER_NAME against a DNS-label pattern, INCUS_IMAGE_VERSION
against plain X.Y.Z, INCUS_IMAGE_SHA256 against a lowercase 64-character
hex digest when provided, INCUS_TS_AUTHKEY against a short token-safe
character set, INCUS_BRIDGE_SUBNET against IPv4 CIDR syntax, HTTP_PORT
against a numeric 1–65535 range, and LABBY_DIR against a path-character
allowlist (^/[A-Za-z0-9_./-]+$) with no shell metacharacters permitted at
all.
Two version numbers, on purpose
labby.plg tracks two independent versions:
version— the plugin package's own version, shown in Unraid's Plugins page and used for its install/update comparison. Bumped wheneverunraid/packaging itself changes, even if labby's binary hasn't (e.g. the1.3.0abump that shipped theHTTP_HOSTdefault fix below).labbyVersion— the labby release tag this plugin currently bundles. Only this entity drivestarballURL/tarballMD5. Keeping it separate fromversionmeans a packaging-only fix never has to point at a labby release tag whose binary asset doesn't exist (or force a new labby release just to ship a plugin bugfix).
Keeping the .plg in sync with releases
Every <MD5> in labby.plg, plus labbyVersion, must match what's
actually published, or Unraid's install/update either 404s or fails
checksum verification. scripts/ci/unraid-plugin-checksums.sh is the single
source of truth for this — it checks (default) or rewrites (--fix) every
entity:
scripts/ci/unraid-plugin-checksums.sh # check only
scripts/ci/unraid-plugin-checksums.sh --fix # repair after editing unraid/source/
scripts/ci/unraid-plugin-checksums.sh --tag vX.Y.Z --tarball PATH # also check labbyVersion + release tarball MD5
ci.yml's always-onunraid-plugin-checkjob runs the no-args form on every push/PR, so editingunraid/source/without running--fixafterward fails CI immediately (this is exactly the failure mode that motivated the script — see git history for theLabby.pagechecksum drift caught and fixed during initial scaffolding).- The
--tag/--tarballform is a manual tool, not wired into any CI job. An earlier version of this PR ran it automatically fromrelease.ymlagainst${{ github.ref_name }}(the tag currently being released) — that was wrong on two counts, caught in review:labbyVersionintentionally pins to a specific, already-published, manually-vetted labby release (see "Two version numbers" above), not whatever tag is currently being built, so comparing it togithub.ref_namewould fail on every release where they legitimately differ (which is the normal case); and even if they matched, a freshly-built release tarball's MD5 is not reproducible build-to-build — GNU tar embeds each packaged file's mtime, so byte-identical binary content still produces a different archive hash on every CI run, making an automatic same-run comparison impossible to ever pass. Run this form by hand instead, against a tarball downloaded from the already-published release you're pointinglabbyVersionat, whenever you deliberately bump it:gh release download vX.Y.Z --repo dinglebear-ai/labby -p "lab-x86_64-unknown-linux-gnu.tar.gz" scripts/ci/unraid-plugin-checksums.sh --tag vX.Y.Z --tarball lab-x86_64-unknown-linux-gnu.tar.gz --fix - The
unraid-plugin-checkCI job does not auto-commit a fix; a mismatch fails the run and the fix must be applied locally (--fix) and committed like any other change. - The
versionentity itself has no automated check — it's a plugin-package concern bumped by hand, the same wayincus-unraidhand-bumps its own.plgversion per content change, decoupled from any upstream Incus version.
Required step: tag every commit that touches labby.plg or unraid/source/
srcURL (all thirteen companion-file downloads: labby.cfg, Labby.page,
LabbyDashboard.page, its aggregate status endpoint, rc.labby,
labby-preflight.sh, labby-incus-env.sh, labby-incus-init.sh, the
vendored Incus profile, both event hooks, and both brand PNGs) is pinned to
an immutable tag —
unraid-v&version; — not to main. This is deliberate: every file under
srcURL is MD5-verified against a value baked into whatever version is
cached on flash, and Unraid's classic .plg model re-downloads and
re-verifies every <FILE> on every boot, since /usr/local/emhttp is
tmpfs and gets wiped on reboot. If srcURL pointed at main (as it did
before 1.3.0e), any later commit touching these files — even for a
totally unrelated packaging round — would break every already-installed
copy's next boot with an MD5 mismatch, without version ever having
changed for that install. pluginURL (the manifest URL Unraid's plugin
manager polls to detect updates) deliberately stays on main — that one
must always resolve to the latest content, or update detection would freeze.
After committing any change to labby.plg or unraid/source/:
git tag unraid-v<version> # e.g. unraid-v1.3.0e, matching the version entity
git push origin unraid-v<version>
This has no automated check — the tag legitimately can't exist until after
the commit it points at is pushed, so scripts/ci/unraid-plugin-checksums.sh
cannot verify it in the same CI run. Forgetting this step doesn't break the
commit you just made (fresh installs and installs already on main's
current state at push time still resolve srcURL correctly), but it does
mean the NEXT commit that touches these files will retroactively break any
install that adopted the untagged version — tag every round, not just when
something feels risky.
Community Applications
Two files describe this repo to Community Applications, and CA reads both
from main — neither is covered by the srcRef tag pinning above, because
CA is not the plugin's own boot-time download path:
-
ca_profile.xml(repo root, required there — CA looks for it at the root of a submitted repository, not in a subdirectory) — the repository profile: one-line overview, project page, and the repo icon. Its<Icon>points at the repo-rooticon.svg. -
unraid/ca/labby.xml— the plugin entry. Its<PluginURL>must matchlabby.plg'spluginURLentity byte-for-byte, or the Apps-page install button breaks silently. Both arehttps://github.com/dinglebear-ai/labby/raw/main/unraid/labby.plg."Byte-for-byte" is not pedantry. GitHub serves the identical
.plgfrom bothgithub.com/OWNER/REPO/raw/REF/PATHandraw.githubusercontent.com/OWNER/REPO/REF/PATH, so a mismatch here is invisible to every check that merely fetches the URL — but CA compares the two as strings.labby.xmlused theraw.githubusercontent.comform against the.plg'sgithub.com/.../raw/form right up until1.4.0.scripts/ci/unraid-plugin-checksums.shnow expands the.plg's entity chain (pluginURL→rawURL→repoURL) and string-compares the result, failing theunraid-plugin-checkCI job on any drift. It deliberately has no--fixfor this one: which side is wrong depends on whether the repo moved or the CA entry went stale, and that is a call a human has to make.
The Unraid version floor lives on labby.plg's PLUGIN tag as
min="7.0.0", not as <MinVer> in labby.xml — CA gives the .plg
attribute precedence and ignores the XML element when both exist, and the
attribute additionally covers direct Install-Plugin-URL installs, which CA
metadata never sees. dynamix.plugin.manager/scripts/plugin checks it with
version_compare and aborts with "installed Unraid version is too low,
require at least version 7.0.0".
The number is not arbitrary: the release binary's highest required symbol
version is GLIBC_2.39 (confirmed with objdump -T), and Unraid 7.0.0 is
the first release shipping a new enough glibc — 7.0.0 has 2.40, 7.2.0 has
2.42, 7.3.x has 2.43. Unraid 6.x is below the floor and would otherwise
fail labby-preflight.sh at start time rather than install time.
Revisit this if the release build's glibc floor ever moves.
<Support> in labby.xml is redundant with the support attribute now
set on labby.plg's PLUGIN tag — per the CA starter template, the .plg
attribute wins and the XML element is ignored when both exist. Both are set
to the same issues URL, so they cannot disagree.
Submission itself is a manual, one-time step and has not been done yet:
push the repo, then run Validate and Scan in the Community Apps
submit flow (/submit) per the
starter template.
CA requires an OSI-approved license in the repo. LICENSE (AGPL-3.0-only,
adopted in #346 alongside separate commercial terms) satisfies this, and
labby.xml declares <License>AGPL-3.0-only</License> to match. Keep that
element in sync with Cargo.toml's license field and LICENSING.md — it
is a public claim about how the listing is distributed, and nothing checks
it automatically. Do not advertise the commercial option in that element;
CA's field describes the open-source license only.
Known gaps
- Community Applications submission has not been run — the templates are
in place and valid, but until the
/submitValidate+Scan flow is completed, install via the Plugins tab's "Install Plugin" URL field pointed at the rawlabby.plgURL. RUNTIME_MODE="incus"now has its architecture, image/version pinning, Tailscale behavior, bridge, and egress defaults wired into the plugin package, but the Incus path still depends on a known release-asset CI gap: onlyv1.2.0currently publishes thelabby-incus-*.tar.xzimage asset. Newer Incus images require fixing that CI path first, then bumping bothINCUS_IMAGE_VERSIONandINCUS_IMAGE_SHA256. Tracked aslab-26zqj.RUNTIME_MODE="incus"has not yet been exercised across a real Unraid reboot or a real incus-unraid uninstall/reinstall cycle. The current implementation is designed for array-start/stop and plugin install/update/uninstall of the labby plugin itself; full lifecycle validation is still pending.- Validated end-to-end on real hardware (tower, Unraid 7.3.2) via Unraid's
actual
plugincommand: fresh install (checksum-verified download of every file),rc.labbystart/status/ready/stop,plugin remove labby.plguninstall (state correctly preserved), a real version-bump update cycle (plugin check/plugin update), the settings form's save-and-regenerate path (including injection-resistance testing), and a real Google OAuth login through the web UI (via a Tailscale Serve HTTPS front). Not yet tested: theevent/disks_mountedandevent/unmounting_diskshooks via a real array stop/start (onlyrc.labby start/stophave been invoked directly), and reboot persistence (the RAM-boot rebuild-from-flash-cache behavior has never actually been observed across a real reboot).