Skip to main content

Homelab Infrastructure

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:

EnvironmentPurposeManagement
ProdProduction workloadsGitOps (Komodo)
TestPre-production validationGitOps (Komodo)
DevDevelopment, experimentation, OCP clusterMixed (Komodo for Docker, unmanaged for VMs/OCP)
UserPersonal workstationN/A
IoTSmart home, camerasIsolated
DMZPublic-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.


Overview

Orienting views of the whole lab.

DocumentContents
Homelab mapHardware, VLANs, services, network topology
Visual overview (HTML)Interactive layout (open locally or publish as static HTML)
Published graphicSame idea hosted on the public site (path may differ from this repo)

Network

Connectivity, edge, and remote access.

DocumentContents
TailscaleOverlay, remote access, Docktail, shared access model
UniFi configurationsSSIDs, VLANs, controller notes

Platform

Architecture of environments, hardware, and infrastructure-as-code layout.

DocumentContents
ArchitectureSystem diagrams, environment map, GitOps flow
EnvironmentsDetailed breakdown of each environment
HardwarePhysical inventory and specs
Planned repository structureInfrastructure repo layout
Leantime MCP serverLeantime MCP bridge setup and how it reaches the homelab instance

Observability

Public status and operator dashboards.

DocumentContents
Observability indexTopic entry point
Status site & operator dashboardstatus.samplayskeys.com, VPS hosting, single pane of glass

Planning

Decisions, roadmap, ADRs, and working notes.

DocumentContents
DecisionsKey decisions and rationale
ADRsArchitecture decision records (detailed, reference-friendly)
RoadmapOpen questions and future work
ScratchpadInformal notes and state

AI VM

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.

DocumentContents
ArchitectureVM specs, storage layout, GPU profile, provisioning flow
ADR 0001LlamaFarm as AI model management layer
Ansible playbook draftProvisioning roles (os-hardening, nvidia-cuda, persistent-storage, llamafarm-install, llamafarm-service)

Smart home (IoT)

DocumentContents
IoT documentationHome Assistant, Zigbee, automations, device decisions

Tooling stack

  • Infrastructure provisioning: Terraform
  • System configuration: Ansible
  • Container orchestration: Komodo (Core + Periphery agents)
  • Version control: Git (GitHub)

Key principles

  1. GitOps for managed environments β€” Desired state lives in Git; Komodo reconciles actual state to match
  2. Promotion via file movement β€” Move/copy TOML files between dev/ β†’ test/ β†’ prod/ directories
  3. Isolation for sandboxes β€” DevNode and DevOCP have no GitOps integration; they’re for experimentation
  4. Dedicated management plane β€” Komodo Controller runs on separate hardware (NUC) with no workloads
Sep 3, 2026

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.

Architecture

[opencode / MCP client]
        β”‚ MCP protocol (stdio)
        β–Ό
leantime-mcp bridge (local process, /opt/homebrew/bin/leantime-mcp)
        β”‚ HTTP JSON-RPC + Bearer token
        β–Ό
Leantime instance  http://leantime.samplayskeys.ts.net/mcp
        (served from fedora-dev, reached via Tailscale)

No n8n, no Zapier, no reverse proxy β€” the agent talks to Leantime directly through MCP tools.

Configuration

Registered in ~/.config/opencode/opencode.json under the mcp block:

"leantime": {
  "type": "local",
  "command": ["leantime-mcp"],
  "enabled": true,
  "environment": {
    "LEANTIME_SERVER_URL": "http://leantime.samplayskeys.ts.net/mcp",
    "LEANTIME_API_TOKEN": "tOInzazhMmw9gxFVQmSV1mYp7aykn0UbaJ9w4K3l"
  }
}

Bridge binary

  • leantime-mcp v1.6.5 is an npm global install (/opt/homebrew/bin/leantime-mcp).
  • It is a symlink to a local source checkout at ~/Src/leantime-mcp (upstream: github.com/leantime/leantime-mcp), built to dist/ via tsc.
  • Bin wrapper: bin/leantime-mcp.js imports dist/index.js and runs the server entrypoint.

Config resolution (src/config.ts)

  • No CLI args are passed from opencode.json; config comes from the environment variables.
  • LEANTIME_SERVER_URL β†’ server URL, LEANTIME_API_TOKEN β†’ token.
  • Defaults: Bearer auth, 3 retries with jittered exponential backoff, 5-minute tool cache.

Proxy behavior (src/proxy.ts)

  • 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.
Sep 3, 2026

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.

DocumentStatus
Per-environment Git branches via Komodo ResourceSyncAccepted
Baremetal Docker Host OS SelectionProposed
Container Runtime TradeoffsProposed
Skip local Docker registry, focus on git pipelineAccepted
Docker vs Kubernetes for an Orchestration platformInconclusive
Tailnet-per-environment with declarative node sharingRejected
Workload isolation in a single tailnet β€” access poolsProposed
Aug 26, 2026

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:

{"src": ["tag:prod"], "dst": ["tag:prod"], "ip": ["*:*"]}
{"src": ["tag:test"], "dst": ["tag:test"], "ip": ["*:*"]}
{"src": ["tag:dev"], "dst": ["tag:dev"], "ip": ["*:*"]}

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:

  1. 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).
  2. 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.

Drivers

DriverWeightNotes
Blast radius of a policy mistakeHighCurrent model: one file protects everything
Prod stability during Dev experimentsHighDev is where breakage is expected
Operational overhead (per-tailnet admin, keys, DNS)MediumLower than assumed β€” API-drivable
Dependence on an alpha featureHighDeclarative sharing is alpha + waitlist
Komodo GitOps flow across environmentsMediumController currently reaches all three
Observability / audit clarityMediumPer-tailnet audit is cleaner than tag filters
Churn to the existing Services layerMediumDNS suffixes, sidecar auth, per-tailnet httpsEnabled
Partner / shared access modelMediumAdversarial model already documented
CostLow–MediumOPEN: plan implications of additional tailnets

The two sharing mechanisms

Getting this distinction right is most of the decision.

Link-based sharingDeclarative node sharing
StatusGA (beta flag in docs), v1.4+Alpha, waitlist-gated
Unit of sharingOne machine β†’ one userPolicy-level, resource classes
Tags across the boundaryStrippedSupported via tag://<alias>/<tag>
Machine-to-machineNot supported β€” only users accept sharesExplicitly supported
QuarantineInbound-only by defaultGoverned by allowIncomingConnections; OPEN whether quarantine semantics still apply
SetupInvite link, out-of-band, manual acceptDouble opt-in policy edits on both tailnets
AutomatablePoorlyFully β€” policy file is GET/POST /api/v2/tailnet/-/acl
Group sync (SCIM/Google)n/aUnsupported β€” 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.

Shape of the config

Double opt-in, per the declarative node sharing docs. Receiving tailnet declares what may be referenced:

{
  "externalTailnets": {
    "prod": {
      "externalID": "<prod-tailnet-id>",
      "allowIncomingConnections": false,
      "allowExternalReferencesTo": ["tag:komodo-controller"]
    }
  }
}

Sharing tailnet accepts the relationship and writes the grant:

{
  "externalTailnets": {
    "dev": { "externalID": "<dev-tailnet-id>", "allowIncomingConnections": true }
  },
  "grants": [
    { "src": ["tag://dev/komodo-controller"], "dst": ["tag:host"], "ip": ["8120"] }
  ]
}

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.
  • Longer FQDNs and a documentation sweep to match.
  • Per-tailnet provisioning steps that don’t exist today: httpsEnabled PATCH, tagOwners, auth keys, policy push.
  • 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:

  1. Plan/cost implications of additional tailnets β€” moot here; relocated to ADR-0007.
  2. Waitlist timing for declarative node sharing β€” no longer gating this decision; still worth joining for ADR-0007.
  3. Quarantine semantics vs. allowIncomingConnections for declaratively shared nodes β€” unresolved; relocated to ADR-0007, where it actually matters.
  4. Operator identity/devices across three tailnets β€” moot; admin access stays in one tailnet, which is the point of the rejection.
  5. Komodo topology across a boundary β€” moot; controller and periphery agents stay in one tailnet.
  6. 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.
  7. 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.
  8. Migration order β€” moot; no migration.
  9. 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

Aug 26, 2026

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:

  1. 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.
  2. 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

ConcernADR-0006 (environment split)This ADR (pools)
Admin/Komodo pathsCut by the boundary β€” homelab manageability behind an alpha featureUntouched; all stay in the homelab tailnet
Existing Services layerRewritten across three tailnetsUntouched
Partner/guest accessComplicated by tailnet-qualified DNSLink-based sharing, unchanged from today
Alpha dependencyOn the critical management pathOn the deferred off-ramp only
Migration costThree tailnets, full grants rewriteZero now; the split is a future, triggered exercise
Blast radiusHypothetical (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

  1. 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.
  2. 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.
  3. Exit node β€” does workload need its own, or use none?
  4. Split trigger threshold β€” what concretely constitutes “outside resources managing elements”? Naming the threshold now makes the off-ramp decision unemotional later.
  5. 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

Aug 26, 2026

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:

  1. 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).
  2. 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

AxisDocker + KomodoKubernetes + ArgoCD
Runtime complexityLow; Compose, one socket, familiarHigh; control plane, CRDs, more failure modes
Declarative spin up/downPossible, but needs branch/merge hygiene (ADR 0001)Similar; kubectl apply / Git commit, controller reconciles, but follows a similar git architecture
Single pane of glassKomodo dashboard (Docker-only)ArgoCD dashboard (K8s-only)
Ecosystem / docsSmaller (Komodo)Ubiquitous (ArgoCD, CNCF)
Operational burdenLower set-up complexity, higher per-change ceremonyHigher standing complexity, lower per-change friction
Skills transfer to work with OCPDev OCP cluster already covers this separatelyDirect overlap with Red Hat stack
Details

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?

References

Aug 25, 2026

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:

  1. 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.
  2. Leverage existing pipeline investment β€” GitHub Actions, Ansible validation, and Komodo ResourceSync are already in place and can be extended to cover image governance.
  3. 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.
  4. 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:

  1. Pin images explicitly in Komodo TOML configs (e.g., nginx:1.25-alpine instead of latest) β€” this is the primary guardrail.
  2. 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
  3. Use Docker Hub rate-limit-friendly practices β€” pull once, cache locally via Docker’s built-in store, and pin versions.
  4. Revisit local registry later only if offline access or rate limits become actual problems.

Considered alternatives

ApproachSecurity EvaluationVersion PinningOfflineMaintenance
Local Docker registryManual (ad-hoc scanning)βœ… Config-basedβœ… YesLow-Medium
Docker Hub + pinningManual (ad-hoc scanning)βœ… Config-based❌ NoLow
Git pipeline + image scanningβœ… Automated (PR gate)βœ… PR-approved tagβœ… CI cacheMedium
Artifactory/Lightwellβœ… Scanning + promotionβœ… Pinningβœ… YesHigh

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.

References

Aug 19, 2026

ADR: Baremetal Docker Host OS Selection

Context

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:

  1. RPM/DNF-based β€” user preference, already familiar with the ecosystem.
  2. Komodo GitOps compatibility β€” Periphery agent must be able to manage container workloads.
  3. Low-maintenance β€” set once and forget; not something to constantly troubleshoot.
  4. Easily automatable β€” host provisioning should be reproducible via Ansible, including Periphery agent setup.

Options Considered

Host OS

OptionRelease CadenceSupport WindowNotes
Fedora Server~6mo~13moLatest kernel + tooling, frequent upgrades
Rocky / Alma LinuxMajor (9.x)~10yrRHEL-compatible, boring & stable
Fedora CoreOSAuto-updating streamsPer streamImmutable, no package management at runtime
CentOS StreamRolling between RHEL releasesContinuousBetween Fedora and RHEL

Container Runtime

FactorDockerPodman
Komodo Periphery supportFirst-class (native socket or API)Rough β€” community workarounds only (DOCKER_HOST to socket emulation)
RootlessAvailable (opt-in via dockerd-rootless-setuptool.sh)Built-in, rootless by default
ComposeDocker Compose v2podman-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)

  1. Fedora Server for the host OS β€” DNF-native, recent kernel, familiar tooling, easy Ansible provisioning.
  2. Docker (not Podman) for the container runtime β€” Komodo’s first-class support removes any reason to fight Podman compatibility.
  3. Rootful Docker β€” simplicity wins for a homelab with trusted workloads. No port restrictions, no privileged container limitations, no extra setup steps.
  4. 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:

roles/
  komodo-periphery/
    tasks/
      main.yml          # pull, create dirs, run container
    templates/
      periphery.config.toml.j2   # config template
    vars/
      main.yml          # defaults (core URL, port, etc.)

Tasks (main.yml)

- name: Ensure Periphery data directory exists
  ansible.builtin.file:
    path: /opt/periphery
    state: directory
    mode: '0755'

- name: Deploy Periphery container
  community.docker.docker_container:
    name: periphery
    image: "ghcr.io/moghtech/periphery:{{ periphery_version }}"
    restart_policy: unless-stopped
    network_mode: host
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"
      - "/opt/periphery:/config"
      - "/:/mnt/host:ro"
    env:
      PERIPHERY_CONFIG_PATH: /config/periphery.config.toml

Key choices:

  • 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.

Config Template (periphery.config.toml.j2)

[periphery]
port = {{ periphery_port | default(8120) }}
root_directory = "/mnt/host"

[[connections]]
address = "wss://{{ core_address }}/connection"
connect_as = "{{ inventory_hostname }}"
onboarding_key = "{{ periphery_onboarding_key }}"

The config template is rendered by Ansible with host-specific variables (core address, hostname, onboarding key), sourced from Ansible vault or group vars.

Ansible Playbook Structure

- hosts: docker_hosts
  become: yes
  roles:
    - role: docker-install        # dnf install docker, enable + start
    - role: komodo-periphery      # pull image, deploy container

Gotchas

  1. 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".

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

Jun 9, 2026

ADR: Container Runtime Tradeoffs β€” Docker (Rootful), Docker (Rootless), and Podman

Context

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.

The host OS decision is handled in ADR 0002: Baremetal Docker Host OS Selection.

Runtime Options

Docker (Rootful)

The classic model: dockerd runs as root, exposes /var/run/docker.sock.

AspectDetail
Socket/var/run/docker.sock β€” single, system-wide, owned by root:docker
Daemondockerd running as root β€” always-on, manages all containers
Root modelContainer processes run as root by default (can drop to user with USER in Dockerfile)
Komodo supportFirst-class β€” native socket access, all features work
Setup complexityMinimal β€” dnf install docker, start daemon, add user to docker group
ComposeNative docker compose v2 support

Docker (Rootless)

dockerd runs under a user namespace, exposes a user-scoped socket.

AspectDetail
Socket/run/user/$UID/docker.sock β€” per-user, still a single management endpoint
Daemondockerd-rootless running as the user β€” still a central daemon
Root modelContainers run in a user namespace β€” real root is not exposed, but root within the namespace
Komodo supportViable with configuration β€” DOCKER_HOST env pointed at rootless socket
Setup complexityModerate β€” dockerd-rootless-setuptool.sh, loginctl enable-linger, env setup
ComposeNative docker compose v2 β€” works within user namespace

Podman (Daemonless)

No central daemon. Each container is launched directly by the user’s Podman client.

AspectDetail
SocketNone by default β€” containers are child processes of the user’s shell
DaemonNo daemon β€” podman system service can emulate a Docker API socket on demand
Root modelRootless by default β€” no user namespace needed, containers run as the calling user
Komodo supportWorkarounds only β€” DOCKER_HOST to emulated socket, known bugs (stack status, network labels)
Setup complexityMinimal for CLI β€” dnf install podman. Complex for management tooling (need podman system service as a systemd unit)
Composepodman-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:

RuntimeManagement modelTooling friction
Docker (rootful)One socket, one daemon, everything worksZero β€” every tool speaks Docker API natively
Docker (rootless)One socket per user, one daemon per userLow β€” set DOCKER_HOST, same API
PodmanNo socket, no daemon, containers are child processesHigher β€” 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

CriterionDocker (rootful)Docker (rootless)Podman (daemonless)
Komodo compatibilityNativeConfigurableWorkarounds
Socket securityDangerous (root equiv.)Safer (user namespaced)None by default
Privileged containersFull supportBlockedBlocked (rootless)
Port <1024NativeWorkaround neededWorkaround needed
Systemd integrationManualManualNative (podman generate systemd)
cgroup managementFullLimitedLimited (rootless)
BuildsNativeNative (BuildKit)Native (Buildah)
Swarm modeSupportedBlockedN/A
Docker ComposeNativeNativePartial (podman-compose)
Multi-arch buildsNativeNativeNative (Buildah)

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
  • Management complexity increases β€” socket discovery, env vars, emulation layers required for Komodo
  • Ports <1024 need workarounds

References

Jun 9, 2026

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:

  1. 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.
  2. 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).
  3. 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

  1. 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).

  2. 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.
  3. 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.

  4. 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

AlternativeWhy not chosen (for now)
Prod pinned only by releases/tagsValuable later; deferred to reduce moving parts until branch steering is proven in daily use.
Separate repos per environmentDuplicates structure; conflicts with single-repo promotion story already documented.
Only ever sync from mainForces feature testing via copy/paste or temporary files outside Git flow; rejected.

References

May 12, 2026

Tailscale

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 VPNTailscale
Hub-and-spoke β€” all traffic through central serverMesh β€” direct device-to-device connections
Central server is bottleneck and single point of failureNo central infrastructure to fail
Complex setup (certificates, port forwarding, firewall rules)Zero-config in most cases
Requires exposing a port to the internetNAT traversal built-in, no exposed ports
VPN server needs to be always onCoordination server only for handshake, not traffic
All-or-nothing accessGranular 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

ApproachExposureGranularity
Host-basedEntire machine accessibleAll ports, all services
Subnet routingEntire network accessibleEverything on the subnet
ServicesSingle application endpointOne 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

NeedPattern
Users access this app, want centralizedDocktail (labels-based)
Per-service control, inbound + outboundScaleTail (sidecar pattern)
App needs tailnet access, no inboundOutbound-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.

Multi-Node Service Advertisement

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Prod-Node-1   β”‚     β”‚   Prod-Node-2   β”‚
β”‚                 β”‚     β”‚                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚     β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  Docktail β”‚  β”‚     β”‚  β”‚  Docktail β”‚  β”‚
β”‚  β”‚           β”‚  β”‚     β”‚  β”‚           β”‚  β”‚
β”‚  β”‚ advertisesβ”‚  β”‚     β”‚  β”‚ advertisesβ”‚  β”‚
β”‚  β”‚ "plex"    β”‚  β”‚     β”‚  β”‚ "plex"    β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β”‚     β”‚  β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚                       β”‚
         β–Ό                       β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  plex.tailnet.ts.net            β”‚
    β”‚  (routes to available node)     β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

HA Candidates

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

RoleDescriptionExample Users
userCan access consumer-facing application UIsPartner, guests
managerCan access app admin panels, not infrastructureβ€”
adminFull access including infrastructure managementOwner

Interface Separation

Interface TypeAccessible ToExamples
User Web UIAll tailnet usersPlex, Actual Budget, Jellyfin
App Admin Portalmanagers, adminsPlex admin, *arr settings
Infrastructure UIadmins onlyKomodo, Proxmox, router admin
SSH/Consoleadmins onlyAll hosts

Implementation Approach

ACLs enforce access tiers by:

  1. Grouping users into roles (groups in ACL policy)
  2. Tagging services by interface type
  3. 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.

Admin ──(subnet route)──▢ 10.0.50.0/24 (IoT VLAN) ──▢ Switch management UI
User  ──(blocked)──────▢ 10.0.50.0/24

This preserves the principle of least privilege: users access applications, admins access infrastructure.

Subnets vs Services

Access MethodUserManagerAdmin
Tailscale ServicesPer-ACLPer-ACLYes
Subnet RoutesNoNoYes

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:

  • Her devices may be compromised
  • Mistakes happen (accidental deletion, misconfiguration)
  • Principle of least privilege β€” only grant what’s needed

Partner Access (User Role)

ResourceAccessRationale
User Web UIs (Plex, Actual, etc.)YesShared apps
App Admin PortalsNoNo management need
Infrastructure UIsNoNo management need
SSH/ConsoleNoNo direct machine access
Subnet RoutesNoAdmin-only
File sharesLimited foldersOnly shared content

Environment-Based Protections

EnvironmentPartner Access
ProdUser Web UIs only
TestNo
DevNo

Prod services exposed to partner are explicitly whitelisted as user-tier services. Everything else is deny-by-default.


Docktail Implementation

Docktail runs as an independent container and advertises other containers as Tailscale Services based on Docker labels.

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚            Docker Host                  β”‚
β”‚                                         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
β”‚  β”‚  Docktail   β”‚    β”‚  App        β”‚     β”‚
β”‚  β”‚             │◀───│  Container  β”‚     β”‚
β”‚  β”‚             β”‚    β”‚             β”‚     β”‚
β”‚  β”‚ tailscale   β”‚    β”‚ label:      β”‚     β”‚
 β”‚  β”‚  connected   β”‚    β”‚ docktail.*  β”‚     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
β”‚         β”‚                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          β”‚
          β–Ό
    Tailscale Service
    (MagicDNS name)

Docker Labels

services:
  actual-budget:
    image: actualbudget/actual-server
    labels:
      - "docktail.service.enable=true"
      - "docktail.service.name=actual"
      - "docktail.service.port=5006"

Docktail advertises the service at actual.tailnet.ts.net.

Why Docktail

  • No Tailscale in every container image
  • Declarative β€” labels define what’s exposed
  • Works with Komodo GitOps β€” labels are in compose files
  • Centralized Tailscale management per Docker host
  • Services get proper MagicDNS names

Installation Strategy by Node Type

NodeTailscale InstallationService ExposureExit Node
Komodo ControllerDirect installAdvertised service (admin-only)No
ProxMox HostDirect installAdvertised service (admin-only)No
OCP ClusterTailscale OperatorKubernetes-nativeNo
Docker HostsHost + DocktailServices via DocktailNo
Exit NodeDirect installNoneYes

Komodo Controller (NUC)

Direct Tailscale installation. Advertises as a service for admin access only.

  • Komodo UI (admin-only service)
  • SSH access (admin-only)
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --ssh --advertise-tags=tag:admin-infra

ProxMox Host

Direct Tailscale installation. Admin-only access.

  • ProxMox web UI
  • SSH access to host
  • VM console access

OCP Cluster (Dev)

Uses the Tailscale Kubernetes Operator for Kubernetes-native integration:

  • Ingress via Tailscale
  • Service exposure with proper tagging
  • Pod identity

Docker Hosts (Prod/Test/Dev)

Tailscale on the host for SSH, Docktail for service advertisement:

  • Host Tailscale provides admin SSH access
  • Docktail advertises container services with appropriate tags
  • Services tagged for user vs admin access
services:
  docktail:
    image: marvinvr/docktail
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - TS_AUTHKEY=${TAILSCALE_AUTHKEY}

Dedicated Exit Node

A minimal node dedicated to exit node functionality:

  • Tailscale with --advertise-exit-node
  • No Docktail, no services, no subnet routes
  • Provides “at home” internet access when traveling
  • Isolated from internal service exposure

Decisions

  • Docktail for service advertisement β€” containers that need to be reachable get Docktail labels
  • ScaleTail sidecars β€” per-service Tailscale sidecars for inbound and outbound tailnet access
  • Outbound-only sidecars β€” lightweight sidecars for containers that need tailnet access but not inbound
  • Subnet routes are admin-only β€” ACLs restrict subnet access to admin role
  • Dedicated exit node β€” isolate exit node from service/subnet exposure

Open Questions

  • Which node becomes the dedicated exit node? (Candidate: lightweight VM or spare device)
  • Tagging convention for services β€” how to tag user vs admin interfaces in Docktail labels?
  • HA service list β€” which services warrant multi-node advertisement?
  • Grants policy β€” built as [[tailscale-grants]]

Aug 3, 2026

ADR: Podman vs Docker for Container Runtime

Status

Draft

Context

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)
  • Orchestration integration (Kubernetes, Compose, Swarm)
  • Licensing and corporate governance

Decision

Defer β€” no single winner. The choice depends on the deployment context:

ContextRecommendationWhy
Local dev (macOS)Docker DesktopBetter UX, tighter IDE integration, mature volume mounts
Local dev (Linux)PodmanNative rootless, no daemon tax, distro-packaged
CI/CD pipelinesDocker-in-DockerIndustry standard, broadest runner support
Homelab serverPodmanSystemd integration, rootless by default, lighter footprint
Kubernetes devPodmanBuilt-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

ComponentMinimumRecommendedNotes
vCPU4 cores8+ coresInference benefits from more cores for prompt processing
RAM16 GB32 GB~16 GB for OS + LlamaFarm services + 8B model inference + KV cache
Root disk40 GB64 GBOS + LlamaFarm service binaries (~3-5 GB) + swap space
Model/data disk100 GB256 GBPersistent volume for model weights + vector store + RAG data
GPUQuadro P5000Quadro P500016 GB GDDR5X VRAM β€” comfortable for 7B-13B quantized models

Quadro P5000 Profile

SpecValue
VRAM16 GB GDDR5X
CUDA cores2560 (Pascal architecture β€” compute capability 6.1)
CUDA toolkit11.x or 12.x (Pascal supports both; use 12.x for upstream llama.cpp compatibility)
PCIeGen3 x16 β€” ensure host has an available slot and IOMMU group isolation
TDP180 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)

Size estimate with a typical setup:

ItemSize
PyApp service binaries~1-2 GB
Qwen3-8B (Q4_K_M GGUF)~5 GB
Qwen3-1.7B (Q4_K_M GGUF)~1 GB
all-MiniLM-L6-v2 (embeddings)~80 MB
Chroma vector store (RAG)grows with ingested data (MBs to GBs)
Total~8-12 GB excluding RAG data

Software Stack

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    User / Client                      β”‚
β”‚  (Tailscale SSH, chat UI at :14345, REST API)        β”‚
β”‚           Direct HTTP β€” no reverse proxy             β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚              LlamaFarm Server (:14345)                β”‚
β”‚           OpenAI-compatible REST API                  β”‚
β”‚           Designer Web UI                             β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                     β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”‚
β”‚   β”‚ RAG Worker   β”‚   β”‚ Universal    β”‚              β”‚
β”‚   β”‚ (Celery)     β”‚   β”‚ Runtime      β”‚              β”‚
β”‚   β”‚              β”‚   β”‚ (:11540)     β”‚              β”‚
β”‚   β”‚ Parsing      β”‚   β”‚ HF Models    β”‚              β”‚
β”‚   β”‚ Chunking     β”‚   β”‚ GGUF via     β”‚              β”‚
β”‚   β”‚ Embedding    β”‚   β”‚ llama.cpp   β”‚              β”‚
β”‚   β”‚ ChromaStore  β”‚   β”‚ OCR/Classify β”‚              β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜              β”‚
β”‚                              β”‚                       β”‚
β”‚                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚
β”‚                    β”‚   NVIDIA CUDA      β”‚            β”‚
β”‚                    β”‚   Quadro P5000    β”‚            β”‚
β”‚                    β”‚   16 GB VRAM      β”‚            β”‚
β”‚                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
|              Debian (latest stable)                   |
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
|              Hypervisor (Proxmox/ESXi)               |
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Provisioning Flow

Day 0: Hypervisor

  1. Pass through Quadro P5000 to VM via IOMMU group isolation
  2. Allocate vCPUs, RAM, root disk
  3. Attach persistent data volume (NFS or secondary vDisk)
  4. Boot Debian netinstall

Day 0: Ansible

ansible-playbook -i inventory/ai-vm playbooks/ai-vm.yml

The playbook:

  1. OS hardening: fail2ban, unattended-upgrades, firewall (ports 14345, 11540 restricted to Tailscale IPs)
  2. NVIDIA driver + CUDA toolkit: pin to version compatible with PyTorch in LlamaFarm
  3. Persistent volume mount: format if needed, mount at /var/lib/llamafarm, symlink ~/.llamafarm and ~/.cache/huggingface
  4. Install LlamaFarm CLI: curl -fsSL ... | bash
  5. Create systemd unit for LlamaFarm services
  6. Verify: lf version, nvidia-smi, CUDA test; access Designer UI at http://ai-vm:14345

Day 1: LlamaFarm

lf init my-ai-vm
lf start                        # starts server + RAG worker + Universal Runtime
lf chat "Hello"                 # verify model works (pulls on first request)
lf datasets create ...          # ingest documents for RAG

Model Configuration

# ~/.llamafarm/projects/default/my-ai-vm/llamafarm.yaml
version: v1
name: my-ai-vm
namespace: default

runtime:
  default_model: balanced

  models:
    fast:
      provider: universal
      model: Qwen/Qwen2.5-1.5B-Instruct
      base_url: http://127.0.0.1:11540/v1

    balanced:
      provider: universal
      model: unsloth/Qwen3-8B-GGUF:Q4_K_M
      base_url: http://127.0.0.1:11540/v1
      extra_body:
        n_ctx: 4096
        n_gpu_layers: -1
        flash_attn: true

    embedder:
      provider: universal
      model: sentence-transformers/all-MiniLM-L6-v2
      base_url: http://127.0.0.1:11540/v1
      model_type: embedding

rag:
  databases:
    - name: main_db
      type: ChromaStore
      default_embedding_strategy: default_embeddings
      default_retrieval_strategy: semantic_search
      embedding_strategies:
        - name: default_embeddings
          type: UniversalEmbedder
          config:
            model: sentence-transformers/all-MiniLM-L6-v2
            base_url: http://127.0.0.1:11540/v1
      retrieval_strategies:
        - name: semantic_search
          type: BasicSimilarityStrategy
          config:
            top_k: 5

Disposability Strategy

The VM can be destroyed and recreated with zero data loss:

WhatLives whereSurvives VM destroy?
OS + lf binaryRoot diskNo β€” `curl
Model weights~/.cache/huggingface/ on persistent volumeYes
Vector DB, project config, session~/.llamafarm/ on persistent volumeYes
Ansible inventory + playbooksAnsible control node (workstation or homelab management box)Yes
llamafarm.yamlPersistent volume + version-controlled in Ansible repoYes

Recovery sequence:

  1. Create VM, attach persistent volume
  2. ansible-playbook playbooks/ai-vm.yml
  3. lf init my-ai-vm && lf start
  4. lf chat β€” works immediately, models already cached

Total time: ~5-15 minutes (mostly apt updates + NVIDIA driver compile/dkms).

Service Management

Service managed via systemd:

[Unit]
Description=LlamaFarm AI Platform
After=network.target

[Service]
Type=simple
User=ai
Group=ai
Environment=LF_DATA_DIR=/var/lib/llamafarm
Environment=TRANSFORMERS_CACHE=/var/lib/llamafarm/cache
WorkingDirectory=/var/lib/llamafarm/projects/default/my-ai-vm
ExecStart=/usr/local/bin/lf start --daemon
Restart=on-failure
RestartSec=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

WhatFrequencyMethod
~/.cache/huggingface/ (models)Once (re-downloadable)Backup only if slow link. Models are cachable artifacts.
~/.llamafarm/projects/ (configs + vector DB)Dailyrclone to NAS (or any S3-compatible target)
Ansible playbooksOn changeGit β€” 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.


Environment Map

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        KOMODO CONTROLLER (NUC)                              β”‚
β”‚                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                            β”‚
β”‚                     β”‚ Komodo Core  β”‚  MongoDB  β”‚                            β”‚
β”‚                     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                            β”‚
β”‚                            β”‚ manages                                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚                    β”‚                    β”‚
        β–Ό                    β–Ό                    β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      GITOPS MANAGED ENVIRONMENTS                            β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚        PROD         β”‚ β”‚        TEST         β”‚ β”‚          DEV          β”‚  β”‚
β”‚  β”‚                     β”‚ β”‚                     β”‚ β”‚                       β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚ β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚ β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚  β”‚
β”‚  β”‚  β”‚  Prod MiniPC  β”‚  β”‚ β”‚  β”‚  Test MiniPC  β”‚  β”‚ β”‚  β”‚ DevDocker VM    β”‚  β”‚  β”‚
β”‚  β”‚  β”‚ [Periphery]   β”‚  β”‚ β”‚  β”‚ [Periphery]   β”‚  β”‚ β”‚  β”‚ (on ProxMox)    β”‚  β”‚  β”‚
β”‚  β”‚  β”‚               β”‚  β”‚ β”‚  β”‚               β”‚  β”‚ β”‚  β”‚ [Periphery]     β”‚  β”‚  β”‚
β”‚  β”‚  β”‚ β€’ Plex        β”‚  β”‚ β”‚  β”‚ β€’ Plex (test) β”‚  β”‚ β”‚  β”‚                 β”‚  β”‚  β”‚
β”‚  β”‚  β”‚ β€’ *arr Suite  β”‚  β”‚ β”‚  β”‚ β€’ Test Apps   β”‚  β”‚ β”‚  β”‚ β€’ Experimental  β”‚  β”‚  β”‚
β”‚  β”‚  β”‚ β€’ ...         β”‚  β”‚ β”‚  β”‚               β”‚  β”‚ β”‚  β”‚   containers    β”‚  β”‚  β”‚
β”‚  β”‚  β”‚               β”‚  β”‚ β”‚  β”‚               β”‚  β”‚ β”‚  β”‚                 β”‚  β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚ β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚ β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  β”‚
β”‚  β”‚         β–²           β”‚ β”‚         β–²           β”‚ β”‚                       β”‚  β”‚
β”‚  β”‚         β”‚ NFS/SMB   β”‚ β”‚         β”‚ NFS/SMB   β”‚ β”‚                       β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚ β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚ β”‚                       β”‚  β”‚
β”‚  β”‚  β”‚  UnRaid NAS   β”‚  β”‚ β”‚  β”‚ Synology NAS  β”‚  β”‚ β”‚                       β”‚  β”‚
β”‚  β”‚  β”‚   (Media)     β”‚  β”‚ β”‚  β”‚ (Test Data)   β”‚  β”‚ β”‚                       β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚ β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚ β”‚                       β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                                             β”‚
β”‚  Promotion path: DEV ──────────────▢ TEST ──────────────▢ PROD              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     UNMANAGED / SANDBOX ENVIRONMENTS                        β”‚
β”‚                                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚     DEVNODE (ProxMox VMs)          β”‚  β”‚    DEVOCP (Isolated)          β”‚  β”‚
β”‚  β”‚                                    β”‚  β”‚                               β”‚  β”‚
β”‚  β”‚  β€’ Test VM 1                       β”‚  β”‚  3x MiniPC Cluster:           β”‚  β”‚
β”‚  β”‚  β€’ Test VM 2                       β”‚  β”‚  β€’ Control Plane              β”‚  β”‚
β”‚  β”‚  β€’ ...                             β”‚  β”‚  β€’ Worker 1                   β”‚  β”‚
β”‚  β”‚                                    β”‚  β”‚  β€’ Worker 2                   β”‚  β”‚
β”‚  β”‚  (bugfixes, themes, ad-hoc)        β”‚  β”‚                               β”‚  β”‚
β”‚  β”‚                                    β”‚  β”‚  (OpenShift - work projects)  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                                             β”‚
β”‚  No Komodo. No GitOps. Just sandboxes.                                      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key Points

  • 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.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    Developer     β”‚
β”‚                  β”‚
β”‚  Edit TOML or    β”‚
β”‚  Compose file    β”‚
β”‚        β”‚         β”‚
β”‚        β–Ό         β”‚
β”‚    Git Push      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   GitHub Repo    β”‚
β”‚                  β”‚
β”‚  Repo Updated    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β”‚ (polled by)
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  KOMODO CONTROLLER                   β”‚
β”‚                                                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚   Komodo   │───▢│  Fetch latest from Git      β”‚   β”‚
β”‚  β”‚    Core    β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚  β”‚            β”‚                   β”‚                  β”‚
β”‚  β”‚  (polling) β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚            β”‚    β”‚  Diff against current state β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                   β”‚                  β”‚
β”‚                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚                    β”‚      Changes detected?      β”‚   β”‚
β”‚                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                          No β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” Yes        β”‚
β”‚                             β”‚           β”‚            β”‚
β”‚                          (loop)  β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚                                  β”‚ deploy: true? β”‚   β”‚
β”‚                                  β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                 No β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” Yes β”‚
β”‚                                    β”‚           β”‚     β”‚
β”‚                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β” β”‚
β”‚                    β”‚ Alert: Pending    β”‚ β”‚  Auto   β”‚ β”‚
β”‚                    β”‚ (manual approval) β”‚ β”‚ Deploy  β”‚ β”‚
β”‚                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β”‚
β”‚                              β”‚                β”‚      β”‚
β”‚                              β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                       β”‚
                                       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    TARGET NODE                       β”‚
β”‚                                                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  Periphery  │──▢│ Pull Image  │──▢│  Start/   β”‚   β”‚
β”‚  β”‚    Agent    β”‚   β”‚             β”‚   β”‚  Update   β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                            β”‚         β”‚
β”‚                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚                              β”‚    Health Check     β”‚ β”‚
β”‚                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                           Healthy β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” Fail β”‚
β”‚                                   β”‚           β”‚      β”‚
β”‚                           β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”‚
β”‚                           β”‚  Running  β”‚ β”‚  Alert/  β”‚ β”‚
β”‚                           β”‚           β”‚ β”‚ Rollback β”‚ β”‚
β”‚                           β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                  β”‚
                                  β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚               CONTINUOUS RECONCILIATION              β”‚
β”‚                                                      β”‚
β”‚      Komodo monitors ──▢ Drift detected? ──▢ Fix     β”‚
β”‚            β–²                                  β”‚      β”‚
β”‚            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Flow Explanation

  1. Developer edits TOML or Compose files and pushes to GitHub
  2. Komodo Core polls GitHub on a configured interval
  3. Diff detection compares Git state to current deployed state
  4. Deployment decision:
    • If deploy: true in the resource config β†’ auto-deploy
    • If deploy: false β†’ alert about pending changes, wait for manual approval
  5. Periphery Agent on the target node pulls the image and starts/updates the container
  6. Health check determines success or triggers alerts/rollback
  7. Continuous reconciliation monitors for drift and corrects it automatically

Hardware Summary

ComponentRoleEnvironment
Intel NUCKomodo ControllerProd (management plane)
MiniPC (Prod)Container hostProd
UnRaid NASMedia storageProd
MiniPC (Test)Container hostTest
Synology NASTest storageTest
ProxMox ServerVM host (DevDocker, DevNode)Dev
3Γ— MiniPC ClusterOpenShiftDev

Network Topology

To be documented β€” network segmentation, VLANs, access controls between environments.

Architecture Decision Records β€” AI VM Management

ADRs here capture decisions for the AI VM homelab project.

#TitleStatus
0001LlamaFarm as AI model management layerProposed

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:

  • Model lifecycle (download, serve, switch, retire)
  • RAG pipeline (ingest documents, chunk, embed, query)
  • Unified API surface (OpenAI-compatible endpoint for all models)
  • Reproducible configuration (infra-as-code for AI, not just the VM)

Decision Drivers

  1. Disposable VM model β€” the VM should be recreatable from Ansible + persistent data volumes without manual recovery steps.
  2. Local-first, offline-capable β€” the AI layer must work without internet after models are cached.
  3. GPU passthrough β€” quad-port P5000 needs CUDA support with minimal friction.
  4. Config-driven, not script-driven β€” model selection, prompt templates, and RAG pipelines should live in version-controlled config files, not shell scripts.
  5. 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.

  • Pro: Config-driven (llamafarm.yaml), unified API, multi-model switching, built-in RAG, MCP support, no-container-required.
  • Con: Pre-1.0 project (v0.0.34, YC W22), active development, small community.

Option 3: Kubernetes + Kubeflow / KServe

Run a lightweight K8s cluster on the VM, deploy models via KServe or similar.

  • Pro: Production-grade scaling, model canarying.
  • Con: Massive operational overhead for a single-VM homelab. K8s on a single node adds complexity with zero benefit at this scale.

Option 4: Docker Compose stack (Ollama + Open WebUI + Qdrant + custom API wrapper)

Orchestrate individual OSS components via Compose.

  • Pro: Each component is mature individually.
  • Con: Glue code for integration, more surface area to maintain, no single config file.

Decision

Adopt LlamaFarm as the AI model management layer, running natively (no containers) on a Debian VM with Ansible for infrastructure provisioning.

Ansible handles:

  • OS hardening, NVIDIA driver + CUDA toolkit installation
  • Persistent storage mount points
  • LlamaFarm CLI installation (lf) via curl | bash
  • $LF_DATA_DIR and $TRANSFORMERS_CACHE redirection to persistent volumes
  • systemd unit for LlamaFarm services
  • Reverse proxy (nginx) with TLS termination

LlamaFarm handles:

  • Model downloading, caching, and serving
  • Multi-model configuration (fast vs powerful, local vs remote)
  • RAG pipeline (document parsing, embedding, retrieval)
  • OpenAI-compatible REST API
  • MCP tool integration

Consequences

Positive

  • 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

RiskImpact
Pre-1.0 stabilityAPI/config changes between releases; may need to pin versions.
Small community833 GitHub stars, pre-1.0. Less troubleshooting help available compared to Ollama or Ansible.
PyApp binary sizeService binaries bundle Python runtimes (~1-2 GB total for server + rag + runtime).
YC-backed startupOpen-source today, but direction could shift. Fork as mitigation.
Dependency chainPython + TypeScript + Go + Nx monorepo. More moving parts than Ollama alone.
CUDA compatibilityRequires matching CUDA toolkit version with PyTorch / llama.cpp. Upstream already dropped CUDA 11 (b7694+).

Mitigations

MitigationDetail
Pin LlamaFarm versionUse 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 onlyAnsible provisions the VM and installs lf; LlamaFarm’s yaml is the AI config. Clear boundary reduces coupling.
Monitor upstreamKeep an eye on breaking changes before applying lf upgrades. Test in a disposable LXC first if uncertain.
NVIDIA driver pinningPin CUDA toolkit version in Ansible to match LlamaFarm’s PyTorch requirement.

References

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:

OptionProsCons
K3s + ArgoCDFamiliar with ArgoCD, true GitOps, industry standardK8s operational complexity, more failure points, overkill for homelab
KomodoDocker-native, ~2.5Γ— less config, uses familiar Compose conceptsNewer tool, less ecosystem
Ansible PullUses existing skills, simpleDIY drift detection, more manual work
Portainer BusinessHas GitOps featuresPaid, vendor lock-in

Decision rationale:

  1. Complexity: Komodo config is ~67 lines for a Plex stack vs ~176 lines for equivalent K8s manifests
  2. Risk tolerance: K3s in production homelab felt risky β€” more moving parts, less familiar failure modes
  3. Concept alignment: Docker Compose is already known; Komodo extends it rather than replacing it
  4. 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.

Full rationale, consequences, and mitigations: ADR β€” Per-environment Git branches via Komodo ResourceSync.


References

FoxxMD β€” Migrating to Komodo (Nov 2024, updated Aug 2025)

URL: https://blog.foxxmd.dev/posts/migrating-to-komodo/#create-komodo-periphery-agents

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.

VLAN: 10.0.1.0/24

Hardware:

  • Compute: MiniPC (LenovoMini 1) running Docker containers
  • Storage: UnRaid NAS (media storage: movies, shows, anime)
  • Management: Intel NUC (Komodo Controller)

Workloads:

  • Plex Media Server
  • *arr suite (Sonarr, Radarr, Prowlarr, etc.)
  • Other production services (see homelab-map.md for full list)

Network access: UnRaid NAS provides NFS/SMB shares to the MiniPC for media access.


Test

Purpose: Pre-production validation β€” test changes before promoting to Prod.

VLAN: 10.0.2.0/24

Hardware:

  • Compute: MiniPC (LenovoMini 2) running Docker containers
  • Storage: Synology NAS

Workloads: Mirrors Prod where needed for testing. Not all Prod services need a Test instance.

Promotion: Validated apps/configs move from Test β†’ Prod by copying/moving TOML files.


Dev

Purpose: Development, experimentation, and work-related projects.

VLAN: 10.0.3.0/24

Hardware:

  • ProxMox Host β€” Runs VMs for Docker and sandbox work
    • DevDocker VM β€” Komodo-managed Docker containers (promotion path to Test β†’ Prod)
    • DevNode VMs β€” Unmanaged sandbox for testing bugfixes, themes, OS-level changes
  • MiniPC cluster (LenovoMini 3–5) β€” OpenShift cluster for work-related projects

Managed vs Unmanaged:

NodeKomodo ManagedPurpose
DevDocker VMYesContainer dev with promotion path
DevNode VMsNoThrowaway VM sandbox
OCP ClusterNoOpenShift for work projects

Note: The OCP cluster uses OpenShift’s built-in GitOps (pulls from Git) but is not managed by Komodo.


Other Environments

These are network segments without Komodo-managed workloads.

User

Purpose: Personal workstation.

VLAN: 10.0.10.0/24

Hardware: Gaming PC

Isolation: Isolated from Prod, Test, Dev, and IoT.


IoT

Purpose: Smart home devices and cameras.

VLAN: 10.0.4.0/24

Hardware: Reolink cameras, Google Home devices

Isolation: Fully isolated from all other VLANs. Cannot reach Prod, Test, or Dev.


DMZ

Purpose: Public-facing services that need internet exposure β€” qBittorrent for Internet Archive torrent backups, potentially other public endpoints.

VLAN: 10.0.5.0/24

Hardware: Two deployment options:

Option A β€” Shared NICs via VLAN tagging:

  • DMZ containers/VMs run on existing hosts (LenovoMini 1, ProxMox) with the DMZ VLAN tagged on a shared physical NIC
  • qBittorrent runs as a Docker container on LenovoMini 1 with a DMZ-network interface
  • Lower cost, no additional hardware, but shares failure domain with Prod/Dev

Option B β€” Dedicated seed box:

  • A dedicated MiniPC on the DMZ VLAN, physically isolated from all other environments
  • Runs qBittorrent (and future DMZ services) on a baremetal OS or minimal Docker setup
  • Higher isolation, no shared attack surface with Prod/Dev, but additional hardware cost

Shared Storage:

  • DMZ services write downloaded content to an NFS export on the UnRaid NAS (Prod VLAN)
  • A specific firewall exception allows DMZ β†’ UnRaid NAS on NFS port 2049
  • Prod services (Plex, *arr) access the same export for processing β€” no data transfer through DMZ needed beyond the initial write

Firewall:

  • DMZ β†’ External: Allowed (torrent traffic, updates)
  • External β†’ DMZ: Restricted to specific ports (torrent DHT/TCP)
  • DMZ β†’ Internal: Blocked by default. Controlled exception for DMZ β†’ UnRaid NAS (NFS)

Environment Comparison

AspectProdTestDevUserIoTDMZ
VLAN10.0.1.0/2410.0.2.0/2410.0.3.0/2410.0.10.0/2410.0.4.0/2410.0.5.0/24
Komodo managedYesYesPartialNoNoNo
Promotion path—→ Prod→ Test———
Primary runtimeDockerDockerDocker, VMs, OCPDesktopEmbeddedDocker / baremetal

Homelab Hardware

Overview

Inventory of physical machines, virtual machines, and network-attached storage in the homelab.


Physical Machines

NameHardware SpecsRole & EnvironmentOSNotes
LenovoMini 18th Gen i7, NVIDIA GPU, 16GB RAMProd Docker hostFedoraKomodo periphery, Tailscale
LenovoMini 27th Gen i5, NVIDIA GPU, 16GB RAMTest Docker hostFedoraKomodo periphery, Tailscale
LenovoMini 3i5, 16GB RAMDev (OCP node 1)β€”Tailscale Operator
LenovoMini 4i5, 16GB RAMDev (OCP node 2)β€”Tailscale Operator
LenovoMini 5i5, 16GB RAMDev (OCP node 3)β€”Tailscale Operator
JONSBO NAS6th Gen i7, 16GB RAM, NVIDIA GPU, 10TB (8TB usable)Prod NASβ€”Tailscale container, mapped to LenovoMini 1
Intel NUCCeleron CPU, 8GB RAMProd managementβ€”Komodo instance, GitHub runner, Tailscale
ProxMox HostRyzen 3700, 16GB RAM, NVIDIA GPUMixedProxMoxTailscale, hosts VMs below
Synology NAS4GB RAM, 6TB storageTest NASβ€”Tailscale, mapped to LenovoMini 2
Gaming PCRyzen 5700, 16GB RAM, NVIDIA GPUUser (future)WindowsRemote Windows access

Virtual Machines

Hosted on ProxMox (Tailscale installed on ProxMox host):

NameOSRole & EnvironmentNotes
Remoting VMFedoraRemote workTailscale, dotfiles, tools
Debian TestDebianPackage testingPowered off most of the time
Docker VMβ€”Docker testingKomodo periphery, mirrors test/prod

Networking

  • Firewall/Router: Unifi Dream Machine (UDM)
  • Switch: Unifi 16-port PoE
  • Uplink: GFiber (1Gbps symmetrical)
  • Overlays: Tailscale for remote management/access

Homelab Map

Environments

EnvironmentPurposeSubnetHardware & Services
ProdProduction services10.0.1.0/24LenovoMini 1, UnRaid NAS, Intel NUC, Komodo, Docker workloads
TestStaging and validation10.0.2.0/24LenovoMini 2, Synology NAS, Komodo periphery
DevDevelopment & experimentation10.0.3.0/24ProxMox Host (DevDocker VM, DevNode VMs), LenovoMini 3–5 (OCP cluster), Tailscale Operator
UserPersonal workstation10.0.10.0/24Gaming PC
IoTSmart home & cameras10.0.4.0/24Reolink cameras, Google Home
DMZPublic-facing services10.0.5.0/24Shared NIC on existing hosts OR dedicated seed box; qBittorrent; shared storage mount

VLANs & Subnets

VLANDescriptionSubnetFirewall RulesUnifi SSID / Notes
1Prod10.0.1.0/24Allow all internal; restrict IoT β†’ ProdWired only
2Test10.0.2.0/24Allow all internal; restrict IoT β†’ TestWired only
3Dev10.0.3.0/24Allow all internalWired only
4IoT10.0.4.0/24Isolate from prod/test/devIoT (WPA2)
5DMZ10.0.5.0/24DMZ β†’ External allowed (specific ports); External β†’ DMZ restricted; DMZ β†’ all internal blocked (except DMZ β†’ UnRaid NFS for shared storage)Wired only
10User10.0.10.0/24Isolate from prod/test/dev/IoTHome (WPA3)

Network Topology

GFiber (1Gbps) β†’ Unifi UDM β†’ Unifi 16-port PoE Switch
β”‚
β”œβ”€β”€ LenovoMini 1 (Prod Docker) β†’ UnRaid NAS
β”œβ”€β”€ LenovoMini 2 (Test Docker) β†’ Synology NAS
β”œβ”€β”€ ProxMox Host (Dev) β†’ DevDocker VM, DevNode VMs
β”œβ”€β”€ LenovoMini 3–5 (Dev OCP cluster)
β”œβ”€β”€ Intel NUC (Prod management / Komodo Controller)
β”œβ”€β”€ Gaming PC (User)
β”œβ”€β”€ IoT VLAN (Reolink, Google Home)
└── DMZ VLAN (qBittorrent, shared storage mount)

Tailscale Overlay: All nodes

Services & Dependencies

Tailscale Services (on all Docker hosts):

  • docktail (logging)
  • uptimekuma (monitoring)

Dev Services (ProxMox DevDocker VM):

  • Forgejo (Git)
  • tsflow (CI/CD)
  • Experimental containers

Prod Services shared with Dev (LenovoMini 1 & ProxMox DevDocker VM):

  • komga (media)

Test & Prod Services (LenovoMini 1 & LenovoMini 2):

  • Signal (chat)
  • Vault (secrets)
  • tsidp (identity)
  • syncthing (file sync)
  • scaletail (logging)
  • miniflux (RSS)
  • transmission (torrent)
  • mongodb (database)

Prod-Only Services (LenovoMini 1):

  • -arr suite (media automation: Sonarr, Radarr, etc.)
  • plex (media server)
  • audiobookshelf (audiobook management)

DMZ Services (shared NIC on LenovoMini 1 / ProxMox, or dedicated seed box):

  • qBittorrent (torrent client for Internet Archive backups)
  • Shared storage mount (NFS from UnRaid NAS for downloaded content β€” accessible by Prod services)
Intel NUC (10.0.1.0/24) - Komodo Controller
β”œβ”€β”€ Komodo Core (orchestrates)
β”œβ”€β”€ GitHub Runner (executes)
└── β†’ Ansible/Terraform β†’ LenovoMini 1, LenovoMini 2, ProxMox

LenovoMini 1 (Prod)
β”œβ”€β”€ Periphery Agent
β”œβ”€β”€ Docker services
β”‚   β”œβ”€β”€ komga          β”œβ”€β”€ transmission   β”œβ”€β”€ plex
β”‚   β”œβ”€β”€ uptimekuma     β”œβ”€β”€ mongodb        β”œβ”€β”€ sonarr/radarr
β”‚   β”œβ”€β”€ docktail       β”œβ”€β”€ miniflux       └── audiobookshelf
β”‚   β”œβ”€β”€ Signal         β”œβ”€β”€ scaletail
β”‚   └── Vault/tsidp
└── UnRaid NAS (mounted)

LenovoMini 2 (Test)
β”œβ”€β”€ Periphery Agent
β”œβ”€β”€ Docker services
β”‚   β”œβ”€β”€ komga          β”œβ”€β”€ transmission   β”œβ”€β”€ Signal
β”‚   β”œβ”€β”€ uptimekuma     β”œβ”€β”€ mongodb        β”œβ”€β”€ Vault/tsidp
β”‚   β”œβ”€β”€ docktail       β”œβ”€β”€ miniflux       β”œβ”€β”€ syncthing
β”‚   └── scaletail
└── Synology NAS (mounted)

ProxMox Host (Dev)
β”œβ”€β”€ DevDocker VM
β”‚   β”œβ”€β”€ Periphery Agent
β”‚   β”œβ”€β”€ Forgejo (Git)
β”‚   β”œβ”€β”€ tsflow (CI/CD)
β”‚   └── Experimental containers
└── DevNode VMs (unmanaged sandbox)

DMZ (10.0.5.0/24)
β”œβ”€β”€ Option A: Shared NICs on existing hosts via VLAN tagging
β”‚   β”œβ”€β”€ LenovoMini 1 (Docker container on DMZ network)
β”‚   └── ProxMox Host (VM with DMZ network)
β”œβ”€β”€ Option B: Dedicated seed box
β”‚   └── MiniPC (qBittorrent, other DMZ services)
└── Shared storage: NFS mount from UnRaid NAS (Prod VLAN)
    └── Firewall exception: allow DMZ β†’ UnRaid NAS (NFS port 2049)

Future

  • Guest Network: VLAN (10.0.20.0/24) on Unifi SSID Guest
  • Tailscale SSH: Secure remote CLI access to all nodes
  • DMZ expansion: Additional public-facing services beyond qBittorrent

Observability (in progress)

Goals and acceptance criteria: observability/status-and-operator-dashboard.md (see also observability/README.md).

InitiativePurpose
Public statusstatus.samplayskeys.com on a VPS β€” user-facing service health (non-admin), available during local outages.
Operator dashboardSingle pane (e.g. Glances Β± link portal) with lab overview and links to Uptime Kuma, Komodo, and management UIs.

Homelab Repository Structure

Layout for the infrastructure-as-code repository that manages the homelab.


Overview

The infrastructure repository has three main sections:

SectionToolPurpose
terraform/TerraformProvision VMs, networks, base infrastructure
ansible/AnsibleSystem configuration, package management, agent installation
komodo/KomodoContainer definitions and GitOps configs

Full Structure

infrastructure/
β”œβ”€β”€ terraform/
β”‚   β”œβ”€β”€ prod/
β”‚   β”œβ”€β”€ test/
β”‚   β”œβ”€β”€ dev/
β”‚   └── modules/
β”‚
β”œβ”€β”€ ansible/
β”‚   β”œβ”€β”€ inventory/
β”‚   β”‚   β”œβ”€β”€ prod.yml
β”‚   β”‚   β”œβ”€β”€ test.yml
β”‚   β”‚   └── dev.yml
β”‚   β”œβ”€β”€ playbooks/
β”‚   β”‚   β”œβ”€β”€ base-setup.yml           # Common system config
β”‚   β”‚   β”œβ”€β”€ install-periphery.yml    # Install Komodo Periphery agent
β”‚   β”‚   └── ...
β”‚   └── roles/
β”‚
└── komodo/
    β”œβ”€β”€ prod/
    β”‚   β”œβ”€β”€ servers.toml              # Server definitions
    β”‚   β”œβ”€β”€ arr-suite/
    β”‚   β”‚   β”œβ”€β”€ arr-suite.toml
    β”‚   β”‚   └── compose.yaml
    β”‚   β”œβ”€β”€ plex/
    β”‚   β”‚   β”œβ”€β”€ plex.toml
    β”‚   β”‚   └── compose.yaml
    β”‚   └── homepage.toml             # Simple apps can be flat
    β”‚
    β”œβ”€β”€ test/
    β”‚   β”œβ”€β”€ servers.toml
    β”‚   └── ...
    β”‚
    β”œβ”€β”€ dev/
    β”‚   β”œβ”€β”€ servers.toml
    β”‚   └── ...
    β”‚
    └── shared/
        └── variables.toml            # Shared variables across envs

Terraform Section

Provisions the underlying infrastructure.

terraform/
β”œβ”€β”€ prod/           # Production environment resources
β”œβ”€β”€ test/           # Test environment resources
β”œβ”€β”€ dev/            # Dev environment resources (DevDocker VM)
└── modules/        # Reusable modules

Managed resources:

  • VMs on ProxMox (for DevDocker, DevNode)
  • Network configuration
  • DNS entries (if applicable)
  • Any cloud resources

Ansible Section

Configures systems after provisioning.

ansible/
β”œβ”€β”€ inventory/
β”‚   β”œβ”€β”€ prod.yml    # Prod hosts
β”‚   β”œβ”€β”€ test.yml    # Test hosts
β”‚   └── dev.yml     # Dev hosts
β”œβ”€β”€ playbooks/
β”‚   β”œβ”€β”€ base-setup.yml           # OS config, packages, users
β”‚   β”œβ”€β”€ install-periphery.yml    # Komodo Periphery agent
β”‚   β”œβ”€β”€ install-docker.yml       # Docker runtime
β”‚   └── ...
└── roles/
    β”œβ”€β”€ common/
    β”œβ”€β”€ docker/
    β”œβ”€β”€ periphery/
    └── ...

Responsibilities:

  • Base system configuration (users, SSH, time, etc.)
  • Package installation and updates
  • Docker installation
  • Komodo Periphery agent installation and configuration
  • Any system-level dependencies

Komodo Section

Defines what containers run where.

Environment Directories (prod/, test/, dev/)

Each environment has:

  • servers.toml β€” Defines the servers (nodes) in that environment
  • <app>/ β€” Subdirectory per app containing all related files (TOML config, compose.yaml, Dockerfiles, etc.)

Subdirectories are for organization only β€” Komodo scans recursively and finds TOML files regardless of nesting.

Server definition example:

# prod/servers.toml
[[server]]
name = "prod-docker-01"
description = "Production Docker host"
tags = ["prod", "docker"]

[server.config]
address = "http://192.168.1.10:8120"
enabled = true

Stack example (multi-container app):

# prod/arr-suite/compose.yaml
services:
  sonarr:
    image: linuxserver/sonarr:latest
    # ...
  radarr:
    image: linuxserver/radarr:latest
    # ...
  prowlarr:
    image: linuxserver/prowlarr:latest
    # ...
# prod/arr-suite/arr-suite.toml
[[stack]]
name = "arr-suite"
description = "Media automation stack"
tags = ["media", "prod"]

[stack.config]
server = "prod-docker-01"
file_paths = ["compose.yaml"]
deploy = true
auto_update = true

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.

Full rationale, merge quirk, and mitigations: ADR β€” Per-environment Git branches via Komodo ResourceSync.


Komodo Controller

The Komodo Controller (dedicated NUC) runs:

  • Komodo Core β€” The control plane that polls Git and orchestrates deployments
  • MongoDB β€” Komodo’s data store
  • Alerting integrations β€” (optional) Slack, Discord, email notifications

The Controller has network access to all managed environments (Prod, Test, Dev) but runs no workloads itself.

IoT Plan β€” New House

Overview and project plan for IoT deployment at the new house.

Philosophy: Convenience first, security second. Self-hosted, local control where possible.


Platform Stack

LayerChoiceStatusNotes
HubHome AssistantPlannedPhase 1: RPi 4, Phase 2: Container on Prod Docker
Primary ProtocolZigbeePlannedVia Sonoff Zigbee 3.0 coordinator
VoiceGoogle HomeExistingTTS announcements via HA
NetworkUnifi NetworkOn orderUDM + switches, foundation for VLANs
CamerasUnifi Protect + TapoOn order / ExistingProtect on UDM, 3x Tapo (existing)
ThermostatsSensi (2 zones)ExistingCloud integration via HA (acceptable trade-off)

See decisions/home-assistant-deployment.md for deployment architecture.


Phase 1 β€” Initial Deployment

Target Rooms

Garage:

  • 1x smart switch (single, not 3-way)
  • 1x wireless button (control from adjacent room)

Bedroom:

  • 4-gang panel, 3 smart + 1 dumb
  • Fan and light on fan are separately wired
PositionDeviceControls
1Inovelli Blue 2-1 (on/off)Hall light (3-way)
2Inovelli Blue Fan SwitchFan motor
3Inovelli Blue 2-1 (dimmer)Fan’s light
4Dumb Decora switchAlcove light

Whole House:

  • Door sensors on 4 doors (front, back, office, garage entry)
  • Automations for “door left open” alerts via Google TTS

Shopping List

ItemQty~CostNotes
Sonoff Zigbee 3.0 USB Dongle Plus1$25Coordinator for HA
Inovelli Blue 2-1 Switch3$135Hall 3-way, garage, fan light (dimmer)
Inovelli Blue Fan Switch1$45Bedroom fan motor
Zigbee wireless button1$12IKEA Shortcut or Aqara
Zigbee door sensors4$50Aqara or Sonoff SNZB-04
Decora 4-gang wall plate1$5Bedroom panel (replacing toggle)
Total~$270-275

Already owned:

  • Raspberry Pi 4 (for Home Assistant)
  • Tapo cameras (3x)
  • Sensi thermostats (2x)
  • LiftMaster garage door openers (2x, staying on myQ for now)

On order:

  • Unifi Network equipment
  • Unifi Protect + cameras

Switch Notes

  • Inovelli Blue 2-1 is configurable as on/off OR dimmer (same hardware)
  • 3-way configuration: Replace only ONE switch, existing dumb switch stays
  • Neutral wire: Confirmed present
  • Style: Decora (rectangular rocker) β€” replace existing toggle plate

Deferred Items

ItemReasonFuture Path
ratgdo (garage doors)Not a priorityAdd when local control becomes valuable (~$35-50/unit)
Smart locksDetection > preventionEvaluate Zigbee/Matter locks or Unifi Access later
Unifi AccessCostRevisit in a few years if “set and forget” lock system needed

See decisions/ for detailed rationale.


Integrations

SystemIntegration MethodLocal?Notes
Zigbee devicesZHA or Zigbee2MQTTYesUSB coordinator on HA host
Google HomeGoogle Cast integrationTTS only (cloud)Requires Prod β†’ IoT firewall rule
Tapo camerasTapo integration or RTSPRTSP is localOn IoT VLAN
Unifi ProtectNative HA integrationYesRuns on UDM, HA needs access to UDM management
Sensi thermostatsEmerson Sensi integrationNo (cloud API)Acceptable trade-off
LiftMaster (myQ)Not integratedN/ADeferred

Key Automations

See automations.md for full workflow definitions.

AutomationTriggerAction
Door left openDoor open > 5 minTTS to Google Home
Door reminderStill openRepeat TTS every 5 min
Garage night alertGarage door opens 10PM-6AMTTS + camera snapshot
Garage buttonZigbee button pressToggle garage light
Goodnight sceneButton or voiceHall off, bedroom dims, fan on
Arriving homePerson arrives after sunsetEntry lights on

Network Considerations

  • Zigbee coordinator should be central in house (or use USB extension cable to avoid USB 3.0 interference)
  • Each mains-powered Zigbee device (switches) extends the mesh
  • Consider IoT VLAN for Tapo cameras and other cloud-dependent devices

Stretch Goals

Automated HA Failover

Status: Future (Phase 3)
Hardware: Second Zigbee coordinator (~$25)

Cold/warm spare HA instance on Test environment (LenovoMini 2) with single-click failover:

  • Second Zigbee coordinator on Test host
  • Config sync from Prod β†’ Test (rsync or NAS)
  • Ansible playbooks to flip firewall rules (Test ↔ IoT instead of Prod ↔ IoT)
  • Zigbee2MQTT for portable network config (coordinator backup)

See decisions/home-assistant-deployment.md for full design.


Future Expansion

When ready to grow:

  • More rooms: Add Inovelli switches as needed (same ecosystem)
  • Motion sensors: Zigbee PIR sensors for presence-based lighting
  • Water leak sensors: Zigbee leak detectors for bathrooms, laundry, water heater
  • Frigate: Local AI object detection when homelab is running (needs Coral TPU)
  • ratgdo: Local garage door control when automation use case emerges
  • Locks: Zigbee/Matter locks or Unifi Access when keyless entry becomes valuable

References

Subsections of IoT Plan β€” New House

ADR: Home Assistant Deployment

Date: 2026-05-11
Status: Accepted
Deciders: Self


Context

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

  1. USB access β€” Zigbee coordinator must be physically attached to host
  2. Network access β€” Must reach IoT VLAN (10.0.4.0/24) for cameras, Google Home, etc.
  3. Unifi Protect access β€” Must reach UDM for Protect integration
  4. Reliability β€” Automation failures directly impact daily life
  5. Manageability β€” Should fit into existing GitOps/Komodo workflow where possible
  6. 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)

  1. Install Home Assistant OS on Raspberry Pi 4
  2. Connect Zigbee coordinator (Sonoff dongle)
  3. Place RPi on Prod VLAN (10.0.1.0/24)
  4. Configure firewall rules:
    • Allow Prod β†’ IoT (for cameras, Google Home)
    • Allow Prod β†’ UDM management interface (for Unifi Protect)
  5. Set up integrations (Zigbee, Tapo, Protect, Google Cast, Sensi)
  6. Configure automations
  7. Validate everything works

Backup: Manual snapshots to USB or network share


Phase 2: Migrate to Prod Docker

Timeline: When homelab Prod environment is stable

  1. Prepare LenovoMini 1:

    • Identify USB port for Zigbee coordinator
    • Test USB device passthrough (/dev/ttyUSB0 or /dev/ttyACM0)
  2. Create HA container configuration:

    # docker-compose.yml (Komodo-managed)
    services:
      homeassistant:
        container_name: homeassistant
        image: ghcr.io/home-assistant/home-assistant:stable
        restart: unless-stopped
        privileged: true  # Required for USB and network discovery
        network_mode: host  # Simplifies discovery protocols
        volumes:
          - /opt/homeassistant/config:/config
          - /etc/localtime:/etc/localtime:ro
        devices:
          - /dev/ttyUSB0:/dev/ttyUSB0  # Zigbee coordinator
        environment:
          - TZ=America/New_York

    Notes:

    • privileged: true or explicit device mapping required for USB
    • network_mode: host recommended for mDNS/SSDP discovery (Google Cast, etc.)
    • Adjust device path based on actual Zigbee dongle
  3. Migrate configuration:

    • Create HA snapshot on RPi
    • Restore snapshot to container config volume
    • Verify Zigbee coordinator is detected
    • Verify all integrations reconnect
  4. Set up backups:

    • Mount UnRaid NAS share for backup storage
    • Configure HA to snapshot to NAS (daily)
    • Or use Komodo/external script to backup /opt/homeassistant/config
  5. Add to Komodo:

    • Create TOML resource for HA container
    • Enable GitOps management
    • Set deploy: false initially (manual approval for HA changes)
  6. Decommission RPi:

    • Verify Prod deployment is stable (run parallel for a few days if desired)
    • Power down RPi
    • Keep as cold spare for emergency restore

Network Configuration

Firewall rules needed (on UDM):

SourceDestinationPortPurpose
Prod VLAN (10.0.1.0/24)IoT VLAN (10.0.4.0/24)*HA reaching IoT devices
Prod VLANUDM management443, 7443Unifi Protect API
IoT VLANProd VLAN:81238123Optional: device callbacks to HA

Note: IoT VLAN should remain isolated from other VLANs. Only Prod (where HA lives) gets access.


Backup & Recovery

Backup strategy:

  • Daily HA snapshots to UnRaid NAS
  • Retain 7 daily + 4 weekly snapshots
  • Config directory also backed up via standard Prod backup job

Recovery procedure:

  1. If LenovoMini 1 fails:
    • Option A: Restore to LenovoMini 2 (Test) temporarily
    • Option B: Boot RPi with latest snapshot
  2. Restore latest snapshot
  3. Reconnect Zigbee coordinator (may need re-pairing if coordinator changed)
  4. Verify automations

RTO target: < 1 hour (acceptable for home automation)


Resilience Notes

What survives HA downtime:

  • Zigbee direct bindings (button β†’ switch) work without HA
  • Smart switches work manually (physical toggle)
  • Cameras continue recording (Protect is independent)
  • Thermostats continue on last schedule (Sensi is independent)

What fails:

  • Automations (door alerts, scenes, presence-based lighting)
  • Voice control via HA (Google Home native commands still work)
  • Unified dashboard/control

Acceptable trade-off: Brief outages are tolerable. Critical functions (lights, climate) have manual fallback.



Phase 3: Automated Failover (Stretch Goal)

Status: Future consideration
Complexity: Medium-high
Additional hardware: Second Zigbee coordinator (~$25)

Concept

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.

Architecture

                     Normal Operation
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                             β”‚
β”‚   Prod VLAN (10.0.1.0/24)              IoT VLAN (10.0.4.0/24)
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚   β”‚   LenovoMini 1      β”‚   allowed    β”‚  Zigbee devices β”‚  β”‚
β”‚   β”‚   [HA - Active]     │─────────────▢│  Cameras        β”‚  β”‚
β”‚   β”‚   [Zigbee Coord 1]  β”‚              β”‚  Google Home    β”‚  β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                 β–²           β”‚
β”‚   Test VLAN (10.0.2.0/24)               blocked β”‚           β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”‚           β”‚
β”‚   β”‚   LenovoMini 2      β”‚β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚
β”‚   β”‚   [HA - Standby]    β”‚                                   β”‚
β”‚   β”‚   [Zigbee Coord 2]  β”‚                                   β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                   β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

                     Failover State
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                             β”‚
β”‚   Prod VLAN (10.0.1.0/24)              IoT VLAN (10.0.4.0/24)
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚   β”‚   LenovoMini 1      β”‚   blocked    β”‚  Zigbee devices β”‚  β”‚
β”‚   β”‚   [HA - DOWN]       │──────────X   β”‚  Cameras        β”‚  β”‚
β”‚   β”‚                     β”‚              β”‚  Google Home    β”‚  β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                 β–²           β”‚
β”‚   Test VLAN (10.0.2.0/24)               allowed β”‚           β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”‚           β”‚
β”‚   β”‚   LenovoMini 2      β”‚β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚
β”‚   β”‚   [HA - Active]     β”‚                                   β”‚
β”‚   β”‚   [Zigbee Coord 2]  β”‚                                   β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                   β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Components

1. Standby HA Instance (LenovoMini 2)

  • Home Assistant container (same config as Prod)
  • Second Zigbee coordinator (Sonoff dongle)
  • Container stopped or running in standby mode

2. Config Synchronization

  • 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 environment
  hosts: localhost
  tasks:
    - name: Update UDM firewall - block Prod to IoT
      community.general.udm_firewall_rule:
        name: "Prod-to-IoT"
        state: disabled
      
    - name: Update UDM firewall - allow Test to IoT
      community.general.udm_firewall_rule:
        name: "Test-to-IoT"
        state: enabled
        
    - name: Start HA container on Test
      community.docker.docker_container:
        name: homeassistant
        state: started
      delegate_to: lenovomini2
      
    - name: Stop HA container on Prod (if reachable)
      community.docker.docker_container:
        name: homeassistant
        state: stopped
      delegate_to: lenovomini1
      ignore_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

ModeStandby StateSync FrequencyFailover TimeResource Usage
Cold SpareContainer stoppedDaily backup restore~5-10 minMinimal
Warm SpareContainer running (no IoT access)Near real-time rsync~1-2 minModerate

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:

  1. Hardware: Second Zigbee coordinator (~$25)
  2. Network: Test VLAN firewall rules pre-configured (disabled)
  3. Ansible: Playbooks for failover/failback
  4. UDM API access: Ansible collection for Unifi or direct API calls
  5. Zigbee2MQTT: Migrate from ZHA if using ZHA initially
  6. Testing: Validate failover in controlled scenario

Risks & Mitigations

RiskMitigation
Zigbee devices don’t reconnect to second coordinatorUse Zigbee2MQTT with network backup; same coordinator model
Config drift between Prod and TestAutomated sync + Git-based config
Failover triggered unnecessarilyManual trigger only (Phase 3a); conservative health checks (Phase 3b)
Firewall automation failsPre-test playbooks; manual fallback documented

Cost

ItemCost
Second Zigbee coordinator~$25
Time to implementMedium
Ongoing maintenanceLow (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.


References

Decision: Door Locks

Date: 2026-05-11
Status: Deferred
Decision: No smart locks initially; rely on physical keys + camera detection


Context

Evaluating whether to include smart locks in the initial IoT deployment.


Decision

Defer smart locks. Focus on detection, not prevention.

Rationale

  1. Security philosophy: If someone is determined to get in, they will. Detection and evidence (cameras) are more valuable than a smart lock.

  2. Existing coverage: Unifi Protect cameras + door sensors provide detection and alerting.

  3. Complexity trade-off: Smart locks add battery management, failure modes, and integration points without a clear use case.

  4. Physical keys work: No current pain point that a smart lock solves.


Future Considerations

Revisit when one of these use cases becomes valuable:

  • Keyless entry for family
  • Temporary access codes for guests/contractors
  • Auto-lock after X minutes
  • Remote verification
  • Presence-based auto-unlock

Options to Evaluate Later

Consumer (HA compatible):

  • Yale Assure Lock 2 (Zigbee, Z-Wave, Matter)
  • Schlage Encode Plus (WiFi, HomeKit)
  • August Wi-Fi Smart Lock (retrofit)
  • Level Lock+ (invisible, HomeKit/Matter)

Enterprise:

  • Unifi Access β€” NFC/card based, integrates with Protect
  • Higher cost (~$300+/lock + hub), more commercial-grade
  • Interesting for “set and forget” but overkill for residential

When Adding Locks

  1. Prefer Zigbee or Matter (aligns with existing protocol)
  2. Integrate via Home Assistant
  3. Automate: auto-lock, lock-on-leave, alert if unlocked while away
  4. Tie to camera snapshots on unlock events

Decision: Smart Switch Protocol

Date: 2026-05-11
Status: Decided
Decision: Zigbee (Inovelli Blue switches)


Context

Planning IoT lighting for new house. Requirements:

  • Work in 3-way configurations with only ONE switch replaced
  • Support wireless buttons for remote control
  • Prioritize cost savings and upgradeability
  • Integrate with self-hosted Home Assistant

Neutral wire is present.


Options Considered

Zigbee β€” Inovelli Blue Series

Protocol: Zigbee 3.0
Switch Cost: ~$45-50
Coordinator Cost: ~$20-30

Pros:

  • Works with existing dumb switch in 3-way config
  • Large device ecosystem (sensors, buttons, plugs)
  • Cheap wireless buttons ($7-15)
  • 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

  1. Accessory ecosystem: Zigbee buttons and sensors are significantly cheaper. $7 IKEA button vs $40+ Z-Wave compounds quickly.

  2. Upgradeability: Largest device selection across manufacturers for future expansion.

  3. Feature set: LED notifications, direct binding, scene control β€” useful for automations.

  4. 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: state
    entity_id:
      - binary_sensor.front_door
      - binary_sensor.back_door
      - binary_sensor.office_door
      - binary_sensor.garage_entry_door
    to: "on"
    for:
      minutes: 5
action:
  - service: tts.google_translate_say
    target:
      entity_id: media_player.living_room_speaker
    data:
      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: state
    entity_id:
      - binary_sensor.front_door
      - binary_sensor.back_door
    to: "on"
    for:
      minutes: 5
action:
  - repeat:
      while:
        - condition: state
          entity_id: "{{ trigger.entity_id }}"
          state: "on"
      sequence:
        - service: tts.google_translate_say
          target:
            entity_id: media_player.living_room_speaker
          data:
            message: "Reminder: {{ trigger.to_state.attributes.friendly_name }} is still open."
        - delay:
            minutes: 5
mode: parallel

Garage Entry Night Alert

Alerts when garage entry door opens at night, with camera snapshot.

alias: "Alert - Garage Entry Night"
description: "Alert when garage entry opens at night"
trigger:
  - platform: state
    entity_id: binary_sensor.garage_entry_door
    to: "on"
condition:
  - condition: time
    after: "22:00:00"
    before: "06:00:00"
action:
  - service: tts.google_translate_say
    target:
      entity_id: media_player.bedroom_speaker
    data:
      message: "Garage entry door opened."
  - service: camera.snapshot
    target:
      entity_id: camera.tapo_garage
    data:
      filename: "/config/www/snapshots/garage_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg"
mode: single

Lighting Automations

Garage Light Button

Wireless button in adjacent room toggles garage light.

alias: "Button - Garage Light Toggle"
description: "Wireless button controls garage light"
trigger:
  - platform: event
    event_type: zha_event
    event_data:
      device_id: <zigbee_button_device_id>
      command: "toggle"
action:
  - service: light.toggle
    target:
      entity_id: light.garage
mode: single

Note: Can also use Zigbee direct binding for button-to-switch control without HA in the loop.


Goodnight Scene

Single button or voice command triggers bedtime routine.

alias: "Scene - Goodnight"
description: "Turn off lights, set fan, dim bedroom"
trigger:
  - platform: event
    event_type: zha_event
    event_data:
      device_id: <bedside_button_device_id>
      command: "on"
action:
  - service: light.turn_off
    target:
      entity_id:
        - light.hall_light
        - light.garage
  - service: light.turn_on
    target:
      entity_id: light.bedroom_fan_light
    data:
      brightness_pct: 10
  - service: fan.set_percentage
    target:
      entity_id: fan.bedroom_fan
    data:
      percentage: 50
mode: single

Presence Automations

Arriving Home

Turns on entry lights when arriving home after sunset.

alias: "Presence - Arriving Home"
description: "Turn on lights when someone arrives after sunset"
trigger:
  - platform: state
    entity_id: person.you
    to: "home"
condition:
  - condition: sun
    after: sunset
action:
  - service: light.turn_on
    target:
      entity_id:
        - light.front_porch
        - light.living_room
mode: single

Presence detection options:

  • HA Companion app (phone GPS)
  • Unifi device tracker (via Unifi integration)
  • Bluetooth/BLE presence sensors (future)

Climate Automations (Future)

With Sensi thermostats integrated, potential automations:

Away Mode

alias: "Climate - Away Mode"
description: "Set thermostats to away when everyone leaves"
trigger:
  - platform: state
    entity_id: group.all_people
    to: "not_home"
    for:
      minutes: 30
action:
  - service: climate.set_preset_mode
    target:
      entity_id:
        - climate.sensi_upstairs
        - climate.sensi_downstairs
    data:
      preset_mode: "away"
mode: single

Pre-condition Before Arrival

alias: "Climate - Pre-condition"
description: "Resume normal temps when someone is heading home"
trigger:
  - platform: zone
    entity_id: person.you
    zone: zone.near_home
    event: enter
action:
  - service: climate.set_preset_mode
    target:
      entity_id:
        - climate.sensi_upstairs
        - climate.sensi_downstairs
    data:
      preset_mode: "home"
mode: single

Integration Notes

Google Cast TTS

Uses tts.google_translate_say service. Requires:

  • Google Cast integration configured in HA
  • Speaker entity IDs (e.g., media_player.living_room_speaker)

Tapo Camera Snapshots

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.

alias: "Receipts - NFC App Launch"
description: "Launch receipt scanner app from fridge NFC"
trigger:
  - platform: nfc
    tag_id: <fridge_receipt_nfc_uid>
action:
  - service: notify.mobile_app_<phone>
    data:
      message: "open_receipt_scanner"
      data:
        action: launch_app
        package: com.example.receiptscanner
mode: single

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: nfc
    tag_id: <router_status_nfc_uid>
action:
  - service: browser_mod.popup
    data:
      title: "Service Status"
      # Serve from a local/static endpoint; no external CDN deps
      url: "/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: nfc
    tag_id: <consumable_nfc_uid>
action:
  - service: browser_mod.popup
    data:
      title: "Replace Air Filter"
      # Prefer a stable local/HA endpoint; the destination can rotate behind this URL
      url: "/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: nfc
    tag_id: <pihole_temp_disable_nfc_uid>
action:
  - service: rest_command.pihole_disable_30m
mode: 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).

DocumentContents
Status site & operator dashboardPhases, 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

  1. Public HTTPS at status.samplayskeys.com with a sensible TLS story (e.g. reverse proxy + Let’s Encrypt on the VPS).
  2. Lists only non-admin, user-relevant resources (e.g. Plex, Signal, Miniflux, Syncthing client-facing checks β€” exact list is yours to curate).
  3. Does not link to or name internal admin URLs, VLANs, or management UIs.
  4. 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).
  5. 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)

TopicNotes
Product / implementationOptions 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 KumaKuma 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.
DNSstatus 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

  1. 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.
  2. Curated links (always visible):
    • Uptime Kuma (alerting / synthetics).
    • Komodo (deployments / stacks).
  3. 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

  1. One bookmarkable URL (likely not public β€” Tailscale or LAN-only) that opens the dashboard.
  2. Sections or tiles: β€œLab overview” (metrics snapshot), β€œMonitoring”, β€œOrchestration”, β€œManagement consoles” (grouped by host or role).
  3. Every critical jump target reachable from that page in ≀2 clicks without remembering IPs.
  4. Link list can be updated without fragile one-off edits (manifest, template, or generator).

Open decisions (Phase 2)

TopicNotes
Glances vs hybridGlances 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.
HostingTypically homelab-side (or Tailscale-only) β€” not the same trust boundary as the public status site.
Secrets in URLsManagement 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)
HostVPSHomelab / Tailscale
AudienceUsersYou
DataSynthetic / public checksRicher metrics + deep links
AlertingInformational; Kuma can still alert youKuma primary; dashboard is navigation

Suggested sequencing

  1. Inventory user-facing hostnames and which are probe-able from the public internet (required for Phase 1).
  2. Stand up VPS + DNS + TLS for status.samplayskeys.com.
  3. Implement Phase 1 status page and curate components.
  4. Phase 2: choose portal + Glances (or alternative) layout, add manifest for management links, wire Komodo/Kuma bookmarks.

Pre-mortem hooks (when you pick tools)

  • 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
  • Firewall rules between Prod/Test/Dev/IoT/DMZ β€” defined in unifi-configurations.md
  • DNS strategy (internal DNS, split-horizon?)
  • Reverse proxy / ingress strategy (Traefik, Caddy, nginx?)

Storage Strategy

  • 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

WorkloadTypePriority
PlexStackHigh
*arr suite (Sonarr, Radarr, Prowlarr, etc.)StackHigh
Uptime KumaAppMedium
TBDβ€”β€”

DMZ

WorkloadTypePriority
qBittorrent (Internet Archive torrent backups)AppMedium
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?)
  • Additional storage nodes

Decisions Still Pending

DecisionOptionsNotes
Reverse proxyTraefik, Caddy, nginxNeed to pick one
DNSPi-hole, AdGuard, externalTied to network topology
Monitoring stackKomodo + Uptime Kuma + log paths; metrics stack (Prometheus/Grafana, etc.) deferredEnv/power metrics also deferred
SecretsKomodo variables, Vault, SOPSDepends on sensitivity

Scratchpad


type: Idea

Machine List

JONSBO NAS - 6th Gen i7, 16 GB RAM, NVIDIA GPU, 10TB of storage (8 usable)

Synology NAS - 4GB RAM, 6TB storage

Intel NUC - Celeron CPU, 8GB of RAM

Raspberry Pi 4 - 4GB RAM

LenovoMini 1 - 8th gen i7, NVIDIA GPU, 16GB RAM LenovoMini 2 - 7th gen i5, NVIDIA GPU, 16GB RAM LenovoMini 3 - i5, 16GB RAM LenovoMini 4 - i5, 16GB RAM LenovoMini 5 - i5, 16GB RAM

ProxMox Host - Ryzen 3700, 16GB RAM, NVIDIA GPU Gaming PC - Ryzen 5700, 16GB RAM, NVIDIA GPU

Machine Assignments

LenovoMini 1 - Prod, baremetal docker host, Fedora, tailscale installed, komodo periphery installed LenovoMini 2 - Test, baremetal docker host, Fedora, tailscale installed, komodo periphery installed LenovoMini (3,4,5) - Dev, OCP cluster, tailscale operator installed

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 NameAssigned Network (VLAN)Purpose
ProdProd (1)Core services, Home Assistant
TestTest (2)Pre-production validation
DevDev (3)OCP and Sandbox nodes
IoTIoT (4)Cameras, smart plugs
DMZDMZ (5)Public-facing services (qBittorrent)
UserUser (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:

FieldValueNotes
NameqBittorrent Incoming
EnabledTrue
FromAnyAccepts connections from any WAN IP
Port51820Pick a high ephemeral port (avoid 6881 β€” some ISPs throttle well-known BitTorrent ports)
Forward IP10.0.5.XDMZ machine’s static IP running qBittorrent
Forward Port51820Can differ from WAN port if needed, but matching is cleaner
ProtocolTCP & UDPBoth needed: TCP for peer data, UDP for DHT/magnet links
LogFalseAvoid 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.

Optional: Static DHCP Lease

Ensure the DMZ machine’s IP does not change:

Settings > Networks > LAN (DMZ VLAN) > DHCP > DHCP Lease Table:

  • Add a fixed lease for the qBittorrent MAC β†’ 10.0.5.X

6. Internet Access (External Zone)

The External zone represents the Internet.

  • User, Prod, Test, Dev to External: Generally allowed by default so containers can pull images and devices can update.
  • DMZ to External: Allowed via Policy 6.
  • IoT to External:
    • To block IoT devices from “phoning home”, create a policy: Block IoT to Internet.
    • Source Zone: IoT
    • Destination Zone: External
    • Action: Block
    • (Optional: Create a higher-priority Allow rule for specific IoT devices that require cloud connectivity, like a smart vacuum).