Design, architecture, and operations for a greenfield homelab rebuild. Documentation is grouped by topic; use this page as the index.
At a glance
The homelab is organized into six logical environments (VLANs) with different purposes:
Environment
Purpose
Management
Prod
Production workloads
GitOps (Komodo)
Test
Pre-production validation
GitOps (Komodo)
Dev
Development, experimentation, OCP cluster
Mixed (Komodo for Docker, unmanaged for VMs/OCP)
User
Personal workstation
N/A
IoT
Smart home, cameras
Isolated
DMZ
Public-facing services (qBittorrent)
Isolated, restricted
Prod, Test, and Dev (Docker containers) form a promotion path managed by Komodo. Dev also contains unmanaged nodes (VMs, OCP cluster) for experimentation.
Dedicated Debian VM with Quadro P5000 GPU passthrough, running LlamaFarm as the AI model management layer. Disposable β Ansible provisions, persistent storage holds models and data.
GitOps for managed environments β Desired state lives in Git; Komodo reconciles actual state to match
Promotion via file movement β Move/copy TOML files between dev/ β test/ β prod/ directories
Isolation for sandboxes β DevNode and DevOCP have no GitOps integration; they’re for experimentation
Dedicated management plane β Komodo Controller runs on separate hardware (NUC) with no workloads
Subsections of Homelab Infrastructure
Leantime MCP Server β Local Setup
How the Leantime MCP server is set up and running on this machine (sams-macbook-air), for use as an opencode MCP tool.
Overview
Leantime is a self-hosted project management tool running in the homelab. It exposes an MCP endpoint at /mcp. This machine runs the leantime-mcp npm bridge as a stdio-to-HTTP proxy: it speaks the MCP protocol over stdio to the client (opencode), then forwards every tool call as HTTP JSON-RPC to the remote Leantime endpoint using a personal access token.
Lazily initializes the Leantime session on first tool list (MCP initialize + notifications/initialized).
Every client tool call is proxied to the remote /mcp endpoint with the token.
Retries failures with backoff, caches tool responses for 5 minutes.
Leantime server
Self-hosted at http://leantime.samplayskeys.ts.net/mcp, reachable only over Tailscale.
MagicDNS resolves the name to 100.88.126.2; tailscale ping reports it is served from fedora-dev (100.125.91.5). No Tailscale Serve config exists on this Mac β serving happens on the host side.
The /mcp endpoint is auth-gated: unauthenticated requests return 401/406; the root path 302-redirects. Requests must include Authorization: Bearer <token>.
Requires the Leantime MCP Server plugin (paid, marketplace) for the endpoint and Advanced Auth for personal access tokens (token needed for user-specific queries like “get my tasks”).
History / context
Originally explored for OpenClaw in the WIP session wip/ai-leantime-integration/ (see context.md, mcp-path.md, refs/). The bridge ended up wired into opencode on a local machine instead.
Pitfalls / notes
The bridge must run on a machine that can reach both the AI client and the Leantime instance (Tailscale overlay covers this).
Leantime MCP tools are read+write by default β consider tool filters if destructive operations should be restricted.
API keys act as service accounts and cannot answer user-specific queries; prefer a personal access token.
Architecture decision records (homelab)
ADRs capture decisions that are stable enough to reference later, with context and trade-offs. They supplement the narrative Decisions page.
Subsections of Architecture decision records (homelab)
ADR: Tailnet-per-environment (Prod / Test / Dev) with declarative node sharing
Status
Rejected. The homelab will not split Prod/Test/Dev into separate tailnets. Environment separation stays as it is today: a single tailnet with tag-based grants.
The investigation is retained in full because the rejection is the useful part β it documents why an appealing structural-isolation story doesn’t survive contact with an already-working Services deployment.
Superseded in direction by ADR-0007: Workload isolation in a single tailnet β access pools, which keeps the workload/homelab separation inside the tailnet as ACL-defined access pools, with a separate tailnet retained only as a deferred off-ramp. Its core finding β that a tailnet boundary pays off where paths should be closed anyway (non-homelab workload), not between homelab environments β still stands.
Context
Today the homelab runs a single tailnet with tag-based environment separation. Services are already the primary exposure mechanism for containers across Prod/Test/Dev β advertised via Docktail labels and ScaleTail sidecars, with per-service MagicDNS names, per tailscale.md. That part of the design is settled and this ADR does not revisit it.
What is not settled is the environment boundary. Per tailscale-grants.md, isolation between Prod/Test/Dev is enforced entirely by grant rules:
That works, but the isolation is one policy-file edit deep. A bad merge, a mis-tagged node, or a fat-fingered tag:* destination collapses Prod/Test/Dev into one flat network. The blast radius of a policy mistake is the entire homelab.
Two Tailscale capabilities change the calculus:
Additional tailnets via the API.POST /api/v2/organizations/-/tailnets provisions a fully isolated tailnet under the same account, no console clicks. The response carries a tailnet-scoped OAuth client β creation is org-scoped, but administration and deletion require that per-tailnet credential (worked example).
Declarative node sharing (docs, alpha, waitlist-gated). Cross-tailnet access becomes a policy-file construct β externalTailnets plus grants referencing group://<alias>/<name> or tag://<alias>/<name>. It is explicitly designed for machine-to-machine access between trusted tailnets, which is exactly the homelab’s shape.
The question this ADR frames: is a tailnet boundary the right isolation primitive for the homelab’s environments, and is the cross-tailnet seam cheap enough to live with?
What a tailnet split does to the existing Services layer
Because Services are already the exposure model, the split’s real cost lands here rather than on greenfield design:
Service names become tailnet-qualified. Every service currently resolves under one tailnet’s MagicDNS suffix. Three tailnets means three suffixes, and the naming convention documented in tailscale.md needs an environment-aware revision. OPEN: does plex.<prod-tailnet>.ts.net replace the current flat convention, or does an alias layer hide the split?
httpsEnabled is per-tailnet and off by default. Any new tailnet needs the setting turned on before TLS-terminating services work at all β a provisioning step that doesn’t exist today because the current tailnet was configured once, long ago.
Docktail/ScaleTail config becomes tailnet-scoped. Sidecars and label-driven advertisement authenticate to a specific tailnet. Auth keys, tags, and tagOwners all become per-tailnet concerns.
Cross-environment service consumption is the seam. Anything in one environment consuming a service in another (observability, backups, promotion tooling) stops being an in-tailnet lookup and becomes an externalTailnets grant.
OPEN and worth answering early: whether a Service can be published across a tailnet boundary. If yes, the boundary contract is service-level and clean. If not, cross-environment access is node-and-port level, which is coarser than the exposure model already in use β a genuine regression against current practice, and arguably the strongest argument for staying on one tailnet.
Correction to an earlier framing
An earlier draft of this ADR analyzed the seam using link-based sharing (sharing docs) and concluded the model was largely unworkable β tags stripped, shares user-scoped so tagged machines can’t accept them, quarantine by default. Those constraints are real but belong to link-based sharing, not declarative node sharing. Declarative sharing operates at the policy level and supports tag references and machine-to-machine access directly. The two mechanisms coexist; picking the wrong one produces the wrong conclusion, which is why they’re separated explicitly below.
DNS suffixes, sidecar auth, per-tailnet httpsEnabled
Partner / shared access model
Medium
Adversarial model already documented
Cost
LowβMedium
OPEN: plan implications of additional tailnets
The two sharing mechanisms
Getting this distinction right is most of the decision.
Link-based sharing
Declarative node sharing
Status
GA (beta flag in docs), v1.4+
Alpha, waitlist-gated
Unit of sharing
One machine β one user
Policy-level, resource classes
Tags across the boundary
Stripped
Supported via tag://<alias>/<tag>
Machine-to-machine
Not supported β only users accept shares
Explicitly supported
Quarantine
Inbound-only by default
Governed by allowIncomingConnections; OPEN whether quarantine semantics still apply
Setup
Invite link, out-of-band, manual accept
Double opt-in policy edits on both tailnets
Automatable
Poorly
Fully β policy file is GET/POST /api/v2/tailnet/-/acl
Group sync (SCIM/Google)
n/a
Unsupported β can’t reference synced groups
Consequence: declarative sharing is the mechanism this design should be built on. Link-based sharing remains the right tool only for the partner/guest case β handing one machine to one outside human.
A grant referencing something absent from the other side’s allowExternalReferencesTo is silently ignored β no error, just no connectivity. That failure mode needs a verification step in any runbook.
Options
Retained as the investigation record. Outcome noted on each.
Option A β Status quo: one tailnet, tag-based separation β CHOSEN
Pros: zero migration; flat MagicDNS; one policy file; single console; no alpha dependency; Services and access model untouched.
Cons: isolation is advisory, not structural; one bad grant flattens all environments; no per-environment audit boundary.
Option B β Three tailnets (Prod / Test / Dev), declarative sharing between them β rejected
Pros: isolation is structural β a Dev policy mistake cannot reach Prod; per-tailnet audit logs, auth keys, and tagOwners; Dev becomes genuinely disposable; whole lifecycle is API-drivable (create, httpsEnabled, policy push, scoped auth keys); tags and m2m survive the boundary; double opt-in means neither side can unilaterally widen access.
Cons:depends on an alpha, waitlist-gated feature; three policy files and three scoped OAuth credentials to manage; FQDNs become tailnet-qualified; silent-ignore failure mode on mismatched references; per-tailnet settings (notably HTTPS certs, off by default) must be provisioned explicitly; rewrites a working Services exposure surface.
Option C β Two tailnets: Prod isolated, Test+Dev together β rejected
Pros: captures the blast-radius win that matters at two-thirds the overhead; TestβDev promotion stays in-tailnet.
Cons: Test loses a hard boundary from Dev, so “does Test mirror Prod?” gets fuzzier; still carries the full alpha dependency for one seam β paying the same risk for less of the benefit.
Option D β One tailnet, hardened β CARRIED FORWARD (with A)
Policy-file CI with ACL tests in GitHub Actions, mandatory review on the policy file, ban tag:* destinations, and tighten per-service grants on the Services already in place.
Pros: near-zero migration; no alpha dependency; converts soft isolation into tested isolation.
Cons: still one flat trust domain; a mistake that passes tests still lands everywhere; audit boundary unchanged.
Decision
Rejected β keep the current single-tailnet structure with tag-based environment separation.
The structural-isolation argument is real, and declarative node sharing does make tailnet-per-environment technically achievable in a way link-based sharing never did. It is still the wrong trade for this homelab. Four reasons, in order of weight:
1. It breaks access that currently works, to solve a problem that hasn’t bitten
Services are already the exposure mechanism for most containers, and access works today β admin tiers, partner access, per-service MagicDNS names. A tailnet split rewrites that working surface: tailnet-qualified DNS suffixes, tailnet-scoped Docktail/ScaleTail sidecar auth, per-tailnet httpsEnabled, and per-tailnet auth keys and tagOwners. The failure it prevents β a fat-fingered grant flattening environments β has not actually occurred. Trading working access for hypothetical blast-radius reduction is the wrong direction.
2. Cross-boundary sharing is coarser than what’s already in place
The unresolved question from the investigation is decisive: if a Service cannot be published across a tailnet boundary, then cross-environment access degrades to node-and-port grants. That is coarser than the per-service exposure model already running. And if the shareable app has to physically live in the tailnet it’s shared from, containers move off the Prod Docker host β which drags in Komodo stack placement and the promotion path from ADR-0001. Either answer is worse than the status quo for a boundary nobody asked for.
3. The complexity is not proportionate to a single-operator homelab
Three tailnets means three policy files, three consoles, three sets of tailnet-scoped OAuth credentials, three auth-key rotations, and per-tailnet provisioning steps that don’t exist today. Add the silent-ignore failure mode β a grant referencing something absent from the other side’s allowExternalReferencesTo produces no error, just no connectivity β and routine debugging gets materially harder. Isolation that is expensive to operate gets worked around, and a worked-around boundary is worse than an honest tag.
4. The alpha dependency lands on the wrong path
Declarative node sharing is alpha and waitlist-gated. Under this ADR it would sit on the path between the Komodo controller and Prod periphery agents β meaning homelab manageability depends on an alpha feature. That inverts the risk this ADR set out to reduce.
What is kept
Environment separation stays tag-based (tag:prod / tag:test / tag:dev) in one tailnet. No grants rewrite, no tag taxonomy change, no Services churn.
Partner access stays link-based sharing, per the adversarial model in tailscale.md. Outside humans are not modeled as external tailnets.
Policy-file CI with ACL tests is still worth doing (the former Option D) and is the only recommendation carried forward from this ADR. It converts advisory isolation into tested isolation at near-zero cost, and it is independently useful.
Future option: collapse three environments to two
Noted, not decided: the Prod/Test/Dev split could later collapse to Test and Prod only. Under ADR-0007, Dev’s remaining purpose is being reconsidered β if non-homelab work moves to a workload environment, a third homelab environment may not be earning its keep. That is a separate decision and deliberately out of scope here.
Where the tailnet boundary does belong
The investigation’s real finding is that the boundary axis was wrong. Splitting environments imposes a boundary across paths that need to stay open (admin, Komodo, observability, backups). Splitting workload from homelab puts the boundary where the paths should be closed anyway. See ADR-0007.
Cross-environment flows that made the split expensive
Each of these works today only because everything is one tailnet. Each would have needed an explicit externalTailnets/grant design β collectively, this list is the concrete form of reason #1 above:
Komodo controller (NUC, tag:admin) β Prod/Test/Dev periphery agents β the canonical m2m case; technically workable via tag:// references, but it puts homelab manageability behind an alpha feature
Promotion path DevDocker β Test β Prod (per ADR-0001, promotion is a file move; the network path is currently implicit and would have to become explicit)
Observability scrape/push paths across environments β direction matters against allowIncomingConnections
Backup flows to tag:storage NAS (UnRaid = Prod, Synology = Test)
Admin access from the operator’s devices to all three β one identity in three tailnets, or a device per tailnet, neither cheap
Exit node β one per tailnet, or one shared
Partner/guest access β stays on link-based sharing regardless
ProxMox host, which hosts the Dev VM but is itself infrastructure
Trade-offs that would have been accepted (and were judged too costly)
Alpha-feature dependency on the critical path for cross-environment automation.
Tailnet-scoped OAuth credentials must be captured at creation β the org token cannot administer or delete a tailnet it created.
Cross-environment automation gets deliberately harder. Intended for environments; unhelpful for admin paths.
Consequences / follow-on work
Because the split is rejected, most of the original follow-on work is moot. What remains:
Add policy-file CI with ACL tests. The one carried-forward recommendation. Ban tag:* destinations, require review on the policy file, run tests in GitHub Actions.
No changes to tailscale-grants.md or tailscale.md from this ADR β the tag taxonomy and Services naming convention stand as documented.
Record the rejection so the option isn’t re-litigated from scratch; the mechanism comparison above is the reusable part.
Continue in ADR-0007 for the workload boundary, where the provisioning artifact and waitlist question actually apply.
Open questions β resolved or relocated
The rejection closes most of these. Recorded so the option isn’t re-litigated from scratch:
Plan/cost implications of additional tailnets β moot here; relocated to ADR-0007.
Waitlist timing for declarative node sharing β no longer gating this decision; still worth joining for ADR-0007.
Quarantine semantics vs. allowIncomingConnections for declaratively shared nodes β unresolved; relocated to ADR-0007, where it actually matters.
Operator identity/devices across three tailnets β moot; admin access stays in one tailnet, which is the point of the rejection.
Komodo topology across a boundary β moot; controller and periphery agents stay in one tailnet.
Can a Service be published across a tailnet boundary? β unresolved and now load-bearing for ADR-0007. Answering it decides whether shareable apps can stay on their current host or must physically move.
Does Test lose meaning as a Prod rehearsal under Option C? β moot; Option C rejected. Related but distinct question of collapsing to Test+Prod is noted as a future option above.
Migration order β moot; no migration.
Synced groups (SCIM/Google) can’t be referenced across tailnets β relocated to ADR-0007; only relevant where a boundary exists.
References
Primary sources
Declarative node sharing β the mechanism this ADR is built on. Alpha, waitlist-gated. externalTailnets, double opt-in, tag:///group:// external reference syntax, allowExternalReferencesTo, allowIncomingConnections. Also the source for the SCIM/Google synced-group limitation.
summer-with-tailscale β worked, runnable examples for the API-driven path. Modules used here:
01-tailnet-sandboxes β OAuth token exchange, POST /organizations/-/tailnets, and the tailnet-scoped OAuth credential that the org token cannot substitute for on delete
02-tailnet-membership β tsnet embedded membership; relevant if services join tailnets directly rather than via host daemons
03-declarative-sharing β externalTailnets config walkthrough and scoping external access by group/tag instead of blanket dst: ["*"]
04-api-is-the-way β policy file as data via GET/POST /tailnet/-/acl, the httpsEnabled PATCH, scoped/tagged/short-lived auth keys, plus end-to-end.sh
Supporting
Share your machines with other users β link-based sharing. Source of the tag-stripping, quarantine, and user-scoped constraints; retained here for the partner/guest case and to keep the two mechanisms distinguishable.
Tailscale Services GA β cited for the GA-era additions relevant to a split (declarative on-node JSON config, per-service audit logs, ACL tests), not as an introduction to Services, which the homelab already runs
ADR: Workload isolation in a single tailnet β access pools
Status
Proposed. Direction settled: workload/dev traffic stays in the current tailnet, separated by ACLs organized as access pools. The separate-tailnet option is deferred to a named trigger, not discarded.
Extends ADR-0006, which rejected splitting Prod/Test/Dev into separate tailnets. That investigation’s useful finding was that the boundary axis was wrong, not the boundary idea. This ADR takes the next step of that finding: separation of workload from homelab is real, but the mechanism is pools within one boundary, with structural separation preserved as an off-ramp.
Context
Two distinct things are currently conflated inside one tailnet:
The homelab β Prod and Test infrastructure, private applications, the Komodo GitOps pipeline, storage, admin interfaces. Everything here is either operator-only or shared with the partner as a trusted user.
Dev β nominally a homelab environment, but in practice where non-homelab work happens. It has no promotion relationship worth preserving with Test/Prod for that class of work.
The earlier draft of this ADR weighed two mechanisms: a separate tailnet for workload (structural isolation, independent policy, clean audit boundary) against staying in the shared tailnet with tag separation (zero migration, isolation only advisory). The pivotal consideration β that a boundary’s alpha dependency sits on a path that is allowed to degrade, unlike ADR-0006’s admin/Komodo paths β is retained.
The judgment has since shifted on the trigger. The conditions that would justify a boundary β outside resources managing elements, sustained workload or external-party growth β are not real today. Building the structural split now pays complexity for a hypothetical, and it puts reachability that does not yet need to exist on an alpha, waitlist-gated feature (declarative node sharing). The boundary is kept as the off-ramp, priced and designed, but not built.
Decision
Single tailnet, workload separated by access pools. The structural split is deferred, not discarded.
1. One tailnet for now
Workload/Dev elements stay in the current tailnet, protected by ACLs rather than a boundary. Nothing new to provision, no per-tailnet settings (httpsEnabled, tagOwners, auth keys), and the Services/Docktail/ScaleTail exposure layer works exactly as today. External sharing, when it happens, stays link-based β one machine handed to one invited user β which covers ad-hoc guest access without committing to a separate tailnet.
2. Scope framing shifts from “homelab first” to “environment with pools”
The tailnet is no longer scoped as the homelab with everything else grafted on. It is the environment housing purpose-defined pools β provided services as one pool, development/workloads as another. The pool set is open: additional pools are named when a need clears the bar; none are pre-declared here (see Open Questions). This reframing drives everything that follows: nodes are organized by purpose AND access, and the ACL question becomes “which pools can which users and services reach.”
3. ACLs are pool-based, not environment-tag-based
The access model follows the pools: grants are written in terms of who may reach which pool, in which direction. Two concept principles, without enumerating the pools themselves:
One pool per node. A node belongs to exactly one pool. Cross-pool reach is an explicit, named grant β never a second membership. This keeps the ACL table non-ambiguous: a node’s pool is its identity, and exceptions are visible rules rather than overlapping tags.
Directional by default. Workload β provided services is the natural direction and the common grant (dev needs to reach the things the environment provides). Services β workload is a named exception, not part of the default posture. Reverse-direction access is the thing that erodes the separation, so it must always be a visible rule.
The pool taxonomy itself β names, membership, concrete grants β is deliberately unspecified in this ADR. It is a follow-on design task (see Consequences).
4. Separate tailnet = deferred off-ramp
If the environment grows and outside resources need to manage elements, the workload moves to a new set of devices in a separate tailnet β the homelab tailnet untouched. The earlier draft’s separate-tailnet analysis is retained as the off-ramp design: structural isolation, independent policy file, clean audit boundary, external-group sharing without exposing private applications. Its alpha dependency (declarative node sharing) is acceptable once the boundary is real, because it then sits on a workload path that is allowed to degrade β the same risk posture, just correctly timed.
The pool taxonomy chosen now is the seam that cut will follow: pools map cleanly to future tailnet membership, and a split becomes a placement/policy exercise rather than a redesign. Policy-file CI should be in place first β a split that is mostly policy movement is only cheap if the policy is already tested.
Why this succeeds where ADR-0006 failed
Concern
ADR-0006 (environment split)
This ADR (pools)
Admin/Komodo paths
Cut by the boundary β homelab manageability behind an alpha feature
Untouched; all stay in the homelab tailnet
Existing Services layer
Rewritten across three tailnets
Untouched
Partner/guest access
Complicated by tailnet-qualified DNS
Link-based sharing, unchanged from today
Alpha dependency
On the critical management path
On the deferred off-ramp only
Migration cost
Three tailnets, full grants rewrite
Zero now; the split is a future, triggered exercise
Blast radius
Hypothetical (bad grant)
Contained within pool grants β and CI-testable
Future option: separate tailnet (deferred, priced)
Noted, not built. When the trigger fires β outside resources managing elements, or sustained external-party growth β execute the off-ramp: new device set in a separate tailnet, provisioned via the API chain (OAuth exchange β create tailnet β httpsEnabled β tagOwners/policy β scoped auth keys), homelab tailnet untouched, cross-boundary access via declarative node sharing. Joining the waitlist for declarative sharing is only needed when the trigger becomes real; doing it now starts a clock for a feature nothing active depends on.
Open questions
Pool taxonomy β deliberately unspecified here. The concrete pools, names, membership, and grants are the follow-on design task. Implicit constraint from this ADR: no “miscellaneous” pool; a catch-all pool is how pools rot into a flat network.
Does the partner need any workload access? Default assumption: no β workload pools are operator-only. Link-based sharing remains available per-need; the answer affects whether the future split needs external-group modeling at all.
Exit node β does workload need its own, or use none?
Split trigger threshold β what concretely constitutes “outside resources managing elements”? Naming the threshold now makes the off-ramp decision unemotional later.
Quarantine semantics / synced groups β relocated to the off-ramp; only apply once a boundary exists.
Consequences / follow-on work
Design the pool taxonomy and draft the HuJSON policy β tracked TODO, not done here. A policy skeleton with the pool structure and directional grants, reviewed against the principles above.
Policy-file CI with ACL tests β now more load-bearing than under either prior framing: pools keep isolation advisory, and CI is what converts it into tested isolation. Ban tag:* destinations, require review on the policy file, map pool grants to tests, run in GitHub Actions.
Update tailscale-grants.md once the pool taxonomy is concrete; convert the environment-tag framing to pool framing.
Update tailscale.md to document workload pools, link-based sharing for guests, and the deferred-split framing.
Do not join the declarative-sharing waitlist yet β retracted from the earlier draft; only needed once the off-ramp trigger is real.
Rename Dev β Workload in homelab docs, and be explicit that it is outside the promotion chain, so it isn’t mistaken for a pipeline stage.
Revisit ADR-0001: if Dev leaves the promotion chain, the per-environment ResourceSync branch layout has one fewer environment to steer.
References
ADR-0006: Tailnet-per-environment β the rejected environment split; contains the link-based vs. declarative sharing mechanism comparison and the API provisioning details
Declarative node sharing β alpha, waitlist-gated; the off-ramp’s cross-boundary mechanism, not active today
ADR: Orchestration Model β Docker-native vs Kubernetes/ArgoCD for the Homelab
Context
The homelab currently standardizes on Docker (rootful, on Fedora Server baremetal hosts) managed through Komodo for GitOps. The prior narrative decision (“Komodo over Kubernetes (K3s + ArgoCD)” in planning/decisions.md) chose Docker-native Komodo over a K3s + ArgoCD stack on the grounds of complexity, risk tolerance, and concept alignment with existing familiarity with compose files.
This ADR reopens that axis. Two things have shifted the picture:
Declarative lifecycle is now a hard requirement, not a nice-to-have. The goal is to easily and declaratively spin up / tear down / manage resources across environments. That is exactly what native GitOps (ArgoCD reconciling manifests) does out of the box β and what Komodo does with more manual steering (ResourceSync branch gymnastics, per-environment TOML edits on merge, see ADR 0001).
ArgoCD is the more common / more documented route than Komodo. If the operational surface is “declare desired state, let a controller reconcile,” ArgoCD is the industry-default implementation. Komodo gets us most of the way but is a smaller ecosystem with rougher edges (Podman compat, stack-status quirks).
So the earlier “Kubernetes is overkill for a homelab” conclusion is being stress-tested against a simpler question: is the thing we’re optimizing for simplicity of the runtime or simplicity of declarative management? Those pull in different directions.
The tension
Axis
Docker + Komodo
Kubernetes + ArgoCD
Runtime complexity
Low; Compose, one socket, familiar
High; control plane, CRDs, more failure modes
Declarative spin up/down
Possible, but needs branch/merge hygiene (ADR 0001)
Similar; kubectl apply / Git commit, controller reconciles, but follows a similar git architecture
The salient counterpoint to the original decision: Docker is less complex to run, but it can mean more steps to manage compared to native GitOps. The “overkill” argument was about runtime footprint; it does not directly address declarative management ergonomics, which is now the priority.
Options considered
Option A: All Docker-native (Komodo)
Standardize everything on Docker + Komodo ResourceSync.
Pros: lowest runtime complexity; builds on existing Compose investment; Dev OCP cluster still provides separate K8s skills.
Cons: declarative management is real but requires the merge-hygiene rituals from ADR 0001; smaller ecosystem; no first-class “one command spins up the whole graph.”
Option B: All Kubernetes (K3s/K8s + ArgoCD)
Replace Komodo with a K3s or K8s control plane and ArgoCD as the GitOps controller for every workload.
Pros: single, ubiquitous declarative model; ArgoCD is the common, well-documented path; spin up/down is a Git commit; one pane of glass for everything.
Cons: real operational complexity (control plane, CRDs, more moving parts); the original “overkill for homelab” risk is still valid; more failure modes to own 24/7.
Option C: Split model (leading direction, not yet decided)
Use Docker for “prod apps” and Kubernetes/ArgoCD for “workload apps.”
Keeps the low-friction Docker path for stable, user-facing services (Plex, etc.) where runtime simplicity matters most.
Puts the heavier / more dynamic / more experiment-driven workloads on K8s/ArgoCD where declarative reconciliation pays off and aligns with work skills.
The only clear advantage of “all Kubernetes” (Option B) over this split is the single pane of glass β ArgoCD as the one view of everything. That is a real UX win, but it is currently the only decisive argument for unifying onto K8s, and it may not outweigh the standing complexity cost.
Decision
No conclusion yet, this ADR is intentionally left open.
The leading direction is Option C (split model), driven by the desire for easy declarative management without forcing the entire homelab onto a K8s control plane it may not need. The single strongest argument for abandoning the split and going all-in on Kubernetes (Option B) is the unified ArgoCD dashboard; that has not yet been judged worth the operational cost.
What would move this from Draft to a decision:
A concrete definition of which apps count as “prod apps” (Docker) vs “workload apps” (K8s); the boundary is currently intuitive, not enumerated.
A validated K3s + ArgoCD bring-up path on the homelab hardware (the original decision flagged K3s-in-prod as risky; that risk needs a fresh look).
A judgment on whether “single pane of glass” is worth the control-plane burden, or whether two dashboards (Komodo + ArgoCD) is acceptable.
Consequences (potential)
If Option C is adopted:
Two management planes to operate (Komodo for Docker, ArgoCD for K8s) β more tooling surface, but each stays within its comfort zone.
Prod apps keep the low-ceremony Docker/Compose lifecycle.
Workload apps get first-class declarative reconciliation and stronger skills transfer to the work OCP stack.
Loses the “one ArgoCD view of everything” that Option B would provide.
If Option B is adopted instead:
Single declarative model and dashboard for the whole homelab.
Pays the standing K3s operational cost the original decision sought to avoid.
Supersedes the “Komodo over Kubernetes” narrative in decisions.md β that section would need revisiting.
Open Questions
Where exactly is the “prod app” vs “workload app” line drawn?
Does “prod app” vs “workload app” need to be an treated as alternative approach to my originally declared “Prod/Test/Dev” environments from ADR 0001?
Idea: If this is the case, it would call into question the order of evolution in regards to hardware, with “Prod” still running on a dedicated device, having “Test” running within the ProxMox cluster, and having “Workload/Dev” on a larger version of the OCP cluster.
Is the K3s-in-prod risk from the original decision still valid, or has the operational picture changed?
If my personal work is focused largely on kubernetes, will it be better to have everything involved with kubernetes, or will having “prod apps” in docker (option C) encourage me to not mess with prod apps and focus my time on just the kubernetes side of the field?
Is there a “single pane of glass” option that can manage both docker and kubernetes, or at least have visibility on both?
Does losing a single pane of glass (under the split) actually hurt day-to-day, or is it tolerable?
Does this reopen the runtime question (ADR 0003 / 0002) for the K8s-hosted workloads, or do they stay rootful Docker-in-VM / containerd as K8s dictates?
ADR: Skip local Docker registry, focus on git pipeline
Context
The homelab needs a strategy for managing container images between the local environment and production. Initial considerations included running a local container registry (Artifactory/Lightwell analog) or relying on Docker Hub with version pinning. The homelab already uses Komodo GitOps, GitHub Actions for rendering, and Ansible validation playbooks.
Goals:
Avoid infrastructure overhead β A local registry adds a running service, storage management, and maintenance burden for a feature (offline access) that isn’t actually required.
Leverage existing pipeline investment β GitHub Actions, Ansible validation, and Komodo ResourceSync are already in place and can be extended to cover image governance.
Security evaluation through PR process β Image scanning and review can happen in Git before anything gets deployed, rather than after images sit in a registry.
Version pinning is already the pattern β Komodo configs and Git branches already provide effective version control; a registry would be redundant.
Decision
Skip running a local Docker registry as a stop-gap. Instead:
Pin images explicitly in Komodo TOML configs (e.g., nginx:1.25-alpine instead of latest) β this is the primary guardrail.
Extend the existing Git pipeline to include:
Image scan on PR (Trivy or Anchore)
Push approved images to Docker Hub / existing registry with pinned tags
Komodo picks up the new commit SHA / tag on sync
Use Docker Hub rate-limit-friendly practices β pull once, cache locally via Docker’s built-in store, and pin versions.
Revisit local registry later only if offline access or rate limits become actual problems.
Considered alternatives
Approach
Security Evaluation
Version Pinning
Offline
Maintenance
Local Docker registry
Manual (ad-hoc scanning)
β Config-based
β Yes
Low-Medium
Docker Hub + pinning
Manual (ad-hoc scanning)
β Config-based
β No
Low
Git pipeline + image scanning
β Automated (PR gate)
β PR-approved tag
β CI cache
Medium
Artifactory/Lightwell
β Scanning + promotion
β Pinning
β Yes
High
Consequences
Positive
No extra running service or storage to manage
Security gates move left β images are scanned and reviewed in PR before any deployment
Existing Git/GitHub Actions investment leveraged; no new infra needed
Version pinning already the pattern via Komodo configs; registry would be redundant
Simpler onboarding for new services β just add to pipeline, no registry config needed
Negative / risks
Image scanning depends on CI runner availability and speed
If Docker Hub rate limits become problematic, no local cache (but this hasn’t been an issue in practice)
Security evaluation relies on PR review quality β same as any code change
Mitigations
Add Trivy scan to existing GitHub Action as a checklist step β fail PR if critical vulnerabilities found
Cache scanned results in GitHub Actions artifact so subsequent scans are fast
If rate limits bite, add a registry later as a targeted fix (not infrastructure upfront)
Document the “pinning is the guardrail” principle so teams don’t revert to latest
Status
Accepted β proceed with extending the Git pipeline for image governance instead of running a local registry.
The homelab needs a host OS for baremetal Docker nodes (LenovoMini 1 for Prod, LenovoMini 2 for Test, and the DevDocker VM on ProxMox). Requirements:
RPM/DNF-based β user preference, already familiar with the ecosystem.
Komodo GitOps compatibility β Periphery agent must be able to manage container workloads.
Low-maintenance β set once and forget; not something to constantly troubleshoot.
Easily automatable β host provisioning should be reproducible via Ansible, including Periphery agent setup.
Options Considered
Host OS
Option
Release Cadence
Support Window
Notes
Fedora Server
~6mo
~13mo
Latest kernel + tooling, frequent upgrades
Rocky / Alma Linux
Major (9.x)
~10yr
RHEL-compatible, boring & stable
Fedora CoreOS
Auto-updating streams
Per stream
Immutable, no package management at runtime
CentOS Stream
Rolling between RHEL releases
Continuous
Between Fedora and RHEL
Container Runtime
Factor
Docker
Podman
Komodo Periphery support
First-class (native socket or API)
Rough β community workarounds only (DOCKER_HOST to socket emulation)
Rootless
Available (opt-in via dockerd-rootless-setuptool.sh)
Built-in, rootless by default
Compose
Docker Compose v2
podman-compose or podman play kube
Research Findings
Podman + Komodo Compatibility
After investigation, Podman support in Komodo relies on workarounds:
Requires DOCKER_HOST env pointing at Podman’s Docker API socket (/run/user/$UID/podman/podman.sock)
Stacks may incorrectly show as “DOWN” after successful deploy due to compose network label differences (GitHub issue #504)
No first-class testing or documentation from Komodo maintainers
Conclusion: Support is too rough for a low-maintenance setup
Rootful vs Rootless Decision
Rootless Docker was initially preferred for security, but three factors drove the switch to rootful:
No practical benefit for a homelab β all workloads are user-deployed and trusted. The socket is already protected by host-level access controls (SSH, firewall, physical security).
Avoids community-driven maintenance burden β rootless Docker with Komodo Periphery relies on community patterns not official docs. Rootful is the documented, tested path.
Simplifies Ansible automation β rootful Docker is a single dnf install + daemon enable. No dockerd-rootless-setuptool.sh, no loginctl enable-linger, no environment variable wiring.
Host OS Alignment
Fedora Server ships recent kernel + DNF, packages are fresh enough for both the Docker CE repo and Ansible management
Rocky/Alma would work but are conservative; no advantage for this use case
Fedora CoreOS is immutable β no package install at runtime, conflicts with Ansible-based provisioning
Decision (Provisional)
Fedora Server for the host OS β DNF-native, recent kernel, familiar tooling, easy Ansible provisioning.
Docker (not Podman) for the container runtime β Komodo’s first-class support removes any reason to fight Podman compatibility.
Rootful Docker β simplicity wins for a homelab with trusted workloads. No port restrictions, no privileged container limitations, no extra setup steps.
Containerized Periphery β deployed as a Docker container managed by Ansible, with /var/run/docker.sock mounted for API access. Updates via image pull, not manual binary replacement.
Implementation: Containerized Periphery via Ansible
Periphery runs as a Docker container, managed by the same Ansible playbook that provisions the host. The Komodo maintainer’s filesystem semantics concern (discussion #220) applies to rootless Docker where user namespaces cause path remapping β with rootful Docker, container and host paths are the same, so the concern doesn’t apply.
Ansible Role Structure
The host provisioning playbook will include a role komodo-periphery that handles Periphery as a container:
network_mode: host β Periphery needs to reach containers by their host-level ports for monitoring. Host networking avoids port mapping conflicts and simplifies the network topology.
/var/run/docker.sock mount β gives Periphery the standard Docker API access. With rootful Docker this is the native socket, no indirection needed.
/:/mnt/host:ro β bind mounts the host root so Periphery can read filesystem stats for disk monitoring.
restart_policy: unless-stopped β survives reboots and daemon restarts.
The config template is rendered by Ansible with host-specific variables (core address, hostname, onboarding key), sourced from Ansible vault or group vars.
SELinux may block container socket access. On Fedora Server with SELinux enforcing, the Periphery container needs the :z flag on volume mounts or a dedicated SELinux policy. If Periphery fails to connect, check ausearch -m avc and add :z to the socket mount: "/var/run/docker.sock:/var/run/docker.sock:z".
Host networking limits to one Periphery per host. With network_mode: host, port 8120 is consumed directly on the host. Only one Periphery instance can run per machine. This is the expected topology β one agent per host.
Periphery container image tag strategy. Use a specific version tag (e.g., :0.2.5) not :latest for reproducible deployments. Update by bumping the version in Ansible vars and re-running the playbook.
Onboarding key rotates. The onboarding_key in [[connections]] is a one-time use key from the Komodo Core UI. After first connection, Periphery persists its session β the key is no longer needed. If re-deploying from scratch, generate a new key.
Config changes require container restart. If periphery.config.toml changes, Ansible must trigger a container restart. The docker_container module handles this automatically when config changes are detected via template diff.
Ansible vault for secrets. The onboarding key is sensitive and should be stored in Ansible vault, not in plaintext group vars.
Consequences
Positive:
Familiar RPM/DNF management
Komodo Periphery has native, first-class support for rootful Docker
All workloads work without restriction β privileged containers, ports <1024, cgroup limits
Containerized Periphery auto-updates via docker pull; Ansible handles version pinning
Ansible-driven provisioning means new hosts are reproducible
No special setup beyond standard Docker CE install
Negative / risks:
Docker socket is root-equivalent β compromised container has host access. Mitigated by homelab trust model (all workloads are user-deployed).
Containerized Periphery adds a Docker-in-Docker layer that the Komodo maintainer flags for potential filesystem confusion, but with rootful Docker (no user namespace) this is not a practical concern.
Fedora ~13mo support window means OS upgrades every ~6-12mo (acceptable for homelab).
Periphery config changes need container restart β automated via Ansible.
Open Questions
Which Periphery image tag to pin initially? Start with :latest during bring-up, pin to a specific version once stable.
How to handle Periphery version upgrades? Ansible var bump + playbook re-run. Need a process for checking upstream releases.
References
ADR 0003: Container Runtime Tradeoffs β deeper analysis of Docker rootful, rootless, and Podman comparison
FoxxMD β Migrating to Komodo (rootless Periphery via socket-proxy, systemd agent recommendation)
Selecting a container runtime for baremetal Docker hosts involves balancing security, operational convenience, and tooling compatibility. This ADR breaks down three approaches and the fundamental tension between central-socket management and daemonless isolation.
Native docker compose v2 β works within user namespace
Podman (Daemonless)
No central daemon. Each container is launched directly by the user’s Podman client.
Aspect
Detail
Socket
None by default β containers are child processes of the user’s shell
Daemon
No daemon β podman system service can emulate a Docker API socket on demand
Root model
Rootless by default β no user namespace needed, containers run as the calling user
Komodo support
Workarounds only β DOCKER_HOST to emulated socket, known bugs (stack status, network labels)
Setup complexity
Minimal for CLI β dnf install podman. Complex for management tooling (need podman system service as a systemd unit)
Compose
podman-compose or podman play kube β not drop-in compatible
Analysis
Security: The Central Socket Risk
A Docker socket is equivalent to root access on the host. Any process that can write to the socket can:
Start containers with host filesystem mounts (read/write any file)
Create privileged containers that break out of isolation
Modify iptables, network namespaces, cgroups
Access secrets mounted into any container
Rootful Docker places this power on a single, well-known path (/var/run/docker.sock). The docker group is effectively root. The blast radius of a compromised process with socket access is the entire host.
Rootless Docker mitigates this through user namespaces. The daemon and all containers run within the user’s namespace β root inside a container is the user outside. A compromised socket can’t escape the user namespace, so the blast radius is that user’s resources only. However, it’s still a central daemon β if the rootless dockerd process is exploited, all that user’s containers are compromised.
Podman eliminates the central socket entirely. No process management attachment, no daemon to target. Each container is a child of its launching process. The blast radius is per container β no socket to poison. To use management tooling, you must explicitly start a podman system service socket, which reintroduces a central endpoint β but it’s opt-in, not the default.
Management: Daemonless vs. Central Socket
This is the inverse of the security tradeoff:
Runtime
Management model
Tooling friction
Docker (rootful)
One socket, one daemon, everything works
Zero β every tool speaks Docker API natively
Docker (rootless)
One socket per user, one daemon per user
Low β set DOCKER_HOST, same API
Podman
No socket, no daemon, containers are child processes
Higher β no standard API without emulation layer
Without a central socket, management tooling faces a discovery problem:
How does Komodo’s Periphery find the container runtime? It can’t β it needs an explicit path to podman system service socket, which must be running as a systemd user service.
How does docker ps / podman ps enumerate running containers across a session? Podman uses a per-user boltdb database, but there’s no persistent daemon registering container state.
How do compose stacks get tracked? Podman’s compose support (podman-compose) uses a different project model than Docker Compose β Komodo’s stack status detection breaks (issue #504).
With a central socket, management is trivial: connect to the well-known path, issue standard API calls, get predictable results. The cost is the security risk described above.
Operational Considerations
Criterion
Docker (rootful)
Docker (rootless)
Podman (daemonless)
Komodo compatibility
Native
Configurable
Workarounds
Socket security
Dangerous (root equiv.)
Safer (user namespaced)
None by default
Privileged containers
Full support
Blocked
Blocked (rootless)
Port <1024
Native
Workaround needed
Workaround needed
Systemd integration
Manual
Manual
Native (podman generate systemd)
cgroup management
Full
Limited
Limited (rootless)
Builds
Native
Native (BuildKit)
Native (Buildah)
Swarm mode
Supported
Blocked
N/A
Docker Compose
Native
Native
Partial (podman-compose)
Multi-arch builds
Native
Native
Native (Buildah)
Recommended Direction
This ADR does not make a standalone decision β it informs ADR 0002. The current direction:
Docker (rootful) for maximum simplicity and tooling compatibility. The socket-security risk is accepted as an acceptable tradeoff for a homelab with trusted workloads.
Podman is the more secure architecture by design, and rootless Docker is a middle ground. Rootful Docker optimizes for operational simplicity β no setup ceremony, no port restrictions, no privileged container limitations. For a homelab where all workloads are user-deployed and trusted, the central-socket risk is manageable.
If the homelab expands to include untrusted or multi-tenant workloads, this should be revisited β rootless Docker or Podman would contain blast radius in a way rootful Docker cannot.
Consequences
If Docker (rootful) is adopted (current direction):
All management tooling works natively with zero configuration β standard Docker API on the well-known socket
Setup is minimal β dnf install docker, enable daemon, run containers
No workload restrictions β privileged containers, ports <1024, cgroup limits all work
Socket is root-equivalent β any process with socket access owns the host. Acceptable for homelab trusted workloads.
If Docker (rootless) or Podman is reconsidered later:
Stronger isolation β compromise is contained to a user namespace or individual container
Some workloads (privileged containers, Swarm mode) are restricted
ADR: Per-environment Git branches via Komodo ResourceSync
Context
The homelab uses Komodo with ResourceSync to declare servers, stacks, deployments, and related resources from Git (an βapp-of-appsβ style flow: Git remains the audit trail; Komodo reconciles).
Goals:
main shows the whole picture β dev/, test/, and prod/ (or equivalent komodo/{dev,test,prod}/) layouts exist together on main so reviews and long-lived docs match reality after merges.
Per-environment steering β each environmentβs sync can follow main or follow a named branch (for example to soak a stack change in Dev before it lands on main).
Homelab-realistic ergonomics β accept some manual merge hygiene instead of building full release automation up front.
An earlier sketch of this approach lived in wip/komodo-artifact-templates/scratch/branch_management_ideas.md.
Decision
Single infrastructure repo; main is the default line of truth After work merges, main holds the canonical layout for all managed environments (folder-separated configs, per existing repo structure docs).
One ResourceSync configuration per Komodo-managed environment (Dev, Test, Prod β or whichever environments are GitOps-managed) Each [[resource_sync]] (or equivalent Komodo sync configuration) sets, at minimum:
Repository (and git provider / account as required).
Branch (or other ref field supported by Komodoβs ResourceSync schema β see Resource sync config) independently per environment.
Resource path scope limited to that environmentβs subtree (for example only komodo/dev/** for Dev), so a branch used for experiments does not accidentally apply Prod definitions.
Branch-testing workflow
Create a feature branch for the change under test.
Point the target environmentβs ResourceSync at that branch (for example Dev follows feature/xyz while Test and Prod continue to follow main).
After validation, merge the feature branch to main.
Manually reconcile the ResourceSync metadata on main so environments that should track main again have their branch/ref set back to main (or another agreed default).
Known quirk (accepted): the ResourceSync TOML that sets branch = "feature/xyz"lives in Git. On the feature branch that file typically must reference the same branch so the sync pulls a self-consistent tree. After merge to main, that same fileβs branch field must be updated to main (or removed to default) for environments that should no longer track the feature branch. This is intentional small, explicit edits on main, not hidden automation, until/unless you add tooling.
Releases / tags as a pin for Prod (optional, not required now) Using tags or GitHub Releases so Prod tracks refs/tags/v⦠while main moves forward is a valid pattern when you want Prod immutability without branch gymnastics. With separate per-environment directories and per-sync branch control, the urgency is lower than in a single-shared-path model. We defer mandatory release-based promotion; revisit if main churn becomes painful for Prod.
Consequences
Positive
Clear mental model: folders = promotion path; branch on ResourceSync = βwhich revision of that tree is live for this environment.β
Feature work can soak on Dev/Test without forking the whole repo layout conceptually on main.
Decisions stay in Git and stay reviewable.
Negative / risks
Merge hygiene: forgetting to repoint a ResourceSync after merge can leave an environment tracking a stale or abandoned branch.
Self-consistency: sync config and synced paths must agree on branch, or behavior is confusing during transitions.
Mitigations (recommended, non-blocking)
Short checklist when merging infra PRs: βResourceSync branches updated for Dev/Test/Prod as intended?β
Keep ResourceSync definitions in small, dedicated files (for example under komodo/sync/ or similar) so diffs are obvious.
Optionally add CI later (grep/assert expected branch for Prod) if mistakes recur.
Alternatives considered
Alternative
Why not chosen (for now)
Prod pinned only by releases/tags
Valuable later; deferred to reduce moving parts until branch steering is proven in daily use.
Separate repos per environment
Duplicates structure; conflicts with single-repo promotion story already documented.
Only ever sync from main
Forces feature testing via copy/paste or temporary files outside Git flow; rejected.
Network connectivity strategy for the homelab using Tailscale mesh VPN.
Overview
Tailscale is a mesh VPN built on WireGuard that creates a secure network (called a “tailnet”) across devices regardless of their physical location. Unlike traditional VPNs, there’s no central server that all traffic flows through β devices connect directly to each other.
This homelab leverages Tailscale Services as the primary mechanism for exposing applications, with ACL-based access control separating user-facing interfaces from administrative access.
Key Concepts
Tailnet β Your private network of connected devices
MagicDNS β Automatic DNS for devices (e.g., device-name.tailnet-name.ts.net)
Tailscale Services β Named services advertised to the tailnet, accessible via MagicDNS
ACLs β Access control lists defining who can reach what
Funnel β Expose services to the public internet through Tailscale
Serve β Expose local services to your tailnet
Advantages Over Traditional VPNs
Traditional VPN
Tailscale
Hub-and-spoke β all traffic through central server
Mesh β direct device-to-device connections
Central server is bottleneck and single point of failure
No central infrastructure to fail
Complex setup (certificates, port forwarding, firewall rules)
Zero-config in most cases
Requires exposing a port to the internet
NAT traversal built-in, no exposed ports
VPN server needs to be always on
Coordination server only for handshake, not traffic
All-or-nothing access
Granular ACLs per user/device/service
Why this matters for homelab:
No need to open ports on home router
Access homelab from anywhere without complex setup
Granular control over who accesses what
Works even when home IP changes
Tailscale Services
Tailscale Services are the primary mechanism for exposing applications in this homelab. Rather than exposing entire hosts or subnets, individual services are advertised to the tailnet with their own DNS names.
Why Services Over Hosts
Approach
Exposure
Granularity
Host-based
Entire machine accessible
All ports, all services
Subnet routing
Entire network accessible
Everything on the subnet
Services
Single application endpoint
One port, one purpose
Services provide the tightest blast radius β if a service is compromised, the attacker gains access to that service only, not the host or network.
Service Naming Convention
Services are accessible via MagicDNS:
<service-name>.tailnet-name.ts.net
Examples:
actual.tailnet.ts.net β Actual Budget
plex.tailnet.ts.net β Plex Media Server
komodo.tailnet.ts.net β Komodo UI (admin-only)
Container Exposure Patterns
Three patterns for connecting containerized services to Tailscale:
Docktail β Service Advertisement
Docktail advertises containers as Tailscale Services. Use this when:
The application needs to be reachable by name from the tailnet
Multiple users/devices will access the service
You want the service in MagicDNS
Docktail watches for Docker labels and advertises matching containers as services.
ScaleTail β Per-Service Sidecar Exposure
ScaleTail provides Docker Compose configurations using the sidecar pattern β each service gets its own Tailscale container via network_mode: service:. The sidecar can advertise the service to the tailnet with Tailscale Serve.
Use this when:
You want per-service Tailscale instances (not centralized)
The container needs both inbound and outbound tailnet access
You want explicit control per service
Outbound-Only Sidecar
A sidecar container that joins the tailnet but does not advertise a service. Use this when:
The container needs to reach other tailnet resources
The container should not be directly addressable
You need tailnet access for configuration pulls, secrets, etc.
Example: A container that pulls secrets from Vault over Tailscale but doesn’t need inbound connections.
Choosing Between Them
Need
Pattern
Users access this app, want centralized
Docktail (labels-based)
Per-service control, inbound + outbound
ScaleTail (sidecar pattern)
App needs tailnet access, no inbound
Outbound-Only Sidecar
Both inbound and outbound (centralized)
Docktail (service already has tailnet access)
High Availability with Services
Tailscale Services can be advertised from multiple machines for high availability. When the same service name is advertised from multiple nodes, Tailscale routes traffic to an available instance.
Services that benefit from multi-node advertisement:
Reverse proxies / ingress
Stateless APIs
Load-balanced workers
Services that should remain single-node:
Stateful databases (unless using clustering)
Apps with local storage dependencies
Access Tiers
Not all users should access all interfaces. This homelab distinguishes between user-facing UIs and admin portals.
Role Definitions
Role
Description
Example Users
user
Can access consumer-facing application UIs
Partner, guests
manager
Can access app admin panels, not infrastructure
β
admin
Full access including infrastructure management
Owner
Interface Separation
Interface Type
Accessible To
Examples
User Web UI
All tailnet users
Plex, Actual Budget, Jellyfin
App Admin Portal
managers, admins
Plex admin, *arr settings
Infrastructure UI
admins only
Komodo, Proxmox, router admin
SSH/Console
admins only
All hosts
Implementation Approach
ACLs enforce access tiers by:
Grouping users into roles (groups in ACL policy)
Tagging services by interface type
Granting access based on role β tag mappings
See [[tailscale-grants]] for the full grants policy document β this section defines the model, not the implementation.
Subnet Access Strategy
Subnet routing exposes entire network ranges to the tailnet. This is powerful but dangerous β it bypasses per-service access control.
When to Use Subnets
Subnet routing is appropriate for:
Admin management of devices that can’t run Tailscale (switches, APs, IoT devices)
Legacy systems that predate the Tailscale deployment
Temporary access during migrations
Admin-Only Subnet Access
Subnet routes are advertised but restricted to admins only via ACLs. Regular users and managers cannot reach devices via subnet routing β they must use advertised services.
This preserves the principle of least privilege: users access applications, admins access infrastructure.
Subnets vs Services
Access Method
User
Manager
Admin
Tailscale Services
Per-ACL
Per-ACL
Yes
Subnet Routes
No
No
Yes
Exit Node Strategy
Exit nodes route all device traffic through a tailnet node. Useful for:
Appearing to be “at home” when traveling
Using home DNS/Pi-hole from anywhere
Accessing geo-restricted content
The Exposure Risk
An exit node that also advertises subnet routes or services creates a risk: a device using the exit node could potentially reach resources it shouldn’t.
Mitigation: Dedicated Exit Node
Run exit node functionality on a dedicated node that:
Advertises itself as an exit node
Does not advertise subnet routes
Does not run Docktail or advertise services
Has minimal local services
This isolates the exit node function from service exposure. Traffic exits to the internet through the node, but doesn’t gain access to internal resources.
βββββββββββββββββββββββββββββββββββ
β Exit Node (dedicated) β
β β
β β Exit node enabled β
β β No subnet routes β
β β No Docktail β
β β No local services β
β β
β Internet βββ traffic exits β
βββββββββββββββββββββββββββββββββββ
Alternative: ACL-Controlled Exit
If a dedicated node isn’t practical, ACLs can restrict what exit node users can reach. But this is more complex and easier to misconfigure β the dedicated node approach is preferred.
Use Cases
Remote Application Access
Applications are exposed as Tailscale Services via Docktail:
Phone/Laptop ββ(Tailscale)βββΆ actual.tailnet.ts.net βββΆ Actual Budget container
No public exposure
Accessible from any device on the tailnet
Access controlled by ACLs based on user role
Remote Device Access
Tailscale SSH:
SSH into any device on the tailnet without managing SSH keys
Tailscale handles authentication via identity provider
Restricted to admins via ACLs
ssh user@device.tailnet.ts.net
Management UIs (admin-only):
Proxmox, Komodo, router admin panels
Accessible only to admin role
Either via advertised service or subnet route
Remote File Access
SMB shares:
Access Windows/Samba shares from anywhere
smb://nas.tailnet.ts.net/share
ACLs control which shares are accessible to which roles
NFS mounts:
Mount NFS shares over Tailscale
Performance may degrade over high-latency connections
Shared Access (Partner)
Partner has her own Tailscale account and devices, shared into the tailnet with controlled access. She is a member of the user role.
Adversarial Access Model
Treat partner’s access as adversarial by default β not because of distrust, but because:
We need a container runtime for local development, CI/CD pipelines, and homelab deployment. The ecosystem has two primary open-source contenders: Docker (the incumbent) and Podman (the rising alternative). Both implement OCI standards and can run the same container images, but differ significantly in architecture, security model, and ecosystem integration.
Key evaluation criteria:
Security posture (rootless vs rootful by default)
Daemon architecture (monolithic vs fork/exec)
Docker-compatibility and migration friction
Desktop GUI experience (Docker Desktop vs Podman Desktop)
Defer β no single winner. The choice depends on the deployment context:
Context
Recommendation
Why
Local dev (macOS)
Docker Desktop
Better UX, tighter IDE integration, mature volume mounts
Local dev (Linux)
Podman
Native rootless, no daemon tax, distro-packaged
CI/CD pipelines
Docker-in-Docker
Industry standard, broadest runner support
Homelab server
Podman
Systemd integration, rootless by default, lighter footprint
Kubernetes dev
Podman
Built-in pod concept, better k8s alignment
Rationale
Security
Podman: Rootless by default. Each user gets their own namespace β no central daemon with root-equivalent access. This is a fundamental architectural advantage for multi-tenant systems and security-conscious deployments.
Docker: The daemon (dockerd) runs as root by default. Adding users to the docker group is effectively root-equivalent. Rootless mode exists but is opt-in and less mature.
Architecture
Docker: Client-server model. docker CLI β dockerd daemon β containerd β runc. The daemon is a single point of failure and a persistent resource drain.
Podman: Fork/exec model. Each podman command directly spawns container processes via conmon + runc/crun. No persistent daemon. This means containers are systemd-manageable and don’t die if a daemon crashes.
Docker-Compatibility
Podman implements the Docker CLI API (alias docker=podman works for most commands), supports docker-compose (via podman-compose or podman machine), and can pull/build/run any OCI image.
Edge cases exist: Volume mount semantics differ, networking stacks diverge (podman uses pasta/slirp4netns vs Docker’s built-in bridge), and some Docker-specific features (Swarm mode, BuildKit advanced features) have no Podman equivalent.
Desktop Experience
Docker Desktop: Polished, all-in-one GUI. Includes Kubernetes cluster, volume management, registry browser, Dev Environments, and extensions ecosystem. Paid license for commercial use >250 employees or $10M+ revenue.
Podman Desktop: Open-source GUI, improving rapidly. Supports Podman and Docker backends, Kubernetes integration, extensions. Lighter but less feature-rich. Free for all use.
Ecosystem & Governance
Docker: Docker, Inc. Owns the developer mindshare. Vast documentation, tutorials, Stack Overflow presence. Docker Hub is the default registry. Licensing changes (2021 commercial license) caused community friction.
Podman: Red Hatβbacked, part of the libpod project. CNI-aligned networking, systemd-native. Growing adoption in enterprise Linux spaces (RHEL, Fedora). No corporate licensing tension.
Consequences
We may need to support both runtimes depending on environment. A docker alias or abstraction layer (CONTAINER_RUNTIME env var) can ease transitions.
Compose v3 files are broadly portable; v4 features may not be. Stick to v3 for cross-runtime compatibility.
Podman’s macOS experience relies on a Linux VM (via podman machine or podman desktop), similar to Docker Desktop but less polished. Volume mounts and port forwarding are slower on macOS.
If Kubernetes is the long-term target, Podman’s pod-level semantics map more naturally to k8s concepts.
Docker is still the default for most CI/CD providers. Using Podman in CI requires explicit runner configuration.
AI VM Architecture
Overview
A dedicated Debian VM with an NVIDIA Quadro P5000 passed through, running LlamaFarm natively as the AI management layer. The VM is fully disposable β Ansible provisions the OS and installs tooling; models and data live on persistent storage. If the VM is destroyed, a fresh provision + persistent volume mount restores full capability in minutes.
VM Specifications
Component
Minimum
Recommended
Notes
vCPU
4 cores
8+ cores
Inference benefits from more cores for prompt processing
RAM
16 GB
32 GB
~16 GB for OS + LlamaFarm services + 8B model inference + KV cache
Root disk
40 GB
64 GB
OS + LlamaFarm service binaries (~3-5 GB) + swap space
Model/data disk
100 GB
256 GB
Persistent volume for model weights + vector store + RAG data
GPU
Quadro P5000
Quadro P5000
16 GB GDDR5X VRAM β comfortable for 7B-13B quantized models
11.x or 12.x (Pascal supports both; use 12.x for upstream llama.cpp compatibility)
PCIe
Gen3 x16 β ensure host has an available slot and IOMMU group isolation
TDP
180 W β account for cooling in the chassis
Important: Pascal (6.1) does not support bfloat16 natively. LlamaFarm’s Universal Runtime will use fp16 or fp32 fallback. Use GGUF quantized models (Q4_K_M) for best VRAM fit.
Storage Layout
Filesystem Contents Size Persistent?
/dev/sda1 (root) OS + lf CLI + PyApp bins 40-64 GB No β recreated
/dev/sdb1 or NFS mount Models + data 100-256 GB Yes β survives
Persistent volume contents (/var/lib/llamafarm or mount at ~/.llamafarm):
~/.llamafarm/ # LF_DATA_DIR
βββ bin/ # PyApp service binaries (server, rag, runtime)
βββ projects/ # Project configs, Chroma vector store, session state
βββ addons/ # ML addon wheels
~/.cache/huggingface/ # TRANSFORMERS_CACHE
βββ hub/ # Downloaded model weights (the real space hog)
[Unit]Description=LlamaFarm AI PlatformAfter=network.target[Service]Type=simpleUser=aiGroup=aiEnvironment=LF_DATA_DIR=/var/lib/llamafarmEnvironment=TRANSFORMERS_CACHE=/var/lib/llamafarm/cacheWorkingDirectory=/var/lib/llamafarm/projects/default/my-ai-vmExecStart=/usr/local/bin/lf start --daemonRestart=on-failureRestartSec=10[Install]WantedBy=multi-user.target
Ansible manages this unit file and can enable/start it.
Network Topology
Client βββ Tailscale βββ AI VM (:14345)
(laptop) (overlay) ββ LlamaFarm Server / Designer UI
ββ Universal Runtime (:11540)
ββ RAG worker (internal)
LlamaFarm API and Designer UI accessed directly over Tailscale at http://ai-vm:14345
No reverse proxy needed β LlamaFarm serves HTTP natively
Ports 14345 and 11540 bind to the Tailscale interface only
No ports exposed to LAN or WAN directly
Backup Strategy
What
Frequency
Method
~/.cache/huggingface/ (models)
Once (re-downloadable)
Backup only if slow link. Models are cachable artifacts.
~/.llamafarm/projects/ (configs + vector DB)
Daily
rclone to NAS (or any S3-compatible target)
Ansible playbooks
On change
Git β push to private forge
Current Limitations & Future Considerations
Pascal architecture (compute 6.1) means no bfloat16. All inference is fp16 or fp32. Not a blocker, but newer GPUs (Turing/Ampere+) would be ~2x faster for the same model.
Single GPU β can serve one model at a time efficiently. For multi-model serving concurrently, split VRAM across instances or add a second GPU.
LlamaFarm pre-1.0 β expect occasional breaking changes. Pin the lf version and test upgrades in a disposable LXC first.
Monitoring β not yet addressed. Consider Prometheus + node_exporter or Uptime Kuma for health checks.
Architecture
System diagrams and visual documentation for the homelab infrastructure.
Komodo Controller sits outside the environment groups β it manages but is not part of workload infrastructure
Managed Docker hosts (Prod, Test, DevDocker) have Periphery agents and are GitOps-controlled
DevDocker VM runs on ProxMox but is still Komodo-managed β promotion path to Test β Prod
Unmanaged nodes in Dev β DevNode VMs and OCP cluster have no Komodo integration
Storage is accessed via network mounts from UnRaid NAS (Prod) and Synology NAS (Test)
GitOps Flow
Each managed environment is driven by Komodo ResourceSync, which can target main or a named branch independently (so Dev can track a feature branch while Prod tracks main). The full branch-steering workflow and merge hygiene are in the ADR on per-environment ResourceSync branches; repo layout and promotion folders are in Planned repository structure.
Subsections of Architecture Decision Records β AI VM Management
ADR 0001: LlamaFarm as AI model management layer
Status: Proposed Date: 2026-07-14
Context
The homelab has a dedicated AI VM running Ubuntu with assorted AI tools installed ad-hoc. There is no consistent model management layer β models are pulled manually, runtimes are configured individually (Ollama, vLLM, raw HuggingFace), and there is no unified API surface or RAG pipeline.
As part of a broader effort to make this VM “more than just a box with AI tools on it,” a management layer is needed for:
Unified API surface (OpenAI-compatible endpoint for all models)
Reproducible configuration (infra-as-code for AI, not just the VM)
Decision Drivers
Disposable VM model β the VM should be recreatable from Ansible + persistent data volumes without manual recovery steps.
Local-first, offline-capable β the AI layer must work without internet after models are cached.
GPU passthrough β quad-port P5000 needs CUDA support with minimal friction.
Config-driven, not script-driven β model selection, prompt templates, and RAG pipelines should live in version-controlled config files, not shell scripts.
OpenAI-compatible API β any tool that speaks OpenAI’s API should be able to target this VM.
Options Considered
Option 1: Ansible/Terraform/Semaphore only
Provision the VM, install Ollama/vLLM directly, manage models via shell scripts or ad-hoc commands.
Pro: Mature tooling, full homelab coverage.
Con: No model lifecycle abstraction β still need glue scripts for model switching, RAG pipelines, etc. Ansible does not natively understand “serve this model” as a primitive.
Option 2: LlamaFarm as the AI layer (chosen)
Provision the VM with Ansible, then run LlamaFarm natively (no containers) to manage models, RAG, and API serving.
Single config file (llamafarm.yaml) is the source of truth for all AI behavior β model selection, prompts, RAG, MCP servers.
Truly disposable VM β models and data live on persistent volumes; a fresh Debian install + one lf init + lf start + pointing at cached models restores full capability.
Built-in RAG β no need to wire up a separate vector DB, embedder, and chunker; it’s all in the config.
Native deployment β LlamaFarm runs without Docker/K8s, keeping the footprint lean and the GPU path simple.
Offline-capable β after initial model download, all inference is local with no egress.
Negative / Risks
Risk
Impact
Pre-1.0 stability
API/config changes between releases; may need to pin versions.
Small community
833 GitHub stars, pre-1.0. Less troubleshooting help available compared to Ollama or Ansible.
PyApp binary size
Service binaries bundle Python runtimes (~1-2 GB total for server + rag + runtime).
YC-backed startup
Open-source today, but direction could shift. Fork as mitigation.
Dependency chain
Python + TypeScript + Go + Nx monorepo. More moving parts than Ollama alone.
CUDA compatibility
Requires matching CUDA toolkit version with PyTorch / llama.cpp. Upstream already dropped CUDA 11 (b7694+).
Mitigations
Mitigation
Detail
Pin LlamaFarm version
Use lf bundle --version v0.0.x to create offline install bundles; avoid latest drift.
Persistent model cache
$TRANSFORMERS_CACHE and $LF_DATA_DIR on NAS-backed NFS or dedicated disk β models survive VM recreation.
Ansible as day-0 only
Ansible provisions the VM and installs lf; LlamaFarm’s yaml is the AI config. Clear boundary reduces coupling.
Monitor upstream
Keep an eye on breaking changes before applying lf upgrades. Test in a disposable LXC first if uncertain.
NVIDIA driver pinning
Pin CUDA toolkit version in Ansible to match LlamaFarm’s PyTorch requirement.
AI VM architecture: docs/homelab/ai-vm/architecture.md
Decisions
Key architectural and tooling decisions, with rationale.
Komodo over Kubernetes (K3s + ArgoCD)
Decision: Use Komodo for container GitOps instead of deploying K3s with ArgoCD.
Context: Needed GitOps capabilities (drift detection, continuous reconciliation) for Prod/Test environments. Current approach (GitHub Actions β Ansible playbooks) works but lacks:
Drift detection
Continuous reconciliation
Sustainable operational model
Options considered:
Option
Pros
Cons
K3s + ArgoCD
Familiar with ArgoCD, true GitOps, industry standard
K8s operational complexity, more failure points, overkill for homelab
Komodo
Docker-native, ~2.5Γ less config, uses familiar Compose concepts
Newer tool, less ecosystem
Ansible Pull
Uses existing skills, simple
DIY drift detection, more manual work
Portainer Business
Has GitOps features
Paid, vendor lock-in
Decision rationale:
Complexity: Komodo config is ~67 lines for a Plex stack vs ~176 lines for equivalent K8s manifests
Risk tolerance: K3s in production homelab felt risky β more moving parts, less familiar failure modes
Concept alignment: Docker Compose is already known; Komodo extends it rather than replacing it
Operational simplicity: Single Komodo Core + Periphery agents vs. K8s control plane + ArgoCD + supporting infrastructure
Trade-offs accepted:
Komodo is a newer, smaller project than ArgoCD
Less community support and fewer integrations
If K8s skills are needed for work, the OCP cluster in Dev provides that separately
Three-Tier Managed Docker Hosts (Dev β Test β Prod)
Decision: DevDocker VM is Komodo-managed, not a sandbox.
Context: Initially considered all dev nodes as unmanaged sandboxes. Reconsidered based on promotion path needs.
Rationale:
DevDocker is where container work starts before going to production
Having it in Komodo creates a consistent workflow: same TOML format, same tooling
Promotion is literally moving a file between directories
Other Dev nodes (VMs, OCP cluster) don’t need this β they’re for different purposes
Dedicated Komodo Controller (NUC)
Decision: Run Komodo Core on dedicated hardware with no workloads.
Rationale:
Management plane should be separate from workload infrastructure
If a managed node has issues, the controller can still observe and remediate
Clean failure domain β controller failures don’t affect running workloads (they just stop being managed temporarily)
NUC is low-power, can run 24/7 without significant cost
Terraform + Ansible (Not Just Ansible)
Decision: Use Terraform for provisioning, Ansible for configuration.
Context: Already comfortable with Ansible. Question was whether to use Ansible for everything or split responsibilities.
Boundary:
Terraform: Provision VMs, network resources, base infrastructure
Ansible: System configuration, package installation, Komodo Periphery agent deployment
Rationale:
Terraform has better state management for infrastructure
Ansible is better for configuration management and idempotent system setup
Clean separation of concerns β “what exists” (Terraform) vs “how it’s configured” (Ansible)
Poll-Based GitOps (Not Webhooks)
Decision: Use Komodo’s default poll-based sync rather than GitHub webhooks.
Context: Komodo supports both:
Poll-based: Core periodically checks Git for changes
Webhook-based: GitHub notifies Core immediately on push
Rationale:
Simpler setup β no webhook configuration needed
Works even if GitHub can’t reach the homelab (no public endpoint required)
Slight delay (poll interval) is acceptable for homelab use case
Can add webhooks later if immediate deployment becomes important
Per-environment Git branches (ResourceSync)
Decision: Use separate Komodo ResourceSync configurations per environment, each with its own Git branch / ref, so main can hold all environment folders at once while Dev (or Test) temporarily tracks a feature branch for validation.
Context: Feature work needs to soak in a managed environment without blocking main or copying configs outside Git. ResourceSync already supports repo + branch + scoped paths.
Operational trade-off: ResourceSync TOML that lives in Git may reference the feature branch while that branch is active; after merge to main, small manual edits on main repoint environments back to main where intended. Releases/tags for Prod-only pinning are optional and deferred until needed.
An opinionated migration guide covering a homelab with 5 servers, 20+ stacks, and 60+ containers. Relevant to our setup given similar topology.
What it covers:
Storage strategy: Monorepo layout for stacks (stacks/serverN/) and Komodo resource TOMLs (komodo/resources/main.toml). Git repo-based for all resources (backup via commits on save).
Rootless Periphery (community pattern): Docker socket-proxy (linuxserver/socket-proxy) with a non-root Periphery container, controlled via DOCKER_HOST: tcp://socket-proxy:2375. Also covers building a custom Periphery image for git user config when running non-root.
Periphery deployment evolution: Started containerized, switched to systemd agent after 3+ months β “systemd makes Docker interactions simpler.” Notes terminal/shell access differences between container and systemd agents.
Stack creation: Configuring Linked Repos for monorepo, Run Directory per stack, converting standalone containers via docker-autocompose.
Environment variables & Secrets: How Komodo’s .env interpolation works, secrets via [[SecretName]] syntax.
Resource Sync: Setting up bi-directional sync between Komodo and a Git repo for full topology backup. Tags for scope limiting, Execute vs Commit modes.
Docker data agnostic location: Using $DOCKER_DATA ENV per host for bind mount paths, making compose files portable across servers.
Follow-up post (also by FoxxMD):Komodo FAQ, Tips, and Tricks β covers container exec shortcuts, troubleshooting, and deeper operational patterns.
Environments
Detailed breakdown of each environment (VLAN) in the homelab.
Managed Environments (GitOps)
These environments have Komodo Periphery agents on Docker hosts, managed via GitOps. Changes flow through Git β Komodo Core β Periphery Agent β Container.
Prod
Purpose: Production workloads β services that need to be reliable and always available.
Simple app example (single container, no compose β flat file):
# prod/homepage.toml[[deployment]]
name = "homepage"description = "Dashboard"tags = ["dashboard", "prod"]
[deployment.config]
server = "prod-docker-01"image = "ghcr.io/gethomepage/homepage:latest"deploy = true
Shared Variables (shared/)
Variables that can be interpolated across environments.
# shared/variables.toml[[variable]]
name = "TZ"value = "America/New_York"[[variable]]
name = "PUID"value = "1000"[[variable]]
name = "PGID"value = "1000"
Promotion Flow
Moving apps between environments is a folder operation:
dev/new-app/
β
βΌ (validate in dev)
test/new-app/
β
βΌ (validate in test)
prod/new-app/
To promote: Copy or move the app’s folder to the next environment’s directory. Update the server field in the TOML to point to the correct host.
To deploy to multiple environments: Copy the folder to each environment. Each copy can have environment-specific tweaks (different compose settings, config values, etc.).
Git branch steering
Folder promotion (dev/ β test/ β prod/) answers which subtree on main is canonical after merge. Which Git revision each environment applies is separate: each Komodo ResourceSync sets its own branch / ref and scoped paths against the infrastructure repo.
Homelab default:
main holds the full komodo/ tree (every environment directory) for reviewability and docs.
Each environment has its own ResourceSync so, for example, Dev can track feature/xyz while Prod tracks main.
After merging a feature branch, manually repoint ResourceSync metadata on main for any environment that should follow main again (sync TOML on the feature branch usually names that branch while work is open β see ADR).
Releases / tags to pin Prod are optional later; per-environment folders + branch knobs reduce the urgency.
Home Assistant (HA) is the central hub for IoT automation in the new house. It needs to integrate with:
Zigbee devices (switches, sensors, buttons) via USB coordinator
Tapo cameras (RTSP/Tapo integration)
Unifi Protect (running on UDM)
Google Home (TTS announcements)
Sensi thermostats (cloud API)
The homelab has a defined Prod/Test/Dev architecture with Komodo-managed Docker hosts. Home Assistant must fit into this architecture while meeting its unique requirements.
Requirements
USB access β Zigbee coordinator must be physically attached to host
Network access β Must reach IoT VLAN (10.0.4.0/24) for cameras, Google Home, etc.
Unifi Protect access β Must reach UDM for Protect integration
Reliability β Automation failures directly impact daily life
Manageability β Should fit into existing GitOps/Komodo workflow where possible
Backup/Recovery β Must be recoverable quickly if host fails
Constraints
IoT VLAN (10.0.4.0/24) is isolated from other VLANs by design
Zigbee coordinator is USB-bound β cannot easily fail over to another host
Home Assistant does not support native clustering/HA
Decision
Deploy Home Assistant as a container on LenovoMini 1 (Prod Docker host) with USB passthrough for the Zigbee coordinator.
Phased Approach
Phase 1 β Bootstrap (RPi):
Run HA on existing Raspberry Pi 4
Place on Prod VLAN (10.0.1.0/24)
Configure firewall rules for IoT access
Validates the setup before full integration
Phase 2 β Production Migration:
Migrate HA to container on LenovoMini 1
USB passthrough for Zigbee coordinator
Komodo-managed deployment
Automated backups to UnRaid NAS
Options Considered
Option 1: Dedicated Raspberry Pi
Description: Run Home Assistant OS on a dedicated RPi 4.
Pros:
Simple setup
Direct USB access (no passthrough complexity)
Isolated from other workloads
Cons:
Another device to manage outside Komodo
SD card reliability concerns
Limited compute for future expansion (Frigate, etc.)
No integration with existing backup infrastructure
Verdict: Good for bootstrap, not ideal long-term.
Option 2: Container on Prod Docker Host (LenovoMini 1)
Description: Run HA as a Docker container on the existing Prod host, managed via Komodo.
Pros:
Better hardware (8th Gen i7, 16GB RAM, NVIDIA GPU)
Integrated with existing management (Komodo, GitOps)
Backups to UnRaid NAS
Network already on Prod VLAN with access to other services
GPU available for future Frigate integration
Cons:
USB passthrough required (adds configuration)
Shares resources with Plex/*arr (mitigated by resource limits)
Single point of failure (mitigated by backups)
Verdict: Best fit for long-term architecture.
Option 3: VM on ProxMox
Description: Run HA in a dedicated VM on the ProxMox host.
Pros:
Isolated resources
ProxMox has snapshot/backup features
Cons:
ProxMox is on Dev VLAN (10.0.3.0/24) β wrong network segment
USB passthrough to VM is more complex than Docker
Not on the Komodo-managed path
Verdict: Network placement is wrong. Rejected.
Option 4: Dedicated Mini Host on Prod VLAN
Description: Purchase a dedicated small device (NUC, mini PC) for HA.
Pros:
Dedicated to home automation
Direct USB, proper network placement
Cons:
Additional hardware cost
Another device outside existing management
Overkill given available capacity on LenovoMini 1
Verdict: Unnecessary given existing infrastructure.
Consequences
Positive
Integrated management: HA deployment is Komodo-managed, follows GitOps patterns
Better hardware: More headroom for future expansion (Frigate, more integrations)
Unified backups: HA config/data backed up to UnRaid alongside other Prod data
Network positioning: Prod VLAN has (or will have) appropriate access to IoT and UDM
Negative
USB passthrough complexity: Requires privileged container or explicit device mapping
Shared host: HA competes with Plex/*arr for resources (mitigated with resource limits)
Single point of failure: If LenovoMini 1 goes down, HA is down (mitigated by backups + quick restore)
Neutral
No native HA failover: Accepted limitation β most homelabs operate this way
Zigbee bound to host: Coordinator cannot fail over, but Zigbee direct bindings provide local fallback
Implementation
Phase 1: Bootstrap on RPi
Timeline: Immediate (while homelab is being set up)
Implement a warm/cold spare HA instance on the Test environment (LenovoMini 2) with automated failover capability. When the primary (Prod) instance has unfixable issues, a single-click remediation brings up the spare and updates network rules.
Option A: Scheduled rsync of /opt/homeassistant/config from Prod β Test (via NAS or direct)
Option B: Shared config on NAS mount (both read, only active writes)
Option C: Git-based config with HA Git integration
3. Zigbee Network Portability
Use Zigbee2MQTT (not ZHA) β stores network config in coordinator_backup.json
Backup can be restored to second coordinator
Devices don’t need re-pairing if network key is preserved
Caveat: Second coordinator must be same model or compatible
4. Firewall Automation (Ansible/Terraform)
# ansible playbook: failover-to-test.yml- name: Failover HA to Test environmenthosts: localhosttasks:
- name: Update UDM firewall - block Prod to IoTcommunity.general.udm_firewall_rule:
name: "Prod-to-IoT"state: disabled - name: Update UDM firewall - allow Test to IoTcommunity.general.udm_firewall_rule:
name: "Test-to-IoT"state: enabled - name: Start HA container on Testcommunity.docker.docker_container:
name: homeassistantstate: starteddelegate_to: lenovomini2 - name: Stop HA container on Prod (if reachable)community.docker.docker_container:
name: homeassistantstate: stoppeddelegate_to: lenovomini1ignore_errors: yes
5. Failback Procedure
Reverse the process when Prod is healthy
Sync any config changes from Test back to Prod
Re-enable Prod firewall rules, disable Test
Spare Modes
Mode
Standby State
Sync Frequency
Failover Time
Resource Usage
Cold Spare
Container stopped
Daily backup restore
~5-10 min
Minimal
Warm Spare
Container running (no IoT access)
Near real-time rsync
~1-2 min
Moderate
Recommendation: Start with Cold Spare. Warm Spare adds complexity for marginal time savings.
Triggering Failover
Manual (Phase 3a):
Single-click: Run Ansible playbook from Komodo UI or CLI
Human detects issue, initiates failover
Automated (Phase 3b, future):
Health check monitors HA (e.g., /api/ endpoint)
Consecutive failures trigger alert
Optional: Auto-failover after N failures (risky, needs tuning)
Prerequisites
Before implementing:
Hardware: Second Zigbee coordinator (~$25)
Network: Test VLAN firewall rules pre-configured (disabled)
Ansible: Playbooks for failover/failback
UDM API access: Ansible collection for Unifi or direct API calls
Zigbee2MQTT: Migrate from ZHA if using ZHA initially
Testing: Validate failover in controlled scenario
Risks & Mitigations
Risk
Mitigation
Zigbee devices don’t reconnect to second coordinator
Use Zigbee2MQTT with network backup; same coordinator model
Config drift between Prod and Test
Automated sync + Git-based config
Failover triggered unnecessarily
Manual trigger only (Phase 3a); conservative health checks (Phase 3b)
Firewall automation fails
Pre-test playbooks; manual fallback documented
Cost
Item
Cost
Second Zigbee coordinator
~$25
Time to implement
Medium
Ongoing maintenance
Low (once stable)
Decision
Defer to Phase 3. Implement after Phase 2 is stable and proven. The complexity is justified only after experiencing reliability issues or when uptime requirements increase.
Direct Zigbee binding (button controls switch without hub)
Configurable LED notification bar
Scene/multi-tap support
Mesh network extends coverage
Cons:
Higher per-switch cost
2.4GHz interference possible (manageable)
Z-Wave β Zooz Switches
Protocol: Z-Wave Plus Switch Cost: ~$30-35 Coordinator Cost: ~$35-45
Pros:
Works with existing dumb switch in 3-way config
Lower per-switch cost
Dedicated frequency (no WiFi interference)
Mature, reliable protocol
Cons:
Smaller device ecosystem
Wireless buttons limited and expensive ($40+)
Less feature-rich
Also Considered
Lutron Caseta: Excellent reliability, but requires proprietary bridge. Less flexible ecosystem.
WiFi switches: Cloud dependency or complex local setup. Doesn’t align with self-hosted goals.
Decision
Zigbee with Inovelli Blue switches.
Rationale
Accessory ecosystem: Zigbee buttons and sensors are significantly cheaper. $7 IKEA button vs $40+ Z-Wave compounds quickly.
Upgradeability: Largest device selection across manufacturers for future expansion.
Feature set: LED notifications, direct binding, scene control β useful for automations.
3-way handling: Single switch replacement works cleanly.
Implementation
Coordinator: Sonoff Zigbee 3.0 USB Dongle Plus
Integration: ZHA or Zigbee2MQTT (both well-supported)
Placement: Central location or USB extension to avoid interference
Home Assistant Automations
Workflow definitions for IoT automations. Copy/adapt these for Home Assistant.
Door Sensor Automations
Door Left Open Alert
Announces via Google Home when a door has been open for 5 minutes.
alias: "Alert - Door Left Open"description: "Announce when a door has been open too long"trigger:
- platform: stateentity_id:
- binary_sensor.front_door - binary_sensor.back_door - binary_sensor.office_door - binary_sensor.garage_entry_doorto: "on"for:
minutes: 5action:
- service: tts.google_translate_saytarget:
entity_id: media_player.living_room_speakerdata:
message: "{{ trigger.to_state.attributes.friendly_name }} has been open for 5 minutes."mode: parallel
Door Still Open Reminder
Repeats announcement every 5 minutes while an exterior door remains open.
alias: "Alert - Door Still Open Reminder"description: "Repeat reminder every 5 minutes while door is open"trigger:
- platform: stateentity_id:
- binary_sensor.front_door - binary_sensor.back_doorto: "on"for:
minutes: 5action:
- repeat:
while:
- condition: stateentity_id: "{{ trigger.entity_id }}"state: "on"sequence:
- service: tts.google_translate_saytarget:
entity_id: media_player.living_room_speakerdata:
message: "Reminder: {{ trigger.to_state.attributes.friendly_name }} is still open." - delay:
minutes: 5mode: parallel
Garage Entry Night Alert
Alerts when garage entry door opens at night, with camera snapshot.
Requires Tapo integration or generic camera with RTSP. Snapshots save to /config/www/ for web access.
Zigbee Events
Button events use zha_event (for ZHA) or mqtt triggers (for Zigbee2MQTT). Device IDs and commands vary by button model β check HA device page after pairing.
Receipt Automation
Receipt Fridge Launch β NFC App Launch
Tap the fridge NFC tag to open the receipt scanner app directly.
The NFC tag is the entry point. The app handles the actual capture and any per-receipt workflow.
Considerations:
If the app supports deep links or URL schemes, prefer that over a generic notification.
If people use different phones/scanner apps, map multiple notify targets or a choose block by person/presence.
Service Check / Router Status NFC
Router Status β NFC Status Page
Opens a locally hosted status page showing service health.
alias: "Status - Router NFC"description: "Serve local status page for router/service health"trigger:
- platform: nfctag_id: <router_status_nfc_uid>action:
- service: browser_mod.popupdata:
title: "Service Status"# Serve from a local/static endpoint; no external CDN depsurl: "/local/status/index.html"mode: single
Implementation notes:
Host status/index.html on the same host that serves HA or a lightweight internal web server.
Keep the page plain HTML with no external dependencies so it works when the internet is down.
Display gateway ping, DNS resolution result, Pi-hole stats, and a one-click router reboot if supported.
Consumable Replacement NFC
Consumable Tag β Reorder Trigger
Opens the replacement item’s reorder path.
alias: "Consumables - Replace"description: "Open consumable replacement action"trigger:
- platform: nfctag_id: <consumable_nfc_uid>action:
- service: browser_mod.popupdata:
title: "Replace Air Filter"# Prefer a stable local/HA endpoint; the destination can rotate behind this URLurl: "/local/consumables/air-filter.html"mode: single
Instead of encoding a retailer URL directly, use a stable local or Home Assistant destination so the reorder page can change without rewriting the tag.
Pi-hole Temporary Disable NFC
Pi-hole β 30 Minute Disable
Disables Pi-hole time-boxed for 30 minutes.
alias: "Pi-hole - Temp Disable 30m"description: "Disable Pi-hole for 30 minutes from NFC tap"trigger:
- platform: nfctag_id: <pihole_temp_disable_nfc_uid>action:
- service: rest_command.pihole_disable_30mmode: single
Time-box the disable window so ad-blocking is re-enabled automatically. Surface the remaining time on the local status page so the household can see the countdown.
Observability
Plans for public user-facing status and private operator dashboards (metrics, deep links, navigation to Komodo and Uptime Kuma).
Phases, success criteria, sequencing, status.samplayskeys.com, single pane of glass
For hardware, VLANs, and which services run where, start at the homelab map.
Subsections of Observability
Public status site and operator dashboard
Plans for user-facing public status (status.samplayskeys.com) and a private operator βsingle pane of glass,β with acceptance criteria, sequencing, and open decisions.
Phase 1 β Public status site
URL:status.samplayskeys.com Audience: Partner and other users of user-facing services (not homelab admins). Tone / UX: Similar to GitHub Status: components (or service groups), overall status, recent incident history, minimal jargon.
Intent
User-available observability: clear βis the thing I use up?β without exposing internal topology or admin tools.
Survives local outage: host on a VPS (candidate: Oracle Cloud free tier) so the page stays reachable if home internet or the lab is down.
Success criteria
Public HTTPS at status.samplayskeys.com with a sensible TLS story (e.g. reverse proxy + Letβs Encrypt on the VPS).
Lists only non-admin, user-relevant resources (e.g. Plex, Signal, Miniflux, Syncthing client-facing checks β exact list is yours to curate).
Does not link to or name internal admin URLs, VLANs, or management UIs.
Status data remains trustworthy when the house is offline: checks must run from the VPS (or another off-LAN path) against reachable endpoints (public hostname, reverse proxy, or approved tunnel β not 10.x.x.x).
Optional but valuable: brief incident log / maintenance notices you can edit without redeploying the whole stack (even a static markdown or small API is fine).
Open decisions (Phase 1)
Topic
Notes
Product / implementation
Options include self-hosted status engines (e.g. Cachet, Statping-ng, OpenStatus-style), static site + scheduled probe publishing JSON, or minimal custom static + GitHub-style updates. Pick based on maintenance appetite and whether you want a DB on the VPS.
Source of truth vs Uptime Kuma
Kuma may stay authoritative for alerting on the LAN; the public site can duplicate checks from the VPS or subscribe to exported state if you add a safe integration. Avoid coupling public page to LAN-only Kuma without a tunnel or push path.
DNS
status subdomain DNS at your registrar β VPS public IP; confirm Oracle (or chosen host) outbound checks allowed to your public endpoints.
Non-goals (Phase 1)
Full metrics graphs, Prometheus, or environmental sensors (already deferred elsewhere).
Authenticated βadmin viewβ of the same page (keep public surface boring and safe).
Phase 2 β Single pane of glass (operator)
Audience: You (operator), not the general public. Primary job: One place to orient and jump to deeper tools.
Intent
Overview of the entire lab β health/signal at a glance (CPU/load/disk on key hosts, maybe Docker summary), possibly via Glances or equivalent on hosts plus an aggregation story.
Curated links (always visible):
Uptime Kuma (alerting / synthetics).
Komodo (deployments / stacks).
Dynamic or data-driven links to management URLs: Unraid UI, container admin UIs, UniFi, Synology DSM, etc. β ideally driven from the same source of truth you use for Komodo or a small YAML manifest so new services do not require hand-editing HTML.
Success criteria
One bookmarkable URL (likely not public β Tailscale or LAN-only) that opens the dashboard.
Sections or tiles: βLab overviewβ (metrics snapshot), βMonitoringβ, βOrchestrationβ, βManagement consolesβ (grouped by host or role).
Every critical jump target reachable from that page in β€2 clicks without remembering IPs.
Link list can be updated without fragile one-off edits (manifest, template, or generator).
Open decisions (Phase 2)
Topic
Notes
Glances vs hybrid
Glances is strong per-host; βwhole labβ may mean Glances on LenovoMinis + NUC + ProxMox with a portal (Homer, Dashy, Heimdall, Homarr) that embeds or links. Pure Glances multi-server exists but compare ops cost vs a small static portal + Glances where you care most.
Hosting
Typically homelab-side (or Tailscale-only) β not the same trust boundary as the public status site.
Secrets in URLs
Management UIs often should not be in public git; prefer env-backed or encrypted manifest if URLs contain tokens (usually they should not β use SSO or Tailscale Serve).
Relationship to Phase 1
Phase 1 (public status)
Phase 2 (operator dashboard)
Host
VPS
Homelab / Tailscale
Audience
Users
You
Data
Synthetic / public checks
Richer metrics + deep links
Alerting
Informational; Kuma can still alert you
Kuma primary; dashboard is navigation
Suggested sequencing
Inventory user-facing hostnames and which are probe-able from the public internet (required for Phase 1).
Stand up VPS + DNS + TLS for status.samplayskeys.com.
Implement Phase 1 status page and curate components.
Status on VPS: βChecks pass from Oracle but users still canβt reach the appβ β split your connectivity vs user path (DNS, CDN, home IP change).
Operator dashboard: βI pasted every admin URL into gitβ β rotation and leak risk; prefer Tailscale MagicDNS names + no secrets in repo.
When you name concrete software for Phase 1 or 2, run a short Pre-Mortem before locking in.
Roadmap
Open questions, future work, and ideas for the homelab.
Open Questions
These need to be resolved as the build progresses.
Network Topology
Network segmentation between environments (VLANs) β defined in homelab-map.md
UnRaid NAS role in Prod β just media, or also app data?
Synology NAS role in Test β mirror of UnRaid structure, or different?
Backup strategy across environments
How does DevDocker access test data (if at all)?
Terraform + Ansible Boundary
Exactly which resources does Terraform manage?
Does Terraform manage the physical MiniPCs, or just VMs on ProxMox?
How do we bootstrap the Komodo Controller itself?
DMZ Strategy
DMZ VLAN defined (VLAN 5, 10.0.5.0/24) β see homelab-map.md
Decide on shared NIC vs dedicated seed box hardware
qBittorrent deployment (Docker on shared NIC or baremetal on seed box)
NFS export for shared storage (DMZ β UnRaid NAS)
Lock down firewall rules β strictly limit DMZ β Internal to NFS only
Monitoring & Alerting
Where does monitoring live? (Separate from workloads?)
Komodo has built-in alerting β is that sufficient?
Do we need a dedicated metrics/visualization stack (e.g. Prometheus/Grafana), or is Komodo + Uptime Kuma (+ existing logging) enough for the foreseeable future? (Prometheus/Grafana/Influx-style stack explicitly deferred β not pursuing yet.)
Secrets Management
How do secrets get into Komodo configs?
Komodo supports variables β are those sufficient for secrets?
Do we need external secrets management (Vault, etc.)?
Planned Workloads
Apps and stacks to deploy once infrastructure is ready.
Prod
Workload
Type
Priority
Plex
Stack
High
*arr suite (Sonarr, Radarr, Prowlarr, etc.)
Stack
High
Uptime Kuma
App
Medium
TBD
β
β
DMZ
Workload
Type
Priority
qBittorrent (Internet Archive torrent backups)
App
Medium
TBD
β
β
Test
Mirror of Prod where validation is needed.
Dev
Experimental containers, new apps being evaluated.
Future Ideas
Things to consider but not immediately necessary.
Multi-repo Strategy
Currently planning a single infrastructure/ repo. Could split into:
infra-terraform/ β Just Terraform
infra-ansible/ β Just Ansible
homelab-apps/ β Just Komodo configs
Tradeoff: More repos = more isolation but more complexity. Single repo is simpler to start.
CI/CD for Infra Repo
Lint Terraform/Ansible/TOML on PR
Plan Terraform changes on PR, apply on merge
Validate Komodo TOML syntax before merge
Automated Testing
Spin up ephemeral Test environment for PRs
Run smoke tests against deployed containers
Tear down after validation
Disaster Recovery
Document recovery procedures for each environment
Automate rebuild from scratch (Terraform + Ansible + Komodo)
Backup strategy for stateful data (Plex metadata, *arr databases, etc.)
Hardware Upgrades
Upgrade path for MiniPCs if more compute needed
GPU passthrough for Plex transcoding (currently planned for Prod MiniPC?)
JONSBO NAS - Prod, Tailscale container, mapped to LenovoMini 1
Synology NAS - Test, Tailscale installed, mapped to LenovoMini 2
ProxMox Host - Tailscale installed directly, several VMs available
VM 100 - Bluefin (remote work VM)
4 vCPU, 8GB RAM, 50GB disk (sdb / bluefin-lvm)
Tailscale installed directly
VM 101 - (reserved for Windows VM)
VM 103 - Fedora Server (dev VM)
8 vCPU, 16GB RAM, 50GB disk (nvme0n1 / nvme-lvm)
NVIDIA Quadro P5000 passthrough
“Debian Test”
Test VM, running debian, tools installed, used only for testing packages on Debian. Mostly stays powered off.
Docker VM
mirror of the test and prod VMs, running Komodo periphery, testing docker containers and workloads
Remaining resources will be used for testing out VM ideas. No remote windows machine is needed.
Intel NUC - Technically part of the prod environment, this runs the komodo instance that does the management for everything else. Additionally, it will be the main host that the ansible and terraform commands will be run from. It will have a GitHub runner installed to handle executing the management tasks.
Gaming PC - Not technically part of any environment (it will be part of a separate “user” environment) it will still function as my remote windows machine when needed.
NOTE: At some point, it may become worth it to leverage on of my spare laptops as a stand-in device for running an image for work/testing, but that is a future problem. For now, I will not include a machine to use as a “daily work” machine in my plans.
Unifi Zone-Based Firewall (ZBF) Guide
Overview
This guide walks through configuring the homelab network using UniFi’s Zone-Based Firewalls (ZBF), available in UniFi Network 9.0+.
ZBF shifts away from traditional LAN In/Out rules by grouping networks into Zones and defining Policies that dictate traffic flow between those zones. This is visualized in the Zone Matrix and is vastly superior for environment segmentation.
1. Define Custom Zones
By default, UniFi places all local networks into the built-in Internal zone. To segment our environments, we will create custom zones that perfectly map to our VLANs.
Go to Settings > Security > Firewall > Zones and create the following custom zones:
Zone Name
Assigned Network (VLAN)
Purpose
Prod
Prod (1)
Core services, Home Assistant
Test
Test (2)
Pre-production validation
Dev
Dev (3)
OCP and Sandbox nodes
IoT
IoT (4)
Cameras, smart plugs
DMZ
DMZ (5)
Public-facing services (qBittorrent)
User
User (10)
Laptops, phones, trusted devices
(Note: Ensure each VLAN is moved out of the default Internal zone and into its respective custom zone.)
Built-in Zones
Alongside our custom zones, the following built-in UniFi zones remain actively used:
Hotspot: Contains the Guest network (VLAN 20). UniFi automatically isolates this zone (blocks access to all other zones) while allowing internet access, perfectly mapping to guest network needs.
Gateway: Represents the router itself. Essential for all zones to access DHCP, DNS, and Gateway functions.
External: Represents the Internet.
2. Configure the Zone Matrix (Policies)
Instead of a linear list of “Drop” rules, ZBF uses a matrix (source zone on the Y-axis, destination zone on the X-axis). We define explicit policies for allowed paths and lock down the rest.
Go to Settings > Security > Firewall > Policy Table (or click the cells in the Zone Matrix) and configure the following policies:
Policy 1: HA Control over IoT
Allows Home Assistant to reach and control IoT devices.
Name: Allow HA to IoT
Action: Allow
Source Zone: Prod
Destination Zone: IoT
Source Device/IP:10.0.1.X (Home Assistant IP)
Auto Allow Return Traffic: Checked (Ensures IoT devices can respond to HA)
Policy 2: User Local Access to Prod (Optional for Latency)
Allows User VLAN devices fast local access to specific Prod services (bypassing Tailscale).
Name: Allow User to Prod Apps
Action: Allow
Source Zone: User
Destination Zone: Prod
Destination Port: Select specific ports (e.g., 8123 for HA, 32400 for Plex)
Auto Allow Return Traffic: Checked
Policy 3: Allow User to IoT (Chromecast/Casting)
Allows phones/laptops on User VLAN to cast media to Chromecasts and smart speakers on IoT VLAN.
Name: Allow User to IoT Casting
Action: Allow
Source Zone: User
Destination Zone: IoT
Destination Port:8008, 8009 (TCP - Chromecast control ports)
Auto Allow Return Traffic: Checked
Policy 4: Prod to Test Synchronization
Allows Prod services to sync with Test.
Name: Allow Prod to Test
Action: Allow
Source Zone: Prod
Destination Zone: Test
Auto Allow Return Traffic: Checked
Policy 5: Test to Dev Validation
Allows Test services to reach Dev.
Name: Allow Test to Dev
Action: Allow
Source Zone: Test
Destination Zone: Dev
Auto Allow Return Traffic: Checked
Policy 6: DMZ to External (Internet)
Allows DMZ services (e.g. qBittorrent) to reach the internet for torrent traffic and updates.
Name: Allow DMZ to External
Action: Allow
Source Zone: DMZ
Destination Zone: External
Auto Allow Return Traffic: Checked
Policy 7: External to DMZ (Specific Ports)
Allows incoming connections to DMZ services on required ports. Tightly scoped to only what’s needed.
Name: Allow External to DMZ Services
Action: Allow
Source Zone: External
Destination Zone: DMZ
Destination Port:6881 (TCP β torrent); 6881 (UDP β DHT); add more as new services are added
Auto Allow Return Traffic: Checked
Notes: This is the only inbound path from the internet into the lab. Review and restrict per-service.
Policy 8: DMZ to UnRaid NAS (Shared Storage)
Allows DMZ services to write downloaded content to the UnRaid NAS NFS export so Prod services can access it.
Name: Allow DMZ to UnRaid NFS
Action: Allow
Source Zone: DMZ
Destination Zone: Prod
Source Device/IP: DMZ subnet (10.0.5.0/24)
Destination IP: UnRaid NAS IP (10.0.1.X)
Destination Port:2049 (TCP β NFS)
Auto Allow Return Traffic: Checked
Notes: This is the only exception to DMZ β Internal block. Tightly scoped to a single IP + port.
3. Enable Multicast DNS (mDNS) for Discovery
For casting to work across VLANs, devices on the User VLAN must be able to discover the Chromecasts on the IoT VLAN. UniFi handles this via a built-in mDNS reflector.
Go to Settings > Networks > Global Network Settings (or the specific network settings) and ensure Multicast DNS is enabled for the User and IoT networks.
(Note: The UniFi Gateway automatically permits mDNS discovery traffic under the hood when this feature is turned on, so no explicit ZBF policy is needed for the discovery phaseβonly for the streaming connection, which Policy 3 handles).
4. Enforce Default Deny Between Custom Zones
To complete the segmentation, we must ensure that all other inter-zone traffic is blocked. UniFi’s matrix allows you to set the baseline interaction between zones.
For any zone pair that shouldn’t communicate (e.g., IoT to User, User to Dev, DMZ to any internal zone), create a catch-all block policy:
Name: Block Inter-Zone Traffic
Action: Block
Source Zone: Select all custom zones (Prod, Test, Dev, IoT, User, DMZ)
Destination Zone: Select all custom zones (Prod, Test, Dev, IoT, User)
Note: Place this policy at the very bottom of the Policy Table so our explicit “Allow” rules above take precedence. (The built-in Hotspot zone is natively blocked from other zones by UniFi, so it does not need to be included in this block list).
5. Port Forwarding β qBittorrent
Port forwarding is required for qBittorrent to accept incoming peer connections, which improves swarm connectivity and satisfies tracker connectability requirements.
Configure Port Forward Rule
Go to Settings > Security > Traffic Rules > Port Forwarding and create:
Field
Value
Notes
Name
qBittorrent Incoming
Enabled
True
From
Any
Accepts connections from any WAN IP
Port
51820
Pick a high ephemeral port (avoid 6881 β some ISPs throttle well-known BitTorrent ports)
Forward IP
10.0.5.X
DMZ machine’s static IP running qBittorrent
Forward Port
51820
Can differ from WAN port if needed, but matching is cleaner
Protocol
TCP & UDP
Both needed: TCP for peer data, UDP for DHT/magnet links
Log
False
Avoid log noise
qBittorrent Configuration
In qBittorrent, set the incoming port to match the forward port:
Settings > Connection > Listening Port:51820
Relationship to ZBF Policies
The port forward rule implicitly creates a firewall exception on the WAN interface.
ZBF Policy 7 (External β DMZ) still governs east-west traffic once the packet arrives at the DMZ VLAN.
If Policy 7’s destination port is locked to 6881, you must update it to match your chosen forward port (51820), or widen it to cover both.