Troubleshoot Sessions → Remediation docs (docs/troubleshooting/) and guides (docs/guides/)
Reusable automation templates & AI execution plans → Graduate from wip/ to artifacts/ (for example Ansible/OpenShift patterns, manifests, and prompt-as-a-plan templates) with documentation alongside the templates
See workstyle/working_style.md for the full graduation workflow.
Multi-play validation pattern, role skeleton, example roles; long-form authoring guide in-repo
Graduated from wip/
Subsections of Documentation
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:
Environment
Purpose
Management
Prod
Production workloads
GitOps (Komodo)
Test
Pre-production validation
GitOps (Komodo)
Dev
Development, experimentation, OCP cluster
Mixed (Komodo for Docker, unmanaged for VMs/OCP)
User
Personal workstation
N/A
IoT
Smart home, cameras
Isolated
DMZ
Public-facing services (qBittorrent)
Isolated, restricted
Prod, Test, and Dev (Docker containers) form a promotion path managed by Komodo. Dev also contains unmanaged nodes (VMs, OCP cluster) for experimentation.
Dedicated Debian VM with Quadro P5000 GPU passthrough, running LlamaFarm as the AI model management layer. Disposable — Ansible provisions, persistent storage holds models and data.
GitOps for managed environments — Desired state lives in Git; Komodo reconciles actual state to match
Promotion via file movement — Move/copy TOML files between dev/ → test/ → prod/ directories
Isolation for sandboxes — DevNode and DevOCP have no GitOps integration; they’re for experimentation
Dedicated management plane — Komodo Controller runs on separate hardware (NUC) with no workloads
Subsections of Homelab Infrastructure
Leantime MCP Server — Local Setup
How the Leantime MCP server is set up and running on this machine (sams-macbook-air), for use as an opencode MCP tool.
Overview
Leantime is a self-hosted project management tool running in the homelab. It exposes an MCP endpoint at /mcp. This machine runs the leantime-mcp npm bridge as a stdio-to-HTTP proxy: it speaks the MCP protocol over stdio to the client (opencode), then forwards every tool call as HTTP JSON-RPC to the remote Leantime endpoint using a personal access token.
Lazily initializes the Leantime session on first tool list (MCP initialize + notifications/initialized).
Every client tool call is proxied to the remote /mcp endpoint with the token.
Retries failures with backoff, caches tool responses for 5 minutes.
Leantime server
Self-hosted at http://leantime.samplayskeys.ts.net/mcp, reachable only over Tailscale.
MagicDNS resolves the name to 100.88.126.2; tailscale ping reports it is served from fedora-dev (100.125.91.5). No Tailscale Serve config exists on this Mac — serving happens on the host side.
The /mcp endpoint is auth-gated: unauthenticated requests return 401/406; the root path 302-redirects. Requests must include Authorization: Bearer <token>.
Requires the Leantime MCP Server plugin (paid, marketplace) for the endpoint and Advanced Auth for personal access tokens (token needed for user-specific queries like “get my tasks”).
History / context
Originally explored for OpenClaw in the WIP session wip/ai-leantime-integration/ (see context.md, mcp-path.md, refs/). The bridge ended up wired into opencode on a local machine instead.
Pitfalls / notes
The bridge must run on a machine that can reach both the AI client and the Leantime instance (Tailscale overlay covers this).
Leantime MCP tools are read+write by default — consider tool filters if destructive operations should be restricted.
API keys act as service accounts and cannot answer user-specific queries; prefer a personal access token.
Architecture decision records (homelab)
ADRs capture decisions that are stable enough to reference later, with context and trade-offs. They supplement the narrative Decisions page.
Subsections of Architecture decision records (homelab)
ADR: Tailnet-per-environment (Prod / Test / Dev) with declarative node sharing
Status
Rejected. The homelab will not split Prod/Test/Dev into separate tailnets. Environment separation stays as it is today: a single tailnet with tag-based grants.
The investigation is retained in full because the rejection is the useful part — it documents why an appealing structural-isolation story doesn’t survive contact with an already-working Services deployment.
Superseded in direction by ADR-0007: Workload isolation in a single tailnet — access pools, which keeps the workload/homelab separation inside the tailnet as ACL-defined access pools, with a separate tailnet retained only as a deferred off-ramp. Its core finding — that a tailnet boundary pays off where paths should be closed anyway (non-homelab workload), not between homelab environments — still stands.
Context
Today the homelab runs a single tailnet with tag-based environment separation. Services are already the primary exposure mechanism for containers across Prod/Test/Dev — advertised via Docktail labels and ScaleTail sidecars, with per-service MagicDNS names, per tailscale.md. That part of the design is settled and this ADR does not revisit it.
What is not settled is the environment boundary. Per tailscale-grants.md, isolation between Prod/Test/Dev is enforced entirely by grant rules:
That works, but the isolation is one policy-file edit deep. A bad merge, a mis-tagged node, or a fat-fingered tag:* destination collapses Prod/Test/Dev into one flat network. The blast radius of a policy mistake is the entire homelab.
Two Tailscale capabilities change the calculus:
Additional tailnets via the API.POST /api/v2/organizations/-/tailnets provisions a fully isolated tailnet under the same account, no console clicks. The response carries a tailnet-scoped OAuth client — creation is org-scoped, but administration and deletion require that per-tailnet credential (worked example).
Declarative node sharing (docs, alpha, waitlist-gated). Cross-tailnet access becomes a policy-file construct — externalTailnets plus grants referencing group://<alias>/<name> or tag://<alias>/<name>. It is explicitly designed for machine-to-machine access between trusted tailnets, which is exactly the homelab’s shape.
The question this ADR frames: is a tailnet boundary the right isolation primitive for the homelab’s environments, and is the cross-tailnet seam cheap enough to live with?
What a tailnet split does to the existing Services layer
Because Services are already the exposure model, the split’s real cost lands here rather than on greenfield design:
Service names become tailnet-qualified. Every service currently resolves under one tailnet’s MagicDNS suffix. Three tailnets means three suffixes, and the naming convention documented in tailscale.md needs an environment-aware revision. OPEN: does plex.<prod-tailnet>.ts.net replace the current flat convention, or does an alias layer hide the split?
httpsEnabled is per-tailnet and off by default. Any new tailnet needs the setting turned on before TLS-terminating services work at all — a provisioning step that doesn’t exist today because the current tailnet was configured once, long ago.
Docktail/ScaleTail config becomes tailnet-scoped. Sidecars and label-driven advertisement authenticate to a specific tailnet. Auth keys, tags, and tagOwners all become per-tailnet concerns.
Cross-environment service consumption is the seam. Anything in one environment consuming a service in another (observability, backups, promotion tooling) stops being an in-tailnet lookup and becomes an externalTailnets grant.
OPEN and worth answering early: whether a Service can be published across a tailnet boundary. If yes, the boundary contract is service-level and clean. If not, cross-environment access is node-and-port level, which is coarser than the exposure model already in use — a genuine regression against current practice, and arguably the strongest argument for staying on one tailnet.
Correction to an earlier framing
An earlier draft of this ADR analyzed the seam using link-based sharing (sharing docs) and concluded the model was largely unworkable — tags stripped, shares user-scoped so tagged machines can’t accept them, quarantine by default. Those constraints are real but belong to link-based sharing, not declarative node sharing. Declarative sharing operates at the policy level and supports tag references and machine-to-machine access directly. The two mechanisms coexist; picking the wrong one produces the wrong conclusion, which is why they’re separated explicitly below.
DNS suffixes, sidecar auth, per-tailnet httpsEnabled
Partner / shared access model
Medium
Adversarial model already documented
Cost
Low–Medium
OPEN: plan implications of additional tailnets
The two sharing mechanisms
Getting this distinction right is most of the decision.
Link-based sharing
Declarative node sharing
Status
GA (beta flag in docs), v1.4+
Alpha, waitlist-gated
Unit of sharing
One machine → one user
Policy-level, resource classes
Tags across the boundary
Stripped
Supported via tag://<alias>/<tag>
Machine-to-machine
Not supported — only users accept shares
Explicitly supported
Quarantine
Inbound-only by default
Governed by allowIncomingConnections; OPEN whether quarantine semantics still apply
Setup
Invite link, out-of-band, manual accept
Double opt-in policy edits on both tailnets
Automatable
Poorly
Fully — policy file is GET/POST /api/v2/tailnet/-/acl
Group sync (SCIM/Google)
n/a
Unsupported — can’t reference synced groups
Consequence: declarative sharing is the mechanism this design should be built on. Link-based sharing remains the right tool only for the partner/guest case — handing one machine to one outside human.
A grant referencing something absent from the other side’s allowExternalReferencesTo is silently ignored — no error, just no connectivity. That failure mode needs a verification step in any runbook.
Options
Retained as the investigation record. Outcome noted on each.
Option A — Status quo: one tailnet, tag-based separation — CHOSEN
Pros: zero migration; flat MagicDNS; one policy file; single console; no alpha dependency; Services and access model untouched.
Cons: isolation is advisory, not structural; one bad grant flattens all environments; no per-environment audit boundary.
Option B — Three tailnets (Prod / Test / Dev), declarative sharing between them — rejected
Pros: isolation is structural — a Dev policy mistake cannot reach Prod; per-tailnet audit logs, auth keys, and tagOwners; Dev becomes genuinely disposable; whole lifecycle is API-drivable (create, httpsEnabled, policy push, scoped auth keys); tags and m2m survive the boundary; double opt-in means neither side can unilaterally widen access.
Cons:depends on an alpha, waitlist-gated feature; three policy files and three scoped OAuth credentials to manage; FQDNs become tailnet-qualified; silent-ignore failure mode on mismatched references; per-tailnet settings (notably HTTPS certs, off by default) must be provisioned explicitly; rewrites a working Services exposure surface.
Option C — Two tailnets: Prod isolated, Test+Dev together — rejected
Pros: captures the blast-radius win that matters at two-thirds the overhead; Test↔Dev promotion stays in-tailnet.
Cons: Test loses a hard boundary from Dev, so “does Test mirror Prod?” gets fuzzier; still carries the full alpha dependency for one seam — paying the same risk for less of the benefit.
Option D — One tailnet, hardened — CARRIED FORWARD (with A)
Policy-file CI with ACL tests in GitHub Actions, mandatory review on the policy file, ban tag:* destinations, and tighten per-service grants on the Services already in place.
Pros: near-zero migration; no alpha dependency; converts soft isolation into tested isolation.
Cons: still one flat trust domain; a mistake that passes tests still lands everywhere; audit boundary unchanged.
Decision
Rejected — keep the current single-tailnet structure with tag-based environment separation.
The structural-isolation argument is real, and declarative node sharing does make tailnet-per-environment technically achievable in a way link-based sharing never did. It is still the wrong trade for this homelab. Four reasons, in order of weight:
1. It breaks access that currently works, to solve a problem that hasn’t bitten
Services are already the exposure mechanism for most containers, and access works today — admin tiers, partner access, per-service MagicDNS names. A tailnet split rewrites that working surface: tailnet-qualified DNS suffixes, tailnet-scoped Docktail/ScaleTail sidecar auth, per-tailnet httpsEnabled, and per-tailnet auth keys and tagOwners. The failure it prevents — a fat-fingered grant flattening environments — has not actually occurred. Trading working access for hypothetical blast-radius reduction is the wrong direction.
2. Cross-boundary sharing is coarser than what’s already in place
The unresolved question from the investigation is decisive: if a Service cannot be published across a tailnet boundary, then cross-environment access degrades to node-and-port grants. That is coarser than the per-service exposure model already running. And if the shareable app has to physically live in the tailnet it’s shared from, containers move off the Prod Docker host — which drags in Komodo stack placement and the promotion path from ADR-0001. Either answer is worse than the status quo for a boundary nobody asked for.
3. The complexity is not proportionate to a single-operator homelab
Three tailnets means three policy files, three consoles, three sets of tailnet-scoped OAuth credentials, three auth-key rotations, and per-tailnet provisioning steps that don’t exist today. Add the silent-ignore failure mode — a grant referencing something absent from the other side’s allowExternalReferencesTo produces no error, just no connectivity — and routine debugging gets materially harder. Isolation that is expensive to operate gets worked around, and a worked-around boundary is worse than an honest tag.
4. The alpha dependency lands on the wrong path
Declarative node sharing is alpha and waitlist-gated. Under this ADR it would sit on the path between the Komodo controller and Prod periphery agents — meaning homelab manageability depends on an alpha feature. That inverts the risk this ADR set out to reduce.
What is kept
Environment separation stays tag-based (tag:prod / tag:test / tag:dev) in one tailnet. No grants rewrite, no tag taxonomy change, no Services churn.
Partner access stays link-based sharing, per the adversarial model in tailscale.md. Outside humans are not modeled as external tailnets.
Policy-file CI with ACL tests is still worth doing (the former Option D) and is the only recommendation carried forward from this ADR. It converts advisory isolation into tested isolation at near-zero cost, and it is independently useful.
Future option: collapse three environments to two
Noted, not decided: the Prod/Test/Dev split could later collapse to Test and Prod only. Under ADR-0007, Dev’s remaining purpose is being reconsidered — if non-homelab work moves to a workload environment, a third homelab environment may not be earning its keep. That is a separate decision and deliberately out of scope here.
Where the tailnet boundary does belong
The investigation’s real finding is that the boundary axis was wrong. Splitting environments imposes a boundary across paths that need to stay open (admin, Komodo, observability, backups). Splitting workload from homelab puts the boundary where the paths should be closed anyway. See ADR-0007.
Cross-environment flows that made the split expensive
Each of these works today only because everything is one tailnet. Each would have needed an explicit externalTailnets/grant design — collectively, this list is the concrete form of reason #1 above:
Komodo controller (NUC, tag:admin) → Prod/Test/Dev periphery agents — the canonical m2m case; technically workable via tag:// references, but it puts homelab manageability behind an alpha feature
Promotion path DevDocker → Test → Prod (per ADR-0001, promotion is a file move; the network path is currently implicit and would have to become explicit)
Observability scrape/push paths across environments — direction matters against allowIncomingConnections
Backup flows to tag:storage NAS (UnRaid = Prod, Synology = Test)
Admin access from the operator’s devices to all three — one identity in three tailnets, or a device per tailnet, neither cheap
Exit node — one per tailnet, or one shared
Partner/guest access — stays on link-based sharing regardless
ProxMox host, which hosts the Dev VM but is itself infrastructure
Trade-offs that would have been accepted (and were judged too costly)
Alpha-feature dependency on the critical path for cross-environment automation.
Tailnet-scoped OAuth credentials must be captured at creation — the org token cannot administer or delete a tailnet it created.
Cross-environment automation gets deliberately harder. Intended for environments; unhelpful for admin paths.
Consequences / follow-on work
Because the split is rejected, most of the original follow-on work is moot. What remains:
Add policy-file CI with ACL tests. The one carried-forward recommendation. Ban tag:* destinations, require review on the policy file, run tests in GitHub Actions.
No changes to tailscale-grants.md or tailscale.md from this ADR — the tag taxonomy and Services naming convention stand as documented.
Record the rejection so the option isn’t re-litigated from scratch; the mechanism comparison above is the reusable part.
Continue in ADR-0007 for the workload boundary, where the provisioning artifact and waitlist question actually apply.
Open questions — resolved or relocated
The rejection closes most of these. Recorded so the option isn’t re-litigated from scratch:
Plan/cost implications of additional tailnets — moot here; relocated to ADR-0007.
Waitlist timing for declarative node sharing — no longer gating this decision; still worth joining for ADR-0007.
Quarantine semantics vs. allowIncomingConnections for declaratively shared nodes — unresolved; relocated to ADR-0007, where it actually matters.
Operator identity/devices across three tailnets — moot; admin access stays in one tailnet, which is the point of the rejection.
Komodo topology across a boundary — moot; controller and periphery agents stay in one tailnet.
Can a Service be published across a tailnet boundary? — unresolved and now load-bearing for ADR-0007. Answering it decides whether shareable apps can stay on their current host or must physically move.
Does Test lose meaning as a Prod rehearsal under Option C? — moot; Option C rejected. Related but distinct question of collapsing to Test+Prod is noted as a future option above.
Migration order — moot; no migration.
Synced groups (SCIM/Google) can’t be referenced across tailnets — relocated to ADR-0007; only relevant where a boundary exists.
References
Primary sources
Declarative node sharing — the mechanism this ADR is built on. Alpha, waitlist-gated. externalTailnets, double opt-in, tag:///group:// external reference syntax, allowExternalReferencesTo, allowIncomingConnections. Also the source for the SCIM/Google synced-group limitation.
summer-with-tailscale — worked, runnable examples for the API-driven path. Modules used here:
01-tailnet-sandboxes — OAuth token exchange, POST /organizations/-/tailnets, and the tailnet-scoped OAuth credential that the org token cannot substitute for on delete
02-tailnet-membership — tsnet embedded membership; relevant if services join tailnets directly rather than via host daemons
03-declarative-sharing — externalTailnets config walkthrough and scoping external access by group/tag instead of blanket dst: ["*"]
04-api-is-the-way — policy file as data via GET/POST /tailnet/-/acl, the httpsEnabled PATCH, scoped/tagged/short-lived auth keys, plus end-to-end.sh
Supporting
Share your machines with other users — link-based sharing. Source of the tag-stripping, quarantine, and user-scoped constraints; retained here for the partner/guest case and to keep the two mechanisms distinguishable.
Tailscale Services GA — cited for the GA-era additions relevant to a split (declarative on-node JSON config, per-service audit logs, ACL tests), not as an introduction to Services, which the homelab already runs
ADR: Workload isolation in a single tailnet — access pools
Status
Proposed. Direction settled: workload/dev traffic stays in the current tailnet, separated by ACLs organized as access pools. The separate-tailnet option is deferred to a named trigger, not discarded.
Extends ADR-0006, which rejected splitting Prod/Test/Dev into separate tailnets. That investigation’s useful finding was that the boundary axis was wrong, not the boundary idea. This ADR takes the next step of that finding: separation of workload from homelab is real, but the mechanism is pools within one boundary, with structural separation preserved as an off-ramp.
Context
Two distinct things are currently conflated inside one tailnet:
The homelab — Prod and Test infrastructure, private applications, the Komodo GitOps pipeline, storage, admin interfaces. Everything here is either operator-only or shared with the partner as a trusted user.
Dev — nominally a homelab environment, but in practice where non-homelab work happens. It has no promotion relationship worth preserving with Test/Prod for that class of work.
The earlier draft of this ADR weighed two mechanisms: a separate tailnet for workload (structural isolation, independent policy, clean audit boundary) against staying in the shared tailnet with tag separation (zero migration, isolation only advisory). The pivotal consideration — that a boundary’s alpha dependency sits on a path that is allowed to degrade, unlike ADR-0006’s admin/Komodo paths — is retained.
The judgment has since shifted on the trigger. The conditions that would justify a boundary — outside resources managing elements, sustained workload or external-party growth — are not real today. Building the structural split now pays complexity for a hypothetical, and it puts reachability that does not yet need to exist on an alpha, waitlist-gated feature (declarative node sharing). The boundary is kept as the off-ramp, priced and designed, but not built.
Decision
Single tailnet, workload separated by access pools. The structural split is deferred, not discarded.
1. One tailnet for now
Workload/Dev elements stay in the current tailnet, protected by ACLs rather than a boundary. Nothing new to provision, no per-tailnet settings (httpsEnabled, tagOwners, auth keys), and the Services/Docktail/ScaleTail exposure layer works exactly as today. External sharing, when it happens, stays link-based — one machine handed to one invited user — which covers ad-hoc guest access without committing to a separate tailnet.
2. Scope framing shifts from “homelab first” to “environment with pools”
The tailnet is no longer scoped as the homelab with everything else grafted on. It is the environment housing purpose-defined pools — provided services as one pool, development/workloads as another. The pool set is open: additional pools are named when a need clears the bar; none are pre-declared here (see Open Questions). This reframing drives everything that follows: nodes are organized by purpose AND access, and the ACL question becomes “which pools can which users and services reach.”
3. ACLs are pool-based, not environment-tag-based
The access model follows the pools: grants are written in terms of who may reach which pool, in which direction. Two concept principles, without enumerating the pools themselves:
One pool per node. A node belongs to exactly one pool. Cross-pool reach is an explicit, named grant — never a second membership. This keeps the ACL table non-ambiguous: a node’s pool is its identity, and exceptions are visible rules rather than overlapping tags.
Directional by default. Workload → provided services is the natural direction and the common grant (dev needs to reach the things the environment provides). Services → workload is a named exception, not part of the default posture. Reverse-direction access is the thing that erodes the separation, so it must always be a visible rule.
The pool taxonomy itself — names, membership, concrete grants — is deliberately unspecified in this ADR. It is a follow-on design task (see Consequences).
4. Separate tailnet = deferred off-ramp
If the environment grows and outside resources need to manage elements, the workload moves to a new set of devices in a separate tailnet — the homelab tailnet untouched. The earlier draft’s separate-tailnet analysis is retained as the off-ramp design: structural isolation, independent policy file, clean audit boundary, external-group sharing without exposing private applications. Its alpha dependency (declarative node sharing) is acceptable once the boundary is real, because it then sits on a workload path that is allowed to degrade — the same risk posture, just correctly timed.
The pool taxonomy chosen now is the seam that cut will follow: pools map cleanly to future tailnet membership, and a split becomes a placement/policy exercise rather than a redesign. Policy-file CI should be in place first — a split that is mostly policy movement is only cheap if the policy is already tested.
Why this succeeds where ADR-0006 failed
Concern
ADR-0006 (environment split)
This ADR (pools)
Admin/Komodo paths
Cut by the boundary — homelab manageability behind an alpha feature
Untouched; all stay in the homelab tailnet
Existing Services layer
Rewritten across three tailnets
Untouched
Partner/guest access
Complicated by tailnet-qualified DNS
Link-based sharing, unchanged from today
Alpha dependency
On the critical management path
On the deferred off-ramp only
Migration cost
Three tailnets, full grants rewrite
Zero now; the split is a future, triggered exercise
Blast radius
Hypothetical (bad grant)
Contained within pool grants — and CI-testable
Future option: separate tailnet (deferred, priced)
Noted, not built. When the trigger fires — outside resources managing elements, or sustained external-party growth — execute the off-ramp: new device set in a separate tailnet, provisioned via the API chain (OAuth exchange → create tailnet → httpsEnabled → tagOwners/policy → scoped auth keys), homelab tailnet untouched, cross-boundary access via declarative node sharing. Joining the waitlist for declarative sharing is only needed when the trigger becomes real; doing it now starts a clock for a feature nothing active depends on.
Open questions
Pool taxonomy — deliberately unspecified here. The concrete pools, names, membership, and grants are the follow-on design task. Implicit constraint from this ADR: no “miscellaneous” pool; a catch-all pool is how pools rot into a flat network.
Does the partner need any workload access? Default assumption: no — workload pools are operator-only. Link-based sharing remains available per-need; the answer affects whether the future split needs external-group modeling at all.
Exit node — does workload need its own, or use none?
Split trigger threshold — what concretely constitutes “outside resources managing elements”? Naming the threshold now makes the off-ramp decision unemotional later.
Quarantine semantics / synced groups — relocated to the off-ramp; only apply once a boundary exists.
Consequences / follow-on work
Design the pool taxonomy and draft the HuJSON policy — tracked TODO, not done here. A policy skeleton with the pool structure and directional grants, reviewed against the principles above.
Policy-file CI with ACL tests — now more load-bearing than under either prior framing: pools keep isolation advisory, and CI is what converts it into tested isolation. Ban tag:* destinations, require review on the policy file, map pool grants to tests, run in GitHub Actions.
Update tailscale-grants.md once the pool taxonomy is concrete; convert the environment-tag framing to pool framing.
Update tailscale.md to document workload pools, link-based sharing for guests, and the deferred-split framing.
Do not join the declarative-sharing waitlist yet — retracted from the earlier draft; only needed once the off-ramp trigger is real.
Rename Dev → Workload in homelab docs, and be explicit that it is outside the promotion chain, so it isn’t mistaken for a pipeline stage.
Revisit ADR-0001: if Dev leaves the promotion chain, the per-environment ResourceSync branch layout has one fewer environment to steer.
References
ADR-0006: Tailnet-per-environment — the rejected environment split; contains the link-based vs. declarative sharing mechanism comparison and the API provisioning details
Declarative node sharing — alpha, waitlist-gated; the off-ramp’s cross-boundary mechanism, not active today
ADR: Orchestration Model — Docker-native vs Kubernetes/ArgoCD for the Homelab
Context
The homelab currently standardizes on Docker (rootful, on Fedora Server baremetal hosts) managed through Komodo for GitOps. The prior narrative decision (“Komodo over Kubernetes (K3s + ArgoCD)” in planning/decisions.md) chose Docker-native Komodo over a K3s + ArgoCD stack on the grounds of complexity, risk tolerance, and concept alignment with existing familiarity with compose files.
This ADR reopens that axis. Two things have shifted the picture:
Declarative lifecycle is now a hard requirement, not a nice-to-have. The goal is to easily and declaratively spin up / tear down / manage resources across environments. That is exactly what native GitOps (ArgoCD reconciling manifests) does out of the box — and what Komodo does with more manual steering (ResourceSync branch gymnastics, per-environment TOML edits on merge, see ADR 0001).
ArgoCD is the more common / more documented route than Komodo. If the operational surface is “declare desired state, let a controller reconcile,” ArgoCD is the industry-default implementation. Komodo gets us most of the way but is a smaller ecosystem with rougher edges (Podman compat, stack-status quirks).
So the earlier “Kubernetes is overkill for a homelab” conclusion is being stress-tested against a simpler question: is the thing we’re optimizing for simplicity of the runtime or simplicity of declarative management? Those pull in different directions.
The tension
Axis
Docker + Komodo
Kubernetes + ArgoCD
Runtime complexity
Low; Compose, one socket, familiar
High; control plane, CRDs, more failure modes
Declarative spin up/down
Possible, but needs branch/merge hygiene (ADR 0001)
Similar; kubectl apply / Git commit, controller reconciles, but follows a similar git architecture
The salient counterpoint to the original decision: Docker is less complex to run, but it can mean more steps to manage compared to native GitOps. The “overkill” argument was about runtime footprint; it does not directly address declarative management ergonomics, which is now the priority.
Options considered
Option A: All Docker-native (Komodo)
Standardize everything on Docker + Komodo ResourceSync.
Pros: lowest runtime complexity; builds on existing Compose investment; Dev OCP cluster still provides separate K8s skills.
Cons: declarative management is real but requires the merge-hygiene rituals from ADR 0001; smaller ecosystem; no first-class “one command spins up the whole graph.”
Option B: All Kubernetes (K3s/K8s + ArgoCD)
Replace Komodo with a K3s or K8s control plane and ArgoCD as the GitOps controller for every workload.
Pros: single, ubiquitous declarative model; ArgoCD is the common, well-documented path; spin up/down is a Git commit; one pane of glass for everything.
Cons: real operational complexity (control plane, CRDs, more moving parts); the original “overkill for homelab” risk is still valid; more failure modes to own 24/7.
Option C: Split model (leading direction, not yet decided)
Use Docker for “prod apps” and Kubernetes/ArgoCD for “workload apps.”
Keeps the low-friction Docker path for stable, user-facing services (Plex, etc.) where runtime simplicity matters most.
Puts the heavier / more dynamic / more experiment-driven workloads on K8s/ArgoCD where declarative reconciliation pays off and aligns with work skills.
The only clear advantage of “all Kubernetes” (Option B) over this split is the single pane of glass — ArgoCD as the one view of everything. That is a real UX win, but it is currently the only decisive argument for unifying onto K8s, and it may not outweigh the standing complexity cost.
Decision
No conclusion yet, this ADR is intentionally left open.
The leading direction is Option C (split model), driven by the desire for easy declarative management without forcing the entire homelab onto a K8s control plane it may not need. The single strongest argument for abandoning the split and going all-in on Kubernetes (Option B) is the unified ArgoCD dashboard; that has not yet been judged worth the operational cost.
What would move this from Draft to a decision:
A concrete definition of which apps count as “prod apps” (Docker) vs “workload apps” (K8s); the boundary is currently intuitive, not enumerated.
A validated K3s + ArgoCD bring-up path on the homelab hardware (the original decision flagged K3s-in-prod as risky; that risk needs a fresh look).
A judgment on whether “single pane of glass” is worth the control-plane burden, or whether two dashboards (Komodo + ArgoCD) is acceptable.
Consequences (potential)
If Option C is adopted:
Two management planes to operate (Komodo for Docker, ArgoCD for K8s) — more tooling surface, but each stays within its comfort zone.
Prod apps keep the low-ceremony Docker/Compose lifecycle.
Workload apps get first-class declarative reconciliation and stronger skills transfer to the work OCP stack.
Loses the “one ArgoCD view of everything” that Option B would provide.
If Option B is adopted instead:
Single declarative model and dashboard for the whole homelab.
Pays the standing K3s operational cost the original decision sought to avoid.
Supersedes the “Komodo over Kubernetes” narrative in decisions.md — that section would need revisiting.
Open Questions
Where exactly is the “prod app” vs “workload app” line drawn?
Does “prod app” vs “workload app” need to be an treated as alternative approach to my originally declared “Prod/Test/Dev” environments from ADR 0001?
Idea: If this is the case, it would call into question the order of evolution in regards to hardware, with “Prod” still running on a dedicated device, having “Test” running within the ProxMox cluster, and having “Workload/Dev” on a larger version of the OCP cluster.
Is the K3s-in-prod risk from the original decision still valid, or has the operational picture changed?
If my personal work is focused largely on kubernetes, will it be better to have everything involved with kubernetes, or will having “prod apps” in docker (option C) encourage me to not mess with prod apps and focus my time on just the kubernetes side of the field?
Is there a “single pane of glass” option that can manage both docker and kubernetes, or at least have visibility on both?
Does losing a single pane of glass (under the split) actually hurt day-to-day, or is it tolerable?
Does this reopen the runtime question (ADR 0003 / 0002) for the K8s-hosted workloads, or do they stay rootful Docker-in-VM / containerd as K8s dictates?
ADR: Skip local Docker registry, focus on git pipeline
Context
The homelab needs a strategy for managing container images between the local environment and production. Initial considerations included running a local container registry (Artifactory/Lightwell analog) or relying on Docker Hub with version pinning. The homelab already uses Komodo GitOps, GitHub Actions for rendering, and Ansible validation playbooks.
Goals:
Avoid infrastructure overhead — A local registry adds a running service, storage management, and maintenance burden for a feature (offline access) that isn’t actually required.
Leverage existing pipeline investment — GitHub Actions, Ansible validation, and Komodo ResourceSync are already in place and can be extended to cover image governance.
Security evaluation through PR process — Image scanning and review can happen in Git before anything gets deployed, rather than after images sit in a registry.
Version pinning is already the pattern — Komodo configs and Git branches already provide effective version control; a registry would be redundant.
Decision
Skip running a local Docker registry as a stop-gap. Instead:
Pin images explicitly in Komodo TOML configs (e.g., nginx:1.25-alpine instead of latest) — this is the primary guardrail.
Extend the existing Git pipeline to include:
Image scan on PR (Trivy or Anchore)
Push approved images to Docker Hub / existing registry with pinned tags
Komodo picks up the new commit SHA / tag on sync
Use Docker Hub rate-limit-friendly practices — pull once, cache locally via Docker’s built-in store, and pin versions.
Revisit local registry later only if offline access or rate limits become actual problems.
Considered alternatives
Approach
Security Evaluation
Version Pinning
Offline
Maintenance
Local Docker registry
Manual (ad-hoc scanning)
✅ Config-based
✅ Yes
Low-Medium
Docker Hub + pinning
Manual (ad-hoc scanning)
✅ Config-based
❌ No
Low
Git pipeline + image scanning
✅ Automated (PR gate)
✅ PR-approved tag
✅ CI cache
Medium
Artifactory/Lightwell
✅ Scanning + promotion
✅ Pinning
✅ Yes
High
Consequences
Positive
No extra running service or storage to manage
Security gates move left — images are scanned and reviewed in PR before any deployment
Existing Git/GitHub Actions investment leveraged; no new infra needed
Version pinning already the pattern via Komodo configs; registry would be redundant
Simpler onboarding for new services — just add to pipeline, no registry config needed
Negative / risks
Image scanning depends on CI runner availability and speed
If Docker Hub rate limits become problematic, no local cache (but this hasn’t been an issue in practice)
Security evaluation relies on PR review quality — same as any code change
Mitigations
Add Trivy scan to existing GitHub Action as a checklist step — fail PR if critical vulnerabilities found
Cache scanned results in GitHub Actions artifact so subsequent scans are fast
If rate limits bite, add a registry later as a targeted fix (not infrastructure upfront)
Document the “pinning is the guardrail” principle so teams don’t revert to latest
Status
Accepted — proceed with extending the Git pipeline for image governance instead of running a local registry.
The homelab needs a host OS for baremetal Docker nodes (LenovoMini 1 for Prod, LenovoMini 2 for Test, and the DevDocker VM on ProxMox). Requirements:
RPM/DNF-based — user preference, already familiar with the ecosystem.
Komodo GitOps compatibility — Periphery agent must be able to manage container workloads.
Low-maintenance — set once and forget; not something to constantly troubleshoot.
Easily automatable — host provisioning should be reproducible via Ansible, including Periphery agent setup.
Options Considered
Host OS
Option
Release Cadence
Support Window
Notes
Fedora Server
~6mo
~13mo
Latest kernel + tooling, frequent upgrades
Rocky / Alma Linux
Major (9.x)
~10yr
RHEL-compatible, boring & stable
Fedora CoreOS
Auto-updating streams
Per stream
Immutable, no package management at runtime
CentOS Stream
Rolling between RHEL releases
Continuous
Between Fedora and RHEL
Container Runtime
Factor
Docker
Podman
Komodo Periphery support
First-class (native socket or API)
Rough — community workarounds only (DOCKER_HOST to socket emulation)
Rootless
Available (opt-in via dockerd-rootless-setuptool.sh)
Built-in, rootless by default
Compose
Docker Compose v2
podman-compose or podman play kube
Research Findings
Podman + Komodo Compatibility
After investigation, Podman support in Komodo relies on workarounds:
Requires DOCKER_HOST env pointing at Podman’s Docker API socket (/run/user/$UID/podman/podman.sock)
Stacks may incorrectly show as “DOWN” after successful deploy due to compose network label differences (GitHub issue #504)
No first-class testing or documentation from Komodo maintainers
Conclusion: Support is too rough for a low-maintenance setup
Rootful vs Rootless Decision
Rootless Docker was initially preferred for security, but three factors drove the switch to rootful:
No practical benefit for a homelab — all workloads are user-deployed and trusted. The socket is already protected by host-level access controls (SSH, firewall, physical security).
Avoids community-driven maintenance burden — rootless Docker with Komodo Periphery relies on community patterns not official docs. Rootful is the documented, tested path.
Simplifies Ansible automation — rootful Docker is a single dnf install + daemon enable. No dockerd-rootless-setuptool.sh, no loginctl enable-linger, no environment variable wiring.
Host OS Alignment
Fedora Server ships recent kernel + DNF, packages are fresh enough for both the Docker CE repo and Ansible management
Rocky/Alma would work but are conservative; no advantage for this use case
Fedora CoreOS is immutable — no package install at runtime, conflicts with Ansible-based provisioning
Decision (Provisional)
Fedora Server for the host OS — DNF-native, recent kernel, familiar tooling, easy Ansible provisioning.
Docker (not Podman) for the container runtime — Komodo’s first-class support removes any reason to fight Podman compatibility.
Rootful Docker — simplicity wins for a homelab with trusted workloads. No port restrictions, no privileged container limitations, no extra setup steps.
Containerized Periphery — deployed as a Docker container managed by Ansible, with /var/run/docker.sock mounted for API access. Updates via image pull, not manual binary replacement.
Implementation: Containerized Periphery via Ansible
Periphery runs as a Docker container, managed by the same Ansible playbook that provisions the host. The Komodo maintainer’s filesystem semantics concern (discussion #220) applies to rootless Docker where user namespaces cause path remapping — with rootful Docker, container and host paths are the same, so the concern doesn’t apply.
Ansible Role Structure
The host provisioning playbook will include a role komodo-periphery that handles Periphery as a container:
network_mode: host — Periphery needs to reach containers by their host-level ports for monitoring. Host networking avoids port mapping conflicts and simplifies the network topology.
/var/run/docker.sock mount — gives Periphery the standard Docker API access. With rootful Docker this is the native socket, no indirection needed.
/:/mnt/host:ro — bind mounts the host root so Periphery can read filesystem stats for disk monitoring.
restart_policy: unless-stopped — survives reboots and daemon restarts.
The config template is rendered by Ansible with host-specific variables (core address, hostname, onboarding key), sourced from Ansible vault or group vars.
SELinux may block container socket access. On Fedora Server with SELinux enforcing, the Periphery container needs the :z flag on volume mounts or a dedicated SELinux policy. If Periphery fails to connect, check ausearch -m avc and add :z to the socket mount: "/var/run/docker.sock:/var/run/docker.sock:z".
Host networking limits to one Periphery per host. With network_mode: host, port 8120 is consumed directly on the host. Only one Periphery instance can run per machine. This is the expected topology — one agent per host.
Periphery container image tag strategy. Use a specific version tag (e.g., :0.2.5) not :latest for reproducible deployments. Update by bumping the version in Ansible vars and re-running the playbook.
Onboarding key rotates. The onboarding_key in [[connections]] is a one-time use key from the Komodo Core UI. After first connection, Periphery persists its session — the key is no longer needed. If re-deploying from scratch, generate a new key.
Config changes require container restart. If periphery.config.toml changes, Ansible must trigger a container restart. The docker_container module handles this automatically when config changes are detected via template diff.
Ansible vault for secrets. The onboarding key is sensitive and should be stored in Ansible vault, not in plaintext group vars.
Consequences
Positive:
Familiar RPM/DNF management
Komodo Periphery has native, first-class support for rootful Docker
All workloads work without restriction — privileged containers, ports <1024, cgroup limits
Containerized Periphery auto-updates via docker pull; Ansible handles version pinning
Ansible-driven provisioning means new hosts are reproducible
No special setup beyond standard Docker CE install
Negative / risks:
Docker socket is root-equivalent — compromised container has host access. Mitigated by homelab trust model (all workloads are user-deployed).
Containerized Periphery adds a Docker-in-Docker layer that the Komodo maintainer flags for potential filesystem confusion, but with rootful Docker (no user namespace) this is not a practical concern.
Fedora ~13mo support window means OS upgrades every ~6-12mo (acceptable for homelab).
Periphery config changes need container restart — automated via Ansible.
Open Questions
Which Periphery image tag to pin initially? Start with :latest during bring-up, pin to a specific version once stable.
How to handle Periphery version upgrades? Ansible var bump + playbook re-run. Need a process for checking upstream releases.
Selecting a container runtime for baremetal Docker hosts involves balancing security, operational convenience, and tooling compatibility. This ADR breaks down three approaches and the fundamental tension between central-socket management and daemonless isolation.
Native docker compose v2 — works within user namespace
Podman (Daemonless)
No central daemon. Each container is launched directly by the user’s Podman client.
Aspect
Detail
Socket
None by default — containers are child processes of the user’s shell
Daemon
No daemon — podman system service can emulate a Docker API socket on demand
Root model
Rootless by default — no user namespace needed, containers run as the calling user
Komodo support
Workarounds only — DOCKER_HOST to emulated socket, known bugs (stack status, network labels)
Setup complexity
Minimal for CLI — dnf install podman. Complex for management tooling (need podman system service as a systemd unit)
Compose
podman-compose or podman play kube — not drop-in compatible
Analysis
Security: The Central Socket Risk
A Docker socket is equivalent to root access on the host. Any process that can write to the socket can:
Start containers with host filesystem mounts (read/write any file)
Create privileged containers that break out of isolation
Modify iptables, network namespaces, cgroups
Access secrets mounted into any container
Rootful Docker places this power on a single, well-known path (/var/run/docker.sock). The docker group is effectively root. The blast radius of a compromised process with socket access is the entire host.
Rootless Docker mitigates this through user namespaces. The daemon and all containers run within the user’s namespace — root inside a container is the user outside. A compromised socket can’t escape the user namespace, so the blast radius is that user’s resources only. However, it’s still a central daemon — if the rootless dockerd process is exploited, all that user’s containers are compromised.
Podman eliminates the central socket entirely. No process management attachment, no daemon to target. Each container is a child of its launching process. The blast radius is per container — no socket to poison. To use management tooling, you must explicitly start a podman system service socket, which reintroduces a central endpoint — but it’s opt-in, not the default.
Management: Daemonless vs. Central Socket
This is the inverse of the security tradeoff:
Runtime
Management model
Tooling friction
Docker (rootful)
One socket, one daemon, everything works
Zero — every tool speaks Docker API natively
Docker (rootless)
One socket per user, one daemon per user
Low — set DOCKER_HOST, same API
Podman
No socket, no daemon, containers are child processes
Higher — no standard API without emulation layer
Without a central socket, management tooling faces a discovery problem:
How does Komodo’s Periphery find the container runtime? It can’t — it needs an explicit path to podman system service socket, which must be running as a systemd user service.
How does docker ps / podman ps enumerate running containers across a session? Podman uses a per-user boltdb database, but there’s no persistent daemon registering container state.
How do compose stacks get tracked? Podman’s compose support (podman-compose) uses a different project model than Docker Compose — Komodo’s stack status detection breaks (issue #504).
With a central socket, management is trivial: connect to the well-known path, issue standard API calls, get predictable results. The cost is the security risk described above.
Operational Considerations
Criterion
Docker (rootful)
Docker (rootless)
Podman (daemonless)
Komodo compatibility
Native
Configurable
Workarounds
Socket security
Dangerous (root equiv.)
Safer (user namespaced)
None by default
Privileged containers
Full support
Blocked
Blocked (rootless)
Port <1024
Native
Workaround needed
Workaround needed
Systemd integration
Manual
Manual
Native (podman generate systemd)
cgroup management
Full
Limited
Limited (rootless)
Builds
Native
Native (BuildKit)
Native (Buildah)
Swarm mode
Supported
Blocked
N/A
Docker Compose
Native
Native
Partial (podman-compose)
Multi-arch builds
Native
Native
Native (Buildah)
Recommended Direction
This ADR does not make a standalone decision — it informs ADR 0002. The current direction:
Docker (rootful) for maximum simplicity and tooling compatibility. The socket-security risk is accepted as an acceptable tradeoff for a homelab with trusted workloads.
Podman is the more secure architecture by design, and rootless Docker is a middle ground. Rootful Docker optimizes for operational simplicity — no setup ceremony, no port restrictions, no privileged container limitations. For a homelab where all workloads are user-deployed and trusted, the central-socket risk is manageable.
If the homelab expands to include untrusted or multi-tenant workloads, this should be revisited — rootless Docker or Podman would contain blast radius in a way rootful Docker cannot.
Consequences
If Docker (rootful) is adopted (current direction):
All management tooling works natively with zero configuration — standard Docker API on the well-known socket
Setup is minimal — dnf install docker, enable daemon, run containers
No workload restrictions — privileged containers, ports <1024, cgroup limits all work
Socket is root-equivalent — any process with socket access owns the host. Acceptable for homelab trusted workloads.
If Docker (rootless) or Podman is reconsidered later:
Stronger isolation — compromise is contained to a user namespace or individual container
Some workloads (privileged containers, Swarm mode) are restricted
ADR: Per-environment Git branches via Komodo ResourceSync
Context
The homelab uses Komodo with ResourceSync to declare servers, stacks, deployments, and related resources from Git (an “app-of-apps” style flow: Git remains the audit trail; Komodo reconciles).
Goals:
main shows the whole picture — dev/, test/, and prod/ (or equivalent komodo/{dev,test,prod}/) layouts exist together on main so reviews and long-lived docs match reality after merges.
Per-environment steering — each environment’s sync can follow main or follow a named branch (for example to soak a stack change in Dev before it lands on main).
Homelab-realistic ergonomics — accept some manual merge hygiene instead of building full release automation up front.
An earlier sketch of this approach lived in wip/komodo-artifact-templates/scratch/branch_management_ideas.md.
Decision
Single infrastructure repo; main is the default line of truth After work merges, main holds the canonical layout for all managed environments (folder-separated configs, per existing repo structure docs).
One ResourceSync configuration per Komodo-managed environment (Dev, Test, Prod — or whichever environments are GitOps-managed) Each [[resource_sync]] (or equivalent Komodo sync configuration) sets, at minimum:
Repository (and git provider / account as required).
Branch (or other ref field supported by Komodo’s ResourceSync schema — see Resource sync config) independently per environment.
Resource path scope limited to that environment’s subtree (for example only komodo/dev/** for Dev), so a branch used for experiments does not accidentally apply Prod definitions.
Branch-testing workflow
Create a feature branch for the change under test.
Point the target environment’s ResourceSync at that branch (for example Dev follows feature/xyz while Test and Prod continue to follow main).
After validation, merge the feature branch to main.
Manually reconcile the ResourceSync metadata on main so environments that should track main again have their branch/ref set back to main (or another agreed default).
Known quirk (accepted): the ResourceSync TOML that sets branch = "feature/xyz"lives in Git. On the feature branch that file typically must reference the same branch so the sync pulls a self-consistent tree. After merge to main, that same file’s branch field must be updated to main (or removed to default) for environments that should no longer track the feature branch. This is intentional small, explicit edits on main, not hidden automation, until/unless you add tooling.
Releases / tags as a pin for Prod (optional, not required now) Using tags or GitHub Releases so Prod tracks refs/tags/v… while main moves forward is a valid pattern when you want Prod immutability without branch gymnastics. With separate per-environment directories and per-sync branch control, the urgency is lower than in a single-shared-path model. We defer mandatory release-based promotion; revisit if main churn becomes painful for Prod.
Consequences
Positive
Clear mental model: folders = promotion path; branch on ResourceSync = “which revision of that tree is live for this environment.”
Feature work can soak on Dev/Test without forking the whole repo layout conceptually on main.
Decisions stay in Git and stay reviewable.
Negative / risks
Merge hygiene: forgetting to repoint a ResourceSync after merge can leave an environment tracking a stale or abandoned branch.
Self-consistency: sync config and synced paths must agree on branch, or behavior is confusing during transitions.
Mitigations (recommended, non-blocking)
Short checklist when merging infra PRs: “ResourceSync branches updated for Dev/Test/Prod as intended?”
Keep ResourceSync definitions in small, dedicated files (for example under komodo/sync/ or similar) so diffs are obvious.
Optionally add CI later (grep/assert expected branch for Prod) if mistakes recur.
Alternatives considered
Alternative
Why not chosen (for now)
Prod pinned only by releases/tags
Valuable later; deferred to reduce moving parts until branch steering is proven in daily use.
Separate repos per environment
Duplicates structure; conflicts with single-repo promotion story already documented.
Only ever sync from main
Forces feature testing via copy/paste or temporary files outside Git flow; rejected.
Network connectivity strategy for the homelab using Tailscale mesh VPN.
Overview
Tailscale is a mesh VPN built on WireGuard that creates a secure network (called a “tailnet”) across devices regardless of their physical location. Unlike traditional VPNs, there’s no central server that all traffic flows through — devices connect directly to each other.
This homelab leverages Tailscale Services as the primary mechanism for exposing applications, with ACL-based access control separating user-facing interfaces from administrative access.
Key Concepts
Tailnet — Your private network of connected devices
MagicDNS — Automatic DNS for devices (e.g., device-name.tailnet-name.ts.net)
Tailscale Services — Named services advertised to the tailnet, accessible via MagicDNS
ACLs — Access control lists defining who can reach what
Funnel — Expose services to the public internet through Tailscale
Serve — Expose local services to your tailnet
Advantages Over Traditional VPNs
Traditional VPN
Tailscale
Hub-and-spoke — all traffic through central server
Mesh — direct device-to-device connections
Central server is bottleneck and single point of failure
No central infrastructure to fail
Complex setup (certificates, port forwarding, firewall rules)
Zero-config in most cases
Requires exposing a port to the internet
NAT traversal built-in, no exposed ports
VPN server needs to be always on
Coordination server only for handshake, not traffic
All-or-nothing access
Granular ACLs per user/device/service
Why this matters for homelab:
No need to open ports on home router
Access homelab from anywhere without complex setup
Granular control over who accesses what
Works even when home IP changes
Tailscale Services
Tailscale Services are the primary mechanism for exposing applications in this homelab. Rather than exposing entire hosts or subnets, individual services are advertised to the tailnet with their own DNS names.
Why Services Over Hosts
Approach
Exposure
Granularity
Host-based
Entire machine accessible
All ports, all services
Subnet routing
Entire network accessible
Everything on the subnet
Services
Single application endpoint
One port, one purpose
Services provide the tightest blast radius — if a service is compromised, the attacker gains access to that service only, not the host or network.
Service Naming Convention
Services are accessible via MagicDNS:
<service-name>.tailnet-name.ts.net
Examples:
actual.tailnet.ts.net — Actual Budget
plex.tailnet.ts.net — Plex Media Server
komodo.tailnet.ts.net — Komodo UI (admin-only)
Container Exposure Patterns
Three patterns for connecting containerized services to Tailscale:
Docktail — Service Advertisement
Docktail advertises containers as Tailscale Services. Use this when:
The application needs to be reachable by name from the tailnet
Multiple users/devices will access the service
You want the service in MagicDNS
Docktail watches for Docker labels and advertises matching containers as services.
ScaleTail — Per-Service Sidecar Exposure
ScaleTail provides Docker Compose configurations using the sidecar pattern — each service gets its own Tailscale container via network_mode: service:. The sidecar can advertise the service to the tailnet with Tailscale Serve.
Use this when:
You want per-service Tailscale instances (not centralized)
The container needs both inbound and outbound tailnet access
You want explicit control per service
Outbound-Only Sidecar
A sidecar container that joins the tailnet but does not advertise a service. Use this when:
The container needs to reach other tailnet resources
The container should not be directly addressable
You need tailnet access for configuration pulls, secrets, etc.
Example: A container that pulls secrets from Vault over Tailscale but doesn’t need inbound connections.
Choosing Between Them
Need
Pattern
Users access this app, want centralized
Docktail (labels-based)
Per-service control, inbound + outbound
ScaleTail (sidecar pattern)
App needs tailnet access, no inbound
Outbound-Only Sidecar
Both inbound and outbound (centralized)
Docktail (service already has tailnet access)
High Availability with Services
Tailscale Services can be advertised from multiple machines for high availability. When the same service name is advertised from multiple nodes, Tailscale routes traffic to an available instance.
Services that benefit from multi-node advertisement:
Reverse proxies / ingress
Stateless APIs
Load-balanced workers
Services that should remain single-node:
Stateful databases (unless using clustering)
Apps with local storage dependencies
Access Tiers
Not all users should access all interfaces. This homelab distinguishes between user-facing UIs and admin portals.
Role Definitions
Role
Description
Example Users
user
Can access consumer-facing application UIs
Partner, guests
manager
Can access app admin panels, not infrastructure
—
admin
Full access including infrastructure management
Owner
Interface Separation
Interface Type
Accessible To
Examples
User Web UI
All tailnet users
Plex, Actual Budget, Jellyfin
App Admin Portal
managers, admins
Plex admin, *arr settings
Infrastructure UI
admins only
Komodo, Proxmox, router admin
SSH/Console
admins only
All hosts
Implementation Approach
ACLs enforce access tiers by:
Grouping users into roles (groups in ACL policy)
Tagging services by interface type
Granting access based on role → tag mappings
See [[tailscale-grants]] for the full grants policy document — this section defines the model, not the implementation.
Subnet Access Strategy
Subnet routing exposes entire network ranges to the tailnet. This is powerful but dangerous — it bypasses per-service access control.
When to Use Subnets
Subnet routing is appropriate for:
Admin management of devices that can’t run Tailscale (switches, APs, IoT devices)
Legacy systems that predate the Tailscale deployment
Temporary access during migrations
Admin-Only Subnet Access
Subnet routes are advertised but restricted to admins only via ACLs. Regular users and managers cannot reach devices via subnet routing — they must use advertised services.
This preserves the principle of least privilege: users access applications, admins access infrastructure.
Subnets vs Services
Access Method
User
Manager
Admin
Tailscale Services
Per-ACL
Per-ACL
Yes
Subnet Routes
No
No
Yes
Exit Node Strategy
Exit nodes route all device traffic through a tailnet node. Useful for:
Appearing to be “at home” when traveling
Using home DNS/Pi-hole from anywhere
Accessing geo-restricted content
The Exposure Risk
An exit node that also advertises subnet routes or services creates a risk: a device using the exit node could potentially reach resources it shouldn’t.
Mitigation: Dedicated Exit Node
Run exit node functionality on a dedicated node that:
Advertises itself as an exit node
Does not advertise subnet routes
Does not run Docktail or advertise services
Has minimal local services
This isolates the exit node function from service exposure. Traffic exits to the internet through the node, but doesn’t gain access to internal resources.
┌─────────────────────────────────┐
│ Exit Node (dedicated) │
│ │
│ ✓ Exit node enabled │
│ ✗ No subnet routes │
│ ✗ No Docktail │
│ ✗ No local services │
│ │
│ Internet ◀── traffic exits │
└─────────────────────────────────┘
Alternative: ACL-Controlled Exit
If a dedicated node isn’t practical, ACLs can restrict what exit node users can reach. But this is more complex and easier to misconfigure — the dedicated node approach is preferred.
Use Cases
Remote Application Access
Applications are exposed as Tailscale Services via Docktail:
Phone/Laptop ──(Tailscale)──▶ actual.tailnet.ts.net ──▶ Actual Budget container
No public exposure
Accessible from any device on the tailnet
Access controlled by ACLs based on user role
Remote Device Access
Tailscale SSH:
SSH into any device on the tailnet without managing SSH keys
Tailscale handles authentication via identity provider
Restricted to admins via ACLs
ssh user@device.tailnet.ts.net
Management UIs (admin-only):
Proxmox, Komodo, router admin panels
Accessible only to admin role
Either via advertised service or subnet route
Remote File Access
SMB shares:
Access Windows/Samba shares from anywhere
smb://nas.tailnet.ts.net/share
ACLs control which shares are accessible to which roles
NFS mounts:
Mount NFS shares over Tailscale
Performance may degrade over high-latency connections
Shared Access (Partner)
Partner has her own Tailscale account and devices, shared into the tailnet with controlled access. She is a member of the user role.
Adversarial Access Model
Treat partner’s access as adversarial by default — not because of distrust, but because:
We need a container runtime for local development, CI/CD pipelines, and homelab deployment. The ecosystem has two primary open-source contenders: Docker (the incumbent) and Podman (the rising alternative). Both implement OCI standards and can run the same container images, but differ significantly in architecture, security model, and ecosystem integration.
Key evaluation criteria:
Security posture (rootless vs rootful by default)
Daemon architecture (monolithic vs fork/exec)
Docker-compatibility and migration friction
Desktop GUI experience (Docker Desktop vs Podman Desktop)
Defer — no single winner. The choice depends on the deployment context:
Context
Recommendation
Why
Local dev (macOS)
Docker Desktop
Better UX, tighter IDE integration, mature volume mounts
Local dev (Linux)
Podman
Native rootless, no daemon tax, distro-packaged
CI/CD pipelines
Docker-in-Docker
Industry standard, broadest runner support
Homelab server
Podman
Systemd integration, rootless by default, lighter footprint
Kubernetes dev
Podman
Built-in pod concept, better k8s alignment
Rationale
Security
Podman: Rootless by default. Each user gets their own namespace — no central daemon with root-equivalent access. This is a fundamental architectural advantage for multi-tenant systems and security-conscious deployments.
Docker: The daemon (dockerd) runs as root by default. Adding users to the docker group is effectively root-equivalent. Rootless mode exists but is opt-in and less mature.
Architecture
Docker: Client-server model. docker CLI → dockerd daemon → containerd → runc. The daemon is a single point of failure and a persistent resource drain.
Podman: Fork/exec model. Each podman command directly spawns container processes via conmon + runc/crun. No persistent daemon. This means containers are systemd-manageable and don’t die if a daemon crashes.
Docker-Compatibility
Podman implements the Docker CLI API (alias docker=podman works for most commands), supports docker-compose (via podman-compose or podman machine), and can pull/build/run any OCI image.
Edge cases exist: Volume mount semantics differ, networking stacks diverge (podman uses pasta/slirp4netns vs Docker’s built-in bridge), and some Docker-specific features (Swarm mode, BuildKit advanced features) have no Podman equivalent.
Desktop Experience
Docker Desktop: Polished, all-in-one GUI. Includes Kubernetes cluster, volume management, registry browser, Dev Environments, and extensions ecosystem. Paid license for commercial use >250 employees or $10M+ revenue.
Podman Desktop: Open-source GUI, improving rapidly. Supports Podman and Docker backends, Kubernetes integration, extensions. Lighter but less feature-rich. Free for all use.
Ecosystem & Governance
Docker: Docker, Inc. Owns the developer mindshare. Vast documentation, tutorials, Stack Overflow presence. Docker Hub is the default registry. Licensing changes (2021 commercial license) caused community friction.
Podman: Red Hat–backed, part of the libpod project. CNI-aligned networking, systemd-native. Growing adoption in enterprise Linux spaces (RHEL, Fedora). No corporate licensing tension.
Consequences
We may need to support both runtimes depending on environment. A docker alias or abstraction layer (CONTAINER_RUNTIME env var) can ease transitions.
Compose v3 files are broadly portable; v4 features may not be. Stick to v3 for cross-runtime compatibility.
Podman’s macOS experience relies on a Linux VM (via podman machine or podman desktop), similar to Docker Desktop but less polished. Volume mounts and port forwarding are slower on macOS.
If Kubernetes is the long-term target, Podman’s pod-level semantics map more naturally to k8s concepts.
Docker is still the default for most CI/CD providers. Using Podman in CI requires explicit runner configuration.
AI VM Architecture
Overview
A dedicated Debian VM with an NVIDIA Quadro P5000 passed through, running LlamaFarm natively as the AI management layer. The VM is fully disposable — Ansible provisions the OS and installs tooling; models and data live on persistent storage. If the VM is destroyed, a fresh provision + persistent volume mount restores full capability in minutes.
VM Specifications
Component
Minimum
Recommended
Notes
vCPU
4 cores
8+ cores
Inference benefits from more cores for prompt processing
RAM
16 GB
32 GB
~16 GB for OS + LlamaFarm services + 8B model inference + KV cache
Root disk
40 GB
64 GB
OS + LlamaFarm service binaries (~3-5 GB) + swap space
Model/data disk
100 GB
256 GB
Persistent volume for model weights + vector store + RAG data
GPU
Quadro P5000
Quadro P5000
16 GB GDDR5X VRAM — comfortable for 7B-13B quantized models
11.x or 12.x (Pascal supports both; use 12.x for upstream llama.cpp compatibility)
PCIe
Gen3 x16 — ensure host has an available slot and IOMMU group isolation
TDP
180 W — account for cooling in the chassis
Important: Pascal (6.1) does not support bfloat16 natively. LlamaFarm’s Universal Runtime will use fp16 or fp32 fallback. Use GGUF quantized models (Q4_K_M) for best VRAM fit.
Storage Layout
Filesystem Contents Size Persistent?
/dev/sda1 (root) OS + lf CLI + PyApp bins 40-64 GB No — recreated
/dev/sdb1 or NFS mount Models + data 100-256 GB Yes — survives
Persistent volume contents (/var/lib/llamafarm or mount at ~/.llamafarm):
~/.llamafarm/ # LF_DATA_DIR
├── bin/ # PyApp service binaries (server, rag, runtime)
├── projects/ # Project configs, Chroma vector store, session state
└── addons/ # ML addon wheels
~/.cache/huggingface/ # TRANSFORMERS_CACHE
└── hub/ # Downloaded model weights (the real space hog)
[Unit]Description=LlamaFarm AI PlatformAfter=network.target[Service]Type=simpleUser=aiGroup=aiEnvironment=LF_DATA_DIR=/var/lib/llamafarmEnvironment=TRANSFORMERS_CACHE=/var/lib/llamafarm/cacheWorkingDirectory=/var/lib/llamafarm/projects/default/my-ai-vmExecStart=/usr/local/bin/lf start --daemonRestart=on-failureRestartSec=10[Install]WantedBy=multi-user.target
Ansible manages this unit file and can enable/start it.
Network Topology
Client ─── Tailscale ─── AI VM (:14345)
(laptop) (overlay) ├─ LlamaFarm Server / Designer UI
├─ Universal Runtime (:11540)
└─ RAG worker (internal)
LlamaFarm API and Designer UI accessed directly over Tailscale at http://ai-vm:14345
No reverse proxy needed — LlamaFarm serves HTTP natively
Ports 14345 and 11540 bind to the Tailscale interface only
No ports exposed to LAN or WAN directly
Backup Strategy
What
Frequency
Method
~/.cache/huggingface/ (models)
Once (re-downloadable)
Backup only if slow link. Models are cachable artifacts.
~/.llamafarm/projects/ (configs + vector DB)
Daily
rclone to NAS (or any S3-compatible target)
Ansible playbooks
On change
Git — push to private forge
Current Limitations & Future Considerations
Pascal architecture (compute 6.1) means no bfloat16. All inference is fp16 or fp32. Not a blocker, but newer GPUs (Turing/Ampere+) would be ~2x faster for the same model.
Single GPU — can serve one model at a time efficiently. For multi-model serving concurrently, split VRAM across instances or add a second GPU.
LlamaFarm pre-1.0 — expect occasional breaking changes. Pin the lf version and test upgrades in a disposable LXC first.
Monitoring — not yet addressed. Consider Prometheus + node_exporter or Uptime Kuma for health checks.
Architecture
System diagrams and visual documentation for the homelab infrastructure.
Komodo Controller sits outside the environment groups — it manages but is not part of workload infrastructure
Managed Docker hosts (Prod, Test, DevDocker) have Periphery agents and are GitOps-controlled
DevDocker VM runs on ProxMox but is still Komodo-managed — promotion path to Test → Prod
Unmanaged nodes in Dev — DevNode VMs and OCP cluster have no Komodo integration
Storage is accessed via network mounts from UnRaid NAS (Prod) and Synology NAS (Test)
GitOps Flow
Each managed environment is driven by Komodo ResourceSync, which can target main or a named branch independently (so Dev can track a feature branch while Prod tracks main). The full branch-steering workflow and merge hygiene are in the ADR on per-environment ResourceSync branches; repo layout and promotion folders are in Planned repository structure.
Subsections of Architecture Decision Records — AI VM Management
ADR 0001: LlamaFarm as AI model management layer
Status: Proposed Date: 2026-07-14
Context
The homelab has a dedicated AI VM running Ubuntu with assorted AI tools installed ad-hoc. There is no consistent model management layer — models are pulled manually, runtimes are configured individually (Ollama, vLLM, raw HuggingFace), and there is no unified API surface or RAG pipeline.
As part of a broader effort to make this VM “more than just a box with AI tools on it,” a management layer is needed for:
Unified API surface (OpenAI-compatible endpoint for all models)
Reproducible configuration (infra-as-code for AI, not just the VM)
Decision Drivers
Disposable VM model — the VM should be recreatable from Ansible + persistent data volumes without manual recovery steps.
Local-first, offline-capable — the AI layer must work without internet after models are cached.
GPU passthrough — quad-port P5000 needs CUDA support with minimal friction.
Config-driven, not script-driven — model selection, prompt templates, and RAG pipelines should live in version-controlled config files, not shell scripts.
OpenAI-compatible API — any tool that speaks OpenAI’s API should be able to target this VM.
Options Considered
Option 1: Ansible/Terraform/Semaphore only
Provision the VM, install Ollama/vLLM directly, manage models via shell scripts or ad-hoc commands.
Pro: Mature tooling, full homelab coverage.
Con: No model lifecycle abstraction — still need glue scripts for model switching, RAG pipelines, etc. Ansible does not natively understand “serve this model” as a primitive.
Option 2: LlamaFarm as the AI layer (chosen)
Provision the VM with Ansible, then run LlamaFarm natively (no containers) to manage models, RAG, and API serving.
Single config file (llamafarm.yaml) is the source of truth for all AI behavior — model selection, prompts, RAG, MCP servers.
Truly disposable VM — models and data live on persistent volumes; a fresh Debian install + one lf init + lf start + pointing at cached models restores full capability.
Built-in RAG — no need to wire up a separate vector DB, embedder, and chunker; it’s all in the config.
Native deployment — LlamaFarm runs without Docker/K8s, keeping the footprint lean and the GPU path simple.
Offline-capable — after initial model download, all inference is local with no egress.
Negative / Risks
Risk
Impact
Pre-1.0 stability
API/config changes between releases; may need to pin versions.
Small community
833 GitHub stars, pre-1.0. Less troubleshooting help available compared to Ollama or Ansible.
PyApp binary size
Service binaries bundle Python runtimes (~1-2 GB total for server + rag + runtime).
YC-backed startup
Open-source today, but direction could shift. Fork as mitigation.
Dependency chain
Python + TypeScript + Go + Nx monorepo. More moving parts than Ollama alone.
CUDA compatibility
Requires matching CUDA toolkit version with PyTorch / llama.cpp. Upstream already dropped CUDA 11 (b7694+).
Mitigations
Mitigation
Detail
Pin LlamaFarm version
Use lf bundle --version v0.0.x to create offline install bundles; avoid latest drift.
Persistent model cache
$TRANSFORMERS_CACHE and $LF_DATA_DIR on NAS-backed NFS or dedicated disk — models survive VM recreation.
Ansible as day-0 only
Ansible provisions the VM and installs lf; LlamaFarm’s yaml is the AI config. Clear boundary reduces coupling.
Monitor upstream
Keep an eye on breaking changes before applying lf upgrades. Test in a disposable LXC first if uncertain.
NVIDIA driver pinning
Pin CUDA toolkit version in Ansible to match LlamaFarm’s PyTorch requirement.
AI VM architecture: docs/homelab/ai-vm/architecture.md
Decisions
Key architectural and tooling decisions, with rationale.
Komodo over Kubernetes (K3s + ArgoCD)
Decision: Use Komodo for container GitOps instead of deploying K3s with ArgoCD.
Context: Needed GitOps capabilities (drift detection, continuous reconciliation) for Prod/Test environments. Current approach (GitHub Actions → Ansible playbooks) works but lacks:
Drift detection
Continuous reconciliation
Sustainable operational model
Options considered:
Option
Pros
Cons
K3s + ArgoCD
Familiar with ArgoCD, true GitOps, industry standard
K8s operational complexity, more failure points, overkill for homelab
Komodo
Docker-native, ~2.5× less config, uses familiar Compose concepts
Newer tool, less ecosystem
Ansible Pull
Uses existing skills, simple
DIY drift detection, more manual work
Portainer Business
Has GitOps features
Paid, vendor lock-in
Decision rationale:
Complexity: Komodo config is ~67 lines for a Plex stack vs ~176 lines for equivalent K8s manifests
Risk tolerance: K3s in production homelab felt risky — more moving parts, less familiar failure modes
Concept alignment: Docker Compose is already known; Komodo extends it rather than replacing it
Operational simplicity: Single Komodo Core + Periphery agents vs. K8s control plane + ArgoCD + supporting infrastructure
Trade-offs accepted:
Komodo is a newer, smaller project than ArgoCD
Less community support and fewer integrations
If K8s skills are needed for work, the OCP cluster in Dev provides that separately
Three-Tier Managed Docker Hosts (Dev → Test → Prod)
Decision: DevDocker VM is Komodo-managed, not a sandbox.
Context: Initially considered all dev nodes as unmanaged sandboxes. Reconsidered based on promotion path needs.
Rationale:
DevDocker is where container work starts before going to production
Having it in Komodo creates a consistent workflow: same TOML format, same tooling
Promotion is literally moving a file between directories
Other Dev nodes (VMs, OCP cluster) don’t need this — they’re for different purposes
Dedicated Komodo Controller (NUC)
Decision: Run Komodo Core on dedicated hardware with no workloads.
Rationale:
Management plane should be separate from workload infrastructure
If a managed node has issues, the controller can still observe and remediate
Clean failure domain — controller failures don’t affect running workloads (they just stop being managed temporarily)
NUC is low-power, can run 24/7 without significant cost
Terraform + Ansible (Not Just Ansible)
Decision: Use Terraform for provisioning, Ansible for configuration.
Context: Already comfortable with Ansible. Question was whether to use Ansible for everything or split responsibilities.
Boundary:
Terraform: Provision VMs, network resources, base infrastructure
Ansible: System configuration, package installation, Komodo Periphery agent deployment
Rationale:
Terraform has better state management for infrastructure
Ansible is better for configuration management and idempotent system setup
Clean separation of concerns — “what exists” (Terraform) vs “how it’s configured” (Ansible)
Poll-Based GitOps (Not Webhooks)
Decision: Use Komodo’s default poll-based sync rather than GitHub webhooks.
Context: Komodo supports both:
Poll-based: Core periodically checks Git for changes
Webhook-based: GitHub notifies Core immediately on push
Rationale:
Simpler setup — no webhook configuration needed
Works even if GitHub can’t reach the homelab (no public endpoint required)
Slight delay (poll interval) is acceptable for homelab use case
Can add webhooks later if immediate deployment becomes important
Per-environment Git branches (ResourceSync)
Decision: Use separate Komodo ResourceSync configurations per environment, each with its own Git branch / ref, so main can hold all environment folders at once while Dev (or Test) temporarily tracks a feature branch for validation.
Context: Feature work needs to soak in a managed environment without blocking main or copying configs outside Git. ResourceSync already supports repo + branch + scoped paths.
Operational trade-off: ResourceSync TOML that lives in Git may reference the feature branch while that branch is active; after merge to main, small manual edits on main repoint environments back to main where intended. Releases/tags for Prod-only pinning are optional and deferred until needed.
An opinionated migration guide covering a homelab with 5 servers, 20+ stacks, and 60+ containers. Relevant to our setup given similar topology.
What it covers:
Storage strategy: Monorepo layout for stacks (stacks/serverN/) and Komodo resource TOMLs (komodo/resources/main.toml). Git repo-based for all resources (backup via commits on save).
Rootless Periphery (community pattern): Docker socket-proxy (linuxserver/socket-proxy) with a non-root Periphery container, controlled via DOCKER_HOST: tcp://socket-proxy:2375. Also covers building a custom Periphery image for git user config when running non-root.
Periphery deployment evolution: Started containerized, switched to systemd agent after 3+ months — “systemd makes Docker interactions simpler.” Notes terminal/shell access differences between container and systemd agents.
Stack creation: Configuring Linked Repos for monorepo, Run Directory per stack, converting standalone containers via docker-autocompose.
Environment variables & Secrets: How Komodo’s .env interpolation works, secrets via [[SecretName]] syntax.
Resource Sync: Setting up bi-directional sync between Komodo and a Git repo for full topology backup. Tags for scope limiting, Execute vs Commit modes.
Docker data agnostic location: Using $DOCKER_DATA ENV per host for bind mount paths, making compose files portable across servers.
Follow-up post (also by FoxxMD):Komodo FAQ, Tips, and Tricks — covers container exec shortcuts, troubleshooting, and deeper operational patterns.
Environments
Detailed breakdown of each environment (VLAN) in the homelab.
Managed Environments (GitOps)
These environments have Komodo Periphery agents on Docker hosts, managed via GitOps. Changes flow through Git → Komodo Core → Periphery Agent → Container.
Prod
Purpose: Production workloads — services that need to be reliable and always available.
Simple app example (single container, no compose — flat file):
# prod/homepage.toml[[deployment]]
name = "homepage"description = "Dashboard"tags = ["dashboard", "prod"]
[deployment.config]
server = "prod-docker-01"image = "ghcr.io/gethomepage/homepage:latest"deploy = true
Shared Variables (shared/)
Variables that can be interpolated across environments.
# shared/variables.toml[[variable]]
name = "TZ"value = "America/New_York"[[variable]]
name = "PUID"value = "1000"[[variable]]
name = "PGID"value = "1000"
Promotion Flow
Moving apps between environments is a folder operation:
dev/new-app/
│
▼ (validate in dev)
test/new-app/
│
▼ (validate in test)
prod/new-app/
To promote: Copy or move the app’s folder to the next environment’s directory. Update the server field in the TOML to point to the correct host.
To deploy to multiple environments: Copy the folder to each environment. Each copy can have environment-specific tweaks (different compose settings, config values, etc.).
Git branch steering
Folder promotion (dev/ → test/ → prod/) answers which subtree on main is canonical after merge. Which Git revision each environment applies is separate: each Komodo ResourceSync sets its own branch / ref and scoped paths against the infrastructure repo.
Homelab default:
main holds the full komodo/ tree (every environment directory) for reviewability and docs.
Each environment has its own ResourceSync so, for example, Dev can track feature/xyz while Prod tracks main.
After merging a feature branch, manually repoint ResourceSync metadata on main for any environment that should follow main again (sync TOML on the feature branch usually names that branch while work is open — see ADR).
Releases / tags to pin Prod are optional later; per-environment folders + branch knobs reduce the urgency.
Home Assistant (HA) is the central hub for IoT automation in the new house. It needs to integrate with:
Zigbee devices (switches, sensors, buttons) via USB coordinator
Tapo cameras (RTSP/Tapo integration)
Unifi Protect (running on UDM)
Google Home (TTS announcements)
Sensi thermostats (cloud API)
The homelab has a defined Prod/Test/Dev architecture with Komodo-managed Docker hosts. Home Assistant must fit into this architecture while meeting its unique requirements.
Requirements
USB access — Zigbee coordinator must be physically attached to host
Network access — Must reach IoT VLAN (10.0.4.0/24) for cameras, Google Home, etc.
Unifi Protect access — Must reach UDM for Protect integration
Reliability — Automation failures directly impact daily life
Manageability — Should fit into existing GitOps/Komodo workflow where possible
Backup/Recovery — Must be recoverable quickly if host fails
Constraints
IoT VLAN (10.0.4.0/24) is isolated from other VLANs by design
Zigbee coordinator is USB-bound — cannot easily fail over to another host
Home Assistant does not support native clustering/HA
Decision
Deploy Home Assistant as a container on LenovoMini 1 (Prod Docker host) with USB passthrough for the Zigbee coordinator.
Phased Approach
Phase 1 — Bootstrap (RPi):
Run HA on existing Raspberry Pi 4
Place on Prod VLAN (10.0.1.0/24)
Configure firewall rules for IoT access
Validates the setup before full integration
Phase 2 — Production Migration:
Migrate HA to container on LenovoMini 1
USB passthrough for Zigbee coordinator
Komodo-managed deployment
Automated backups to UnRaid NAS
Options Considered
Option 1: Dedicated Raspberry Pi
Description: Run Home Assistant OS on a dedicated RPi 4.
Pros:
Simple setup
Direct USB access (no passthrough complexity)
Isolated from other workloads
Cons:
Another device to manage outside Komodo
SD card reliability concerns
Limited compute for future expansion (Frigate, etc.)
No integration with existing backup infrastructure
Verdict: Good for bootstrap, not ideal long-term.
Option 2: Container on Prod Docker Host (LenovoMini 1)
Description: Run HA as a Docker container on the existing Prod host, managed via Komodo.
Pros:
Better hardware (8th Gen i7, 16GB RAM, NVIDIA GPU)
Integrated with existing management (Komodo, GitOps)
Backups to UnRaid NAS
Network already on Prod VLAN with access to other services
GPU available for future Frigate integration
Cons:
USB passthrough required (adds configuration)
Shares resources with Plex/*arr (mitigated by resource limits)
Single point of failure (mitigated by backups)
Verdict: Best fit for long-term architecture.
Option 3: VM on ProxMox
Description: Run HA in a dedicated VM on the ProxMox host.
Pros:
Isolated resources
ProxMox has snapshot/backup features
Cons:
ProxMox is on Dev VLAN (10.0.3.0/24) — wrong network segment
USB passthrough to VM is more complex than Docker
Not on the Komodo-managed path
Verdict: Network placement is wrong. Rejected.
Option 4: Dedicated Mini Host on Prod VLAN
Description: Purchase a dedicated small device (NUC, mini PC) for HA.
Pros:
Dedicated to home automation
Direct USB, proper network placement
Cons:
Additional hardware cost
Another device outside existing management
Overkill given available capacity on LenovoMini 1
Verdict: Unnecessary given existing infrastructure.
Consequences
Positive
Integrated management: HA deployment is Komodo-managed, follows GitOps patterns
Better hardware: More headroom for future expansion (Frigate, more integrations)
Unified backups: HA config/data backed up to UnRaid alongside other Prod data
Network positioning: Prod VLAN has (or will have) appropriate access to IoT and UDM
Negative
USB passthrough complexity: Requires privileged container or explicit device mapping
Shared host: HA competes with Plex/*arr for resources (mitigated with resource limits)
Single point of failure: If LenovoMini 1 goes down, HA is down (mitigated by backups + quick restore)
Neutral
No native HA failover: Accepted limitation — most homelabs operate this way
Zigbee bound to host: Coordinator cannot fail over, but Zigbee direct bindings provide local fallback
Implementation
Phase 1: Bootstrap on RPi
Timeline: Immediate (while homelab is being set up)
Implement a warm/cold spare HA instance on the Test environment (LenovoMini 2) with automated failover capability. When the primary (Prod) instance has unfixable issues, a single-click remediation brings up the spare and updates network rules.
Option A: Scheduled rsync of /opt/homeassistant/config from Prod → Test (via NAS or direct)
Option B: Shared config on NAS mount (both read, only active writes)
Option C: Git-based config with HA Git integration
3. Zigbee Network Portability
Use Zigbee2MQTT (not ZHA) — stores network config in coordinator_backup.json
Backup can be restored to second coordinator
Devices don’t need re-pairing if network key is preserved
Caveat: Second coordinator must be same model or compatible
4. Firewall Automation (Ansible/Terraform)
# ansible playbook: failover-to-test.yml- name: Failover HA to Test environmenthosts: localhosttasks:
- name: Update UDM firewall - block Prod to IoTcommunity.general.udm_firewall_rule:
name: "Prod-to-IoT"state: disabled - name: Update UDM firewall - allow Test to IoTcommunity.general.udm_firewall_rule:
name: "Test-to-IoT"state: enabled - name: Start HA container on Testcommunity.docker.docker_container:
name: homeassistantstate: starteddelegate_to: lenovomini2 - name: Stop HA container on Prod (if reachable)community.docker.docker_container:
name: homeassistantstate: stoppeddelegate_to: lenovomini1ignore_errors: yes
5. Failback Procedure
Reverse the process when Prod is healthy
Sync any config changes from Test back to Prod
Re-enable Prod firewall rules, disable Test
Spare Modes
Mode
Standby State
Sync Frequency
Failover Time
Resource Usage
Cold Spare
Container stopped
Daily backup restore
~5-10 min
Minimal
Warm Spare
Container running (no IoT access)
Near real-time rsync
~1-2 min
Moderate
Recommendation: Start with Cold Spare. Warm Spare adds complexity for marginal time savings.
Triggering Failover
Manual (Phase 3a):
Single-click: Run Ansible playbook from Komodo UI or CLI
Human detects issue, initiates failover
Automated (Phase 3b, future):
Health check monitors HA (e.g., /api/ endpoint)
Consecutive failures trigger alert
Optional: Auto-failover after N failures (risky, needs tuning)
Prerequisites
Before implementing:
Hardware: Second Zigbee coordinator (~$25)
Network: Test VLAN firewall rules pre-configured (disabled)
Ansible: Playbooks for failover/failback
UDM API access: Ansible collection for Unifi or direct API calls
Zigbee2MQTT: Migrate from ZHA if using ZHA initially
Testing: Validate failover in controlled scenario
Risks & Mitigations
Risk
Mitigation
Zigbee devices don’t reconnect to second coordinator
Use Zigbee2MQTT with network backup; same coordinator model
Config drift between Prod and Test
Automated sync + Git-based config
Failover triggered unnecessarily
Manual trigger only (Phase 3a); conservative health checks (Phase 3b)
Firewall automation fails
Pre-test playbooks; manual fallback documented
Cost
Item
Cost
Second Zigbee coordinator
~$25
Time to implement
Medium
Ongoing maintenance
Low (once stable)
Decision
Defer to Phase 3. Implement after Phase 2 is stable and proven. The complexity is justified only after experiencing reliability issues or when uptime requirements increase.
Direct Zigbee binding (button controls switch without hub)
Configurable LED notification bar
Scene/multi-tap support
Mesh network extends coverage
Cons:
Higher per-switch cost
2.4GHz interference possible (manageable)
Z-Wave — Zooz Switches
Protocol: Z-Wave Plus Switch Cost: ~$30-35 Coordinator Cost: ~$35-45
Pros:
Works with existing dumb switch in 3-way config
Lower per-switch cost
Dedicated frequency (no WiFi interference)
Mature, reliable protocol
Cons:
Smaller device ecosystem
Wireless buttons limited and expensive ($40+)
Less feature-rich
Also Considered
Lutron Caseta: Excellent reliability, but requires proprietary bridge. Less flexible ecosystem.
WiFi switches: Cloud dependency or complex local setup. Doesn’t align with self-hosted goals.
Decision
Zigbee with Inovelli Blue switches.
Rationale
Accessory ecosystem: Zigbee buttons and sensors are significantly cheaper. $7 IKEA button vs $40+ Z-Wave compounds quickly.
Upgradeability: Largest device selection across manufacturers for future expansion.
Feature set: LED notifications, direct binding, scene control — useful for automations.
3-way handling: Single switch replacement works cleanly.
Implementation
Coordinator: Sonoff Zigbee 3.0 USB Dongle Plus
Integration: ZHA or Zigbee2MQTT (both well-supported)
Placement: Central location or USB extension to avoid interference
Home Assistant Automations
Workflow definitions for IoT automations. Copy/adapt these for Home Assistant.
Door Sensor Automations
Door Left Open Alert
Announces via Google Home when a door has been open for 5 minutes.
alias: "Alert - Door Left Open"description: "Announce when a door has been open too long"trigger:
- platform: stateentity_id:
- binary_sensor.front_door - binary_sensor.back_door - binary_sensor.office_door - binary_sensor.garage_entry_doorto: "on"for:
minutes: 5action:
- service: tts.google_translate_saytarget:
entity_id: media_player.living_room_speakerdata:
message: "{{ trigger.to_state.attributes.friendly_name }} has been open for 5 minutes."mode: parallel
Door Still Open Reminder
Repeats announcement every 5 minutes while an exterior door remains open.
alias: "Alert - Door Still Open Reminder"description: "Repeat reminder every 5 minutes while door is open"trigger:
- platform: stateentity_id:
- binary_sensor.front_door - binary_sensor.back_doorto: "on"for:
minutes: 5action:
- repeat:
while:
- condition: stateentity_id: "{{ trigger.entity_id }}"state: "on"sequence:
- service: tts.google_translate_saytarget:
entity_id: media_player.living_room_speakerdata:
message: "Reminder: {{ trigger.to_state.attributes.friendly_name }} is still open." - delay:
minutes: 5mode: parallel
Garage Entry Night Alert
Alerts when garage entry door opens at night, with camera snapshot.
Requires Tapo integration or generic camera with RTSP. Snapshots save to /config/www/ for web access.
Zigbee Events
Button events use zha_event (for ZHA) or mqtt triggers (for Zigbee2MQTT). Device IDs and commands vary by button model — check HA device page after pairing.
Receipt Automation
Receipt Fridge Launch — NFC App Launch
Tap the fridge NFC tag to open the receipt scanner app directly.
The NFC tag is the entry point. The app handles the actual capture and any per-receipt workflow.
Considerations:
If the app supports deep links or URL schemes, prefer that over a generic notification.
If people use different phones/scanner apps, map multiple notify targets or a choose block by person/presence.
Service Check / Router Status NFC
Router Status — NFC Status Page
Opens a locally hosted status page showing service health.
alias: "Status - Router NFC"description: "Serve local status page for router/service health"trigger:
- platform: nfctag_id: <router_status_nfc_uid>action:
- service: browser_mod.popupdata:
title: "Service Status"# Serve from a local/static endpoint; no external CDN depsurl: "/local/status/index.html"mode: single
Implementation notes:
Host status/index.html on the same host that serves HA or a lightweight internal web server.
Keep the page plain HTML with no external dependencies so it works when the internet is down.
Display gateway ping, DNS resolution result, Pi-hole stats, and a one-click router reboot if supported.
Consumable Replacement NFC
Consumable Tag — Reorder Trigger
Opens the replacement item’s reorder path.
alias: "Consumables - Replace"description: "Open consumable replacement action"trigger:
- platform: nfctag_id: <consumable_nfc_uid>action:
- service: browser_mod.popupdata:
title: "Replace Air Filter"# Prefer a stable local/HA endpoint; the destination can rotate behind this URLurl: "/local/consumables/air-filter.html"mode: single
Instead of encoding a retailer URL directly, use a stable local or Home Assistant destination so the reorder page can change without rewriting the tag.
Pi-hole Temporary Disable NFC
Pi-hole — 30 Minute Disable
Disables Pi-hole time-boxed for 30 minutes.
alias: "Pi-hole - Temp Disable 30m"description: "Disable Pi-hole for 30 minutes from NFC tap"trigger:
- platform: nfctag_id: <pihole_temp_disable_nfc_uid>action:
- service: rest_command.pihole_disable_30mmode: single
Time-box the disable window so ad-blocking is re-enabled automatically. Surface the remaining time on the local status page so the household can see the countdown.
Observability
Plans for public user-facing status and private operator dashboards (metrics, deep links, navigation to Komodo and Uptime Kuma).
Phases, success criteria, sequencing, status.samplayskeys.com, single pane of glass
For hardware, VLANs, and which services run where, start at the homelab map.
Subsections of Observability
Public status site and operator dashboard
Plans for user-facing public status (status.samplayskeys.com) and a private operator “single pane of glass,” with acceptance criteria, sequencing, and open decisions.
Phase 1 — Public status site
URL:status.samplayskeys.com Audience: Partner and other users of user-facing services (not homelab admins). Tone / UX: Similar to GitHub Status: components (or service groups), overall status, recent incident history, minimal jargon.
Intent
User-available observability: clear “is the thing I use up?” without exposing internal topology or admin tools.
Survives local outage: host on a VPS (candidate: Oracle Cloud free tier) so the page stays reachable if home internet or the lab is down.
Success criteria
Public HTTPS at status.samplayskeys.com with a sensible TLS story (e.g. reverse proxy + Let’s Encrypt on the VPS).
Lists only non-admin, user-relevant resources (e.g. Plex, Signal, Miniflux, Syncthing client-facing checks — exact list is yours to curate).
Does not link to or name internal admin URLs, VLANs, or management UIs.
Status data remains trustworthy when the house is offline: checks must run from the VPS (or another off-LAN path) against reachable endpoints (public hostname, reverse proxy, or approved tunnel — not 10.x.x.x).
Optional but valuable: brief incident log / maintenance notices you can edit without redeploying the whole stack (even a static markdown or small API is fine).
Open decisions (Phase 1)
Topic
Notes
Product / implementation
Options include self-hosted status engines (e.g. Cachet, Statping-ng, OpenStatus-style), static site + scheduled probe publishing JSON, or minimal custom static + GitHub-style updates. Pick based on maintenance appetite and whether you want a DB on the VPS.
Source of truth vs Uptime Kuma
Kuma may stay authoritative for alerting on the LAN; the public site can duplicate checks from the VPS or subscribe to exported state if you add a safe integration. Avoid coupling public page to LAN-only Kuma without a tunnel or push path.
DNS
status subdomain DNS at your registrar → VPS public IP; confirm Oracle (or chosen host) outbound checks allowed to your public endpoints.
Non-goals (Phase 1)
Full metrics graphs, Prometheus, or environmental sensors (already deferred elsewhere).
Authenticated “admin view” of the same page (keep public surface boring and safe).
Phase 2 — Single pane of glass (operator)
Audience: You (operator), not the general public. Primary job: One place to orient and jump to deeper tools.
Intent
Overview of the entire lab — health/signal at a glance (CPU/load/disk on key hosts, maybe Docker summary), possibly via Glances or equivalent on hosts plus an aggregation story.
Curated links (always visible):
Uptime Kuma (alerting / synthetics).
Komodo (deployments / stacks).
Dynamic or data-driven links to management URLs: Unraid UI, container admin UIs, UniFi, Synology DSM, etc. — ideally driven from the same source of truth you use for Komodo or a small YAML manifest so new services do not require hand-editing HTML.
Success criteria
One bookmarkable URL (likely not public — Tailscale or LAN-only) that opens the dashboard.
Sections or tiles: “Lab overview” (metrics snapshot), “Monitoring”, “Orchestration”, “Management consoles” (grouped by host or role).
Every critical jump target reachable from that page in ≤2 clicks without remembering IPs.
Link list can be updated without fragile one-off edits (manifest, template, or generator).
Open decisions (Phase 2)
Topic
Notes
Glances vs hybrid
Glances is strong per-host; “whole lab” may mean Glances on LenovoMinis + NUC + ProxMox with a portal (Homer, Dashy, Heimdall, Homarr) that embeds or links. Pure Glances multi-server exists but compare ops cost vs a small static portal + Glances where you care most.
Hosting
Typically homelab-side (or Tailscale-only) — not the same trust boundary as the public status site.
Secrets in URLs
Management UIs often should not be in public git; prefer env-backed or encrypted manifest if URLs contain tokens (usually they should not — use SSO or Tailscale Serve).
Relationship to Phase 1
Phase 1 (public status)
Phase 2 (operator dashboard)
Host
VPS
Homelab / Tailscale
Audience
Users
You
Data
Synthetic / public checks
Richer metrics + deep links
Alerting
Informational; Kuma can still alert you
Kuma primary; dashboard is navigation
Suggested sequencing
Inventory user-facing hostnames and which are probe-able from the public internet (required for Phase 1).
Stand up VPS + DNS + TLS for status.samplayskeys.com.
Implement Phase 1 status page and curate components.
UnRaid NAS role in Prod — just media, or also app data?
Synology NAS role in Test — mirror of UnRaid structure, or different?
Backup strategy across environments
How does DevDocker access test data (if at all)?
Terraform + Ansible Boundary
Exactly which resources does Terraform manage?
Does Terraform manage the physical MiniPCs, or just VMs on ProxMox?
How do we bootstrap the Komodo Controller itself?
DMZ Strategy
DMZ VLAN defined (VLAN 5, 10.0.5.0/24) — see homelab-map.md
Decide on shared NIC vs dedicated seed box hardware
qBittorrent deployment (Docker on shared NIC or baremetal on seed box)
NFS export for shared storage (DMZ → UnRaid NAS)
Lock down firewall rules — strictly limit DMZ → Internal to NFS only
Monitoring & Alerting
Where does monitoring live? (Separate from workloads?)
Komodo has built-in alerting — is that sufficient?
Do we need a dedicated metrics/visualization stack (e.g. Prometheus/Grafana), or is Komodo + Uptime Kuma (+ existing logging) enough for the foreseeable future? (Prometheus/Grafana/Influx-style stack explicitly deferred — not pursuing yet.)
Secrets Management
How do secrets get into Komodo configs?
Komodo supports variables — are those sufficient for secrets?
Do we need external secrets management (Vault, etc.)?
Planned Workloads
Apps and stacks to deploy once infrastructure is ready.
Prod
Workload
Type
Priority
Plex
Stack
High
*arr suite (Sonarr, Radarr, Prowlarr, etc.)
Stack
High
Uptime Kuma
App
Medium
TBD
—
—
DMZ
Workload
Type
Priority
qBittorrent (Internet Archive torrent backups)
App
Medium
TBD
—
—
Test
Mirror of Prod where validation is needed.
Dev
Experimental containers, new apps being evaluated.
Future Ideas
Things to consider but not immediately necessary.
Multi-repo Strategy
Currently planning a single infrastructure/ repo. Could split into:
infra-terraform/ — Just Terraform
infra-ansible/ — Just Ansible
homelab-apps/ — Just Komodo configs
Tradeoff: More repos = more isolation but more complexity. Single repo is simpler to start.
CI/CD for Infra Repo
Lint Terraform/Ansible/TOML on PR
Plan Terraform changes on PR, apply on merge
Validate Komodo TOML syntax before merge
Automated Testing
Spin up ephemeral Test environment for PRs
Run smoke tests against deployed containers
Tear down after validation
Disaster Recovery
Document recovery procedures for each environment
Automate rebuild from scratch (Terraform + Ansible + Komodo)
Backup strategy for stateful data (Plex metadata, *arr databases, etc.)
Hardware Upgrades
Upgrade path for MiniPCs if more compute needed
GPU passthrough for Plex transcoding (currently planned for Prod MiniPC?)
JONSBO NAS - Prod, Tailscale container, mapped to LenovoMini 1
Synology NAS - Test, Tailscale installed, mapped to LenovoMini 2
ProxMox Host - Tailscale installed directly, several VMs available
VM 100 - Bluefin (remote work VM)
4 vCPU, 8GB RAM, 50GB disk (sdb / bluefin-lvm)
Tailscale installed directly
VM 101 - (reserved for Windows VM)
VM 103 - Fedora Server (dev VM)
8 vCPU, 16GB RAM, 50GB disk (nvme0n1 / nvme-lvm)
NVIDIA Quadro P5000 passthrough
“Debian Test”
Test VM, running debian, tools installed, used only for testing packages on Debian. Mostly stays powered off.
Docker VM
mirror of the test and prod VMs, running Komodo periphery, testing docker containers and workloads
Remaining resources will be used for testing out VM ideas. No remote windows machine is needed.
Intel NUC - Technically part of the prod environment, this runs the komodo instance that does the management for everything else. Additionally, it will be the main host that the ansible and terraform commands will be run from. It will have a GitHub runner installed to handle executing the management tasks.
Gaming PC - Not technically part of any environment (it will be part of a separate “user” environment) it will still function as my remote windows machine when needed.
NOTE: At some point, it may become worth it to leverage on of my spare laptops as a stand-in device for running an image for work/testing, but that is a future problem. For now, I will not include a machine to use as a “daily work” machine in my plans.
Unifi Zone-Based Firewall (ZBF) Guide
Overview
This guide walks through configuring the homelab network using UniFi’s Zone-Based Firewalls (ZBF), available in UniFi Network 9.0+.
ZBF shifts away from traditional LAN In/Out rules by grouping networks into Zones and defining Policies that dictate traffic flow between those zones. This is visualized in the Zone Matrix and is vastly superior for environment segmentation.
1. Define Custom Zones
By default, UniFi places all local networks into the built-in Internal zone. To segment our environments, we will create custom zones that perfectly map to our VLANs.
Go to Settings > Security > Firewall > Zones and create the following custom zones:
Zone Name
Assigned Network (VLAN)
Purpose
Prod
Prod (1)
Core services, Home Assistant
Test
Test (2)
Pre-production validation
Dev
Dev (3)
OCP and Sandbox nodes
IoT
IoT (4)
Cameras, smart plugs
DMZ
DMZ (5)
Public-facing services (qBittorrent)
User
User (10)
Laptops, phones, trusted devices
(Note: Ensure each VLAN is moved out of the default Internal zone and into its respective custom zone.)
Built-in Zones
Alongside our custom zones, the following built-in UniFi zones remain actively used:
Hotspot: Contains the Guest network (VLAN 20). UniFi automatically isolates this zone (blocks access to all other zones) while allowing internet access, perfectly mapping to guest network needs.
Gateway: Represents the router itself. Essential for all zones to access DHCP, DNS, and Gateway functions.
External: Represents the Internet.
2. Configure the Zone Matrix (Policies)
Instead of a linear list of “Drop” rules, ZBF uses a matrix (source zone on the Y-axis, destination zone on the X-axis). We define explicit policies for allowed paths and lock down the rest.
Go to Settings > Security > Firewall > Policy Table (or click the cells in the Zone Matrix) and configure the following policies:
Policy 1: HA Control over IoT
Allows Home Assistant to reach and control IoT devices.
Name: Allow HA to IoT
Action: Allow
Source Zone: Prod
Destination Zone: IoT
Source Device/IP:10.0.1.X (Home Assistant IP)
Auto Allow Return Traffic: Checked (Ensures IoT devices can respond to HA)
Policy 2: User Local Access to Prod (Optional for Latency)
Allows User VLAN devices fast local access to specific Prod services (bypassing Tailscale).
Name: Allow User to Prod Apps
Action: Allow
Source Zone: User
Destination Zone: Prod
Destination Port: Select specific ports (e.g., 8123 for HA, 32400 for Plex)
Auto Allow Return Traffic: Checked
Policy 3: Allow User to IoT (Chromecast/Casting)
Allows phones/laptops on User VLAN to cast media to Chromecasts and smart speakers on IoT VLAN.
Name: Allow User to IoT Casting
Action: Allow
Source Zone: User
Destination Zone: IoT
Destination Port:8008, 8009 (TCP - Chromecast control ports)
Auto Allow Return Traffic: Checked
Policy 4: Prod to Test Synchronization
Allows Prod services to sync with Test.
Name: Allow Prod to Test
Action: Allow
Source Zone: Prod
Destination Zone: Test
Auto Allow Return Traffic: Checked
Policy 5: Test to Dev Validation
Allows Test services to reach Dev.
Name: Allow Test to Dev
Action: Allow
Source Zone: Test
Destination Zone: Dev
Auto Allow Return Traffic: Checked
Policy 6: DMZ to External (Internet)
Allows DMZ services (e.g. qBittorrent) to reach the internet for torrent traffic and updates.
Name: Allow DMZ to External
Action: Allow
Source Zone: DMZ
Destination Zone: External
Auto Allow Return Traffic: Checked
Policy 7: External to DMZ (Specific Ports)
Allows incoming connections to DMZ services on required ports. Tightly scoped to only what’s needed.
Name: Allow External to DMZ Services
Action: Allow
Source Zone: External
Destination Zone: DMZ
Destination Port:6881 (TCP — torrent); 6881 (UDP — DHT); add more as new services are added
Auto Allow Return Traffic: Checked
Notes: This is the only inbound path from the internet into the lab. Review and restrict per-service.
Policy 8: DMZ to UnRaid NAS (Shared Storage)
Allows DMZ services to write downloaded content to the UnRaid NAS NFS export so Prod services can access it.
Name: Allow DMZ to UnRaid NFS
Action: Allow
Source Zone: DMZ
Destination Zone: Prod
Source Device/IP: DMZ subnet (10.0.5.0/24)
Destination IP: UnRaid NAS IP (10.0.1.X)
Destination Port:2049 (TCP — NFS)
Auto Allow Return Traffic: Checked
Notes: This is the only exception to DMZ → Internal block. Tightly scoped to a single IP + port.
3. Enable Multicast DNS (mDNS) for Discovery
For casting to work across VLANs, devices on the User VLAN must be able to discover the Chromecasts on the IoT VLAN. UniFi handles this via a built-in mDNS reflector.
Go to Settings > Networks > Global Network Settings (or the specific network settings) and ensure Multicast DNS is enabled for the User and IoT networks.
(Note: The UniFi Gateway automatically permits mDNS discovery traffic under the hood when this feature is turned on, so no explicit ZBF policy is needed for the discovery phase—only for the streaming connection, which Policy 3 handles).
4. Enforce Default Deny Between Custom Zones
To complete the segmentation, we must ensure that all other inter-zone traffic is blocked. UniFi’s matrix allows you to set the baseline interaction between zones.
For any zone pair that shouldn’t communicate (e.g., IoT to User, User to Dev, DMZ to any internal zone), create a catch-all block policy:
Name: Block Inter-Zone Traffic
Action: Block
Source Zone: Select all custom zones (Prod, Test, Dev, IoT, User, DMZ)
Destination Zone: Select all custom zones (Prod, Test, Dev, IoT, User)
Note: Place this policy at the very bottom of the Policy Table so our explicit “Allow” rules above take precedence. (The built-in Hotspot zone is natively blocked from other zones by UniFi, so it does not need to be included in this block list).
5. Port Forwarding — qBittorrent
Port forwarding is required for qBittorrent to accept incoming peer connections, which improves swarm connectivity and satisfies tracker connectability requirements.
Configure Port Forward Rule
Go to Settings > Security > Traffic Rules > Port Forwarding and create:
Field
Value
Notes
Name
qBittorrent Incoming
Enabled
True
From
Any
Accepts connections from any WAN IP
Port
51820
Pick a high ephemeral port (avoid 6881 — some ISPs throttle well-known BitTorrent ports)
Forward IP
10.0.5.X
DMZ machine’s static IP running qBittorrent
Forward Port
51820
Can differ from WAN port if needed, but matching is cleaner
Protocol
TCP & UDP
Both needed: TCP for peer data, UDP for DHT/magnet links
Log
False
Avoid log noise
qBittorrent Configuration
In qBittorrent, set the incoming port to match the forward port:
Settings > Connection > Listening Port:51820
Relationship to ZBF Policies
The port forward rule implicitly creates a firewall exception on the WAN interface.
ZBF Policy 7 (External → DMZ) still governs east-west traffic once the packet arrives at the DMZ VLAN.
If Policy 7’s destination port is locked to 6881, you must update it to match your chosen forward port (51820), or widen it to cover both.
This guide covers methods for checking the expiration dates of critical OpenShift certificates, including the API server and Ingress controller. While OpenShift typically auto-rotates most internal certificates, manually verifying them can be useful for troubleshooting and custom monitoring.
Key Certificate Locations
Critical certificates are stored as Kubernetes Secrets across different namespaces:
Component
Namespace
Secret Name
API Server
openshift-kube-apiserver
kube-apiserver-cert-key-pair
Ingress (default)
openshift-ingress
router-certs-default
Controller Manager
openshift-kube-controller-manager
Various cert secrets
Scheduler
openshift-kube-scheduler
Various cert secrets
Quick Checks
Use these one-liners to manually inspect specific certificates.
This script calculates the remaining days for each certificate in a namespace and provides warnings if they are close to expiring. It handles epoch time conversion for robust comparison.
#!/bin/bash
NAMESPACE="openshift-kube-apiserver"DAYS_WARNING=30oc get secret -n "$NAMESPACE" -o json | jq -r '.items[] | select(.type == "kubernetes.io/tls") | .metadata.name' | while read secret; do echo "=== Certificate: $secret ==="# Extract and decode certificate CERT=$(oc get secret -n "$NAMESPACE""$secret" -o jsonpath='{.data.tls\.crt}' | base64 -d)# Get expiration date in seconds since epoch EXPDATE=$(echo "$CERT" | openssl x509 -enddate -noout | cut -d= -f2) EXPDATE_EPOCH=$(date -d "$EXPDATE" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z""$EXPDATE" +%s) NOW=$(date +%s) DAYS_LEFT=$((($EXPDATE_EPOCH - $NOW)/86400))# Display results echo "Expires: $EXPDATE" echo "Days remaining: $DAYS_LEFT"# Warning if expiring soonif[ $DAYS_LEFT -lt $DAYS_WARNING ]; then echo "⚠️ WARNING: Certificate expires in $DAYS_LEFT days!"elif[ $DAYS_LEFT -lt 0]; then echo "❌ CRITICAL: Certificate has expired!"else echo "✓ OK"fi echo ""done
Note: Save as check-certs.sh, run chmod +x check-certs.sh, and configure it as a cron job for regular monitoring.
Option 3: Quick One-Liners
If you prefer one-liners over a script, you can use jq to loop through secrets:
While scripts are helpful for point-in-time checks, OpenShift provides built-in mechanisms for monitoring:
Prometheus Alerts: OpenShift’s default monitoring stack includes Prometheus alerts for certificate expiration.
Web Console: Check Observe → Alerting in the OpenShift web console for active certificate expiration warnings.
CSR Approval: For pending Certificate Signing Requests (often relevant during node addition or cert rotation), check status with oc get csr -w and approve manually if necessary using oc adm certificate approve <csr-name>.
Chaining GitHub Actions Workflows Without a PAT or GitHub App
How to trigger one workflow from another when you can’t (or don’t want to) use Personal Access Tokens or GitHub Apps.
The Problem
You have two workflows:
Workflow A — Detects branches and auto-creates PRs against main
Workflow B — Runs tests on PRs opened against main
Manual PRs trigger Workflow B. But when Workflow A creates a PR, Workflow B never runs.
Why This Happens
GitHub Actions has a deliberate security feature:
Actions performed using the default GITHUB_TOKEN do not trigger other workflows.
This prevents accidental infinite loops (Workflow A triggers B triggers A triggers B…). When Workflow A creates a PR using GITHUB_TOKEN, GitHub suppresses the pull_request event that Workflow B listens for.
Mitigation Options
Option
How It Works
Pros
Cons
Personal Access Token
Use a PAT instead of GITHUB_TOKEN
Simple
Tied to user account, expires, requires secret management
GitHub App
Create app, use its token
Not tied to user, fine-grained permissions
Requires app creation/management, may need org admin access
workflow_run trigger
Workflow B triggers on Workflow A completion
No extra tokens, works within default permissions
Slightly more complex logic
Why Not PAT or GitHub App?
In enterprise environments, engineers often:
Don’t have permission to create GitHub Apps at the org level
Can’t or shouldn’t create PATs tied to personal accounts for shared workflows
Face security policies that restrict token creation
Need solutions that work within the default GITHUB_TOKEN permissions
The workflow_run approach works entirely within GitHub’s default permission model.
Solution: Using workflow_run Trigger
Instead of Workflow B listening for pull_request events (which won’t fire), have it listen for Workflow A’s completion.
Architecture
┌─────────────────┐ ┌─────────────────┐
│ Workflow A │ │ Workflow B │
│ (Auto-Create PR)│ │ (Run Tests) │
└────────┬────────┘ └────────▲────────┘
│ │
│ completes │ triggers on
│ │ workflow_run
▼ │
┌────────────┐ │
│ PR │───────────────────┘
│ Created │ (B finds PR from A's run)
└────────────┘
# .github/workflows/pr-tests.ymlname: PR Testson:
# Still trigger on manual PRspull_request:
branches: [main]# Also trigger when Auto Create PR workflow completesworkflow_run:
workflows: ["Auto Create PR"]
types: [completed]jobs:
test:
runs-on: ubuntu-latest# Only run if workflow_run was successful (skip if Workflow A failed)if: | github.event_name == 'pull_request' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')steps:
- name: Get PR info (for workflow_run trigger)id: pr-infoif: github.event_name == 'workflow_run'uses: actions/github-script@v7with:
script: | // Find PRs associated with the triggering workflow's head branch
const headBranch = context.payload.workflow_run.head_branch;
const headSha = context.payload.workflow_run.head_sha;
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:${headBranch}`,
state: 'open'
});
if (prs.length === 0) {
core.setFailed(`No open PR found for branch ${headBranch}`);
return;
}
const pr = prs[0];
core.setOutput('pr-number', pr.number);
core.setOutput('pr-head-sha', pr.head.sha);
core.setOutput('pr-head-ref', pr.head.ref);
console.log(`Found PR #${pr.number} for branch ${headBranch}`); - name: Checkout PR codeuses: actions/checkout@v4with:
# Use PR ref for workflow_run, default for pull_requestref: ${{ github.event_name == 'workflow_run' && steps.pr-info.outputs.pr-head-ref || github.head_ref }} - name: Run testsrun: | echo "Running tests..."
# Your test commands here
npm test # or pytest, go test, etc. - name: Report status to PRif: github.event_name == 'workflow_run'uses: actions/github-script@v7with:
script: | const prNumber = '${{ steps.pr-info.outputs.pr-number }}';
const sha = '${{ steps.pr-info.outputs.pr-head-sha }}';
// Create a check run or comment on the PR
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: sha,
state: 'success', // or 'failure' based on test results
context: 'PR Tests (via workflow_run)',
description: 'Tests passed'
});
Key Points
Dual triggers: Workflow B responds to both pull_request (manual PRs) and workflow_run (auto PRs)
Conditional logic: The if condition ensures we only run on successful Workflow A completions
PR discovery: Since workflow_run doesn’t have PR context, we query the API to find the PR by branch name
Status reporting: We manually create commit statuses so the PR shows test results
Caveats
Check Runs and PR Status
When triggered via workflow_run, the workflow runs in the context of the default branch, not the PR. This means:
PR checks list won’t automatically show the workflow
You need to manually create commit statuses (shown above)
Or use the Checks API to create check runs
Race Conditions
If Workflow A completes before the PR is fully created (rare but possible), the PR lookup might fail. Add a short delay or retry logic if needed:
- name: Wait for PR to be indexedrun: sleep 5
Branch Name Matching
The PR lookup uses the head branch. Ensure Workflow A’s branch naming is predictable so Workflow B can find the right PR.
Verification
After implementing:
Push a branch matching your pattern (e.g., feature/test-chain)
Confirm Workflow A runs and creates the PR
Confirm Workflow B triggers on Workflow A completion
Confirm tests run and status appears on the PR
Summary
The workflow_run trigger lets you chain workflows without PATs or GitHub Apps. It’s slightly more complex than the token-based approaches, but works entirely within GitHub’s default permission model — making it ideal for enterprise environments where token creation is restricted.
Fixing Podman Start issues with Krunkit on MacOS
Podman on macOS can run its Linux VM through libkrun via the krunkit binary (installed from Homebrew). When required shared libraries are missing, krunkit crashes immediately and Podman reports an unhelpful abort-trap error instead of the underlying dyld failure.
This guide documents the symptoms, troubleshooting steps, fix, and prevention for that failure mode.
Symptoms
Starting a libkrun-backed Podman machine fails:
podman machine start
Example output:
Starting machine "podman-machine-default"
Error: krunkit was terminated by signal: abort trap
Related indicators:
podman machine list shows the machine in stopped state with Never under Last Up
podman version fails with a socket/SSH connection error because the VM never started
The machine was created with VM type libkrun (default when krunkit is installed)
podman machine list
# NAME VM TYPE ...# podman-machine-default* libkrun ... Never
Root Cause
krunkit depends on a chain of Homebrew libraries (libkrun-efi, virglrenderer, and others). If any link in that chain is missing, macOS dynamic linker (dyld) aborts the process before Podman can surface a clear error.
In this case, two libraries were not installed:
Missing library
Required by
Symptom when running krunkit --version
libepoxy
libkrun-efi
Library not loaded: .../libepoxy.0.dylib
molten-vk
virglrenderer
Library not loaded: .../libMoltenVK.dylib
Podman only reported the generic abort trap because krunkit exited before the VM could boot.
Troubleshooting
Work through these steps to isolate the failure.
1. Confirm machine state and provider
podman machine list
podman machine inspect podman-machine-default
Look for VM TYPE: libkrun and State: stopped.
2. Run krunkit directly
This usually reveals the real dyld error:
which krunkit
krunkit --version
If krunkit crashes with Library not loaded, note the missing .dylib path.
HCL BigFix is a multi-platform endpoint management platform that enables IT operations and security teams to discover, manage, patch, and secure global IT estates. Whether operating on-premises, in the cloud, or in hybrid environments, BigFix uses a unique single-agent, hierarchical architecture to manage thousands of devices efficiently with minimal bandwidth overhead.
This guide is designed for technical consultants planning a new BigFix deployment with a client. It outlines the core architectural components, infrastructure decisions, prerequisite checklists, deployment flows, and discovery questions necessary to align with a client’s requirements.
BigFix Core Architecture & Components
BigFix does not rely on active scanning or heavy database polling. Instead, it operates on a pull-based, “Relevance” evaluation model.
+-------------------+
| Windows Console |
+---------+---------+
|
| (HTTPS - TCP 52311)
v
+-------------------+ +-------------------+ +-------------------+
| Web Reports |<----------| Root Server |---------->| HCL License / |
+-------------------+ (TCP) +---------+---------+ (HTTPS) | Content Servers |
| +-------------------+
| (TCP/UDP 52311)
v
+-------------------+
| Top-Level |
| Relay |
+---------+---------+
|
+----------------------+----------------------+
| (TCP/UDP 52311) | (TCP/UDP 52311)
v v
+-------------------+ +-------------------+
| Branch Relay | | Branch Relay |
| (Location A) | | (Location B) |
+---------+---------+ +---------+---------+
| |
+---------+---------+ +---------+---------+
| | | |
v v v v
+-----------+ +-----------+ +-----------+ +-----------+
| Client | | Client | | Client | | Client |
+-----------+ +-----------+ +-----------+ +-----------+
1. The Root Server
The central brain of the deployment. It:
Collects and aggregates reports from agents.
Downloads patch feeds and fixlets from the HCL Content Servers.
Manages and distributes action payloads.
Runs the core database services.
2. BigFix Relays
Relays are intermediate staging points. Instead of 10,000 agents downloading a 100MB patch over a WAN link from the Root Server, the local Relay downloads it once, and the local agents fetch it from that Relay over the local LAN.
Relays are standard endpoints running a lightweight relay service.
Hierarchical layering: Relays can report to other parent relays (Top-Level Relays), shielding the Root Server from direct load.
3. BigFix Client (Agent)
A lightweight agent (~10-20MB RAM, <2% CPU) running as a system service on every managed machine.
It continuously evaluates the endpoint state using Relevance Language (a declarative query language).
If an agent determines a patch or configuration is “relevant”, it flags itself in the Console.
It executes Action Scripts when instructed by the operator.
4. BigFix Console
The primary administrative interface used by operators to view inventory, author actions, and deploy patches.
Constraint: The Console is strictly a Windows-only desktop application.
It connects directly to the Root Server via HTTP/HTTPS.
5. Web Reports
A web-based interface for secondary reporting, viewing historical trends, and sharing read-only compliance dashboards with executive stakeholders.
Infrastructure Sizing & Database Choices
Selecting the right OS and Database backend is the most critical decision during the initial phase.
Supported Operating Systems for Root Server
Windows Server: 2016, 2019, 2022 (Standard and Datacenter editions).
Red Hat Enterprise Linux (RHEL): 8.x, 9.x (64-bit).
Database Backends & Constraints
Microsoft SQL Server (for Windows Server deployments):
SQL Server Enterprise & Standard: Supported for versions 2014 through 2022.
SQL Server Express: Supported only for proof-of-concept (POC) environments or environments with fewer than 500 endpoints due to the 10GB database size limit.
IBM DB2 (for Linux deployments):
DB2 v11.5 is the standard for Linux deployments.
An evaluation/bundled version of DB2 is typically included in the Linux server installer.
Hardware Sizing Guidelines
Note: Sizing depends on endpoint counts and active modules (e.g., Lifecycle, Compliance, Inventory).
Endpoint Count
Role
CPU Cores
RAM
Storage Type
Recommended Disk IOPS
< 1,000
All-in-One Server
4 Cores
16 GB
SSD (SAS/SATA)
500+
1,000 - 10,000
Root Server + DB
8 Cores
32 GB
High-Speed NVMe/SSD
2,000+
10,000 - 50,000
Split Root & DB Server
16 Cores
64 GB+
Dedicated Enterprise SAN
5,000+
Prerequisites
1. Network & Firewall Port Requirements
The BigFix platform uses TCP/UDP port 52311 as its default communication channel.
Source
Destination
Port / Protocol
Direction
Purpose
Windows Console
Root Server
TCP 52311
Inbound
Console-to-Server connection
Root Server
Internet (HCL)
TCP 80 / 443
Outbound
Gathering sync feeds and downloads
Relays
Root Server
TCP 52311
Inbound
Reporting parent-upstream status
Clients (Agents)
Local Relay
TCP 52311
Inbound
Client report uploads & payloads
Relays / Server
Clients (Agents)
UDP 52311
Outbound
“Ping” notification to trigger fast checks
Web Reports Server
Root Server / DB
TCP 1433 or 50000
Inbound
SQL Server (1433) or DB2 (50000) access
Note on UDP 52311: If UDP is blocked across subnets, agents will still check in via TCP on a polling interval (defaulting to every 4 hours), but real-time “gather now” command execution will be delayed.
2. DNS & Name Resolution
The Root Server must be reachable via a fully qualified domain name (FQDN) or a dedicated IP.
It is highly recommended to use a CNAME alias (e.g., bigfix.clientdomain.com) during installation rather than a specific server host name. This allows easy migration of the hardware/VM in the future without redeploying agents.
Configuration & Installation Sequence
The general deployment path follows these distinct phases:
Run the BigFix Installation Generator on a Windows staging machine.
Submit your HCL license authorization file (.BesLicenseRequest) to the HCL License server.
HCL returns a Signed License file.
Generate the Masthead file (actionsite.afxm). This file acts as the anchor of trust. It contains the server FQDN, configuration parameters, and the public key used by agents to verify that actions sent to them are authentically signed.
Phase 2: Installing the Root Server
On Windows: Run setup.exe and choose “Install Server”. Configure your connection to MS SQL Server and select your generated masthead.
On Linux: Extract the tarball, ensure database (DB2/external) is ready, and run:
./install.sh
Select Option [1] All Components or [2] Server and Client Only when prompted.
Phase 3: Deploying the Console
Run the console installer on a Windows administrator workstation.
Connect to the Root Server using the operator account created during server installation.
Phase 4: Deploying Relays
Install the standard BigFix Agent on dedicated relay VMs or branch office hosts.
In the BigFix Console, right-click the designated computers and select “Install BigFix Relay”.
Configure the relay settings (e.g., maximum cache size, parent relays).
Phase 5: Agent Rollout
Use the Client Deploy Tool (CDT) built into the Console to push agents over SSH or WMI.
For large scale deployments, deploy the agent MSI/RPM package via GPO, SCCM, or Ansible, bundled with the generated actionsite.afxm masthead.
Consultant’s Discovery Checklist
Use these specific discovery questions during your initial meeting with the client to gather architectural requirements:
How many total endpoints (workstations, servers, VMs, containers) are in scope?
Why: Determines whether SQL Server Express is acceptable (< 500) and sets hardware specifications.
What is the current distribution of Operating Systems across these endpoints?
Why: Identifies if they need specific OS agents and helps plan the deployment method.
What is the network topology? Do you have remote branch offices or bandwidth-constrained links?
Why: Drives the relay placement strategy and WAN bandwidth throttle planning.
Do you have a preferred Operating System for the central BigFix server (Windows Server vs RHEL)?
Why: Dictates database licensing requirements (MS SQL Server vs DB2) and administrative skillsets.
How are endpoints currently patched or managed? Do we need to plan co-existence or a migration from tools like WSUS, SCCM, or Ansible?
Why: Highlights potential software conflicts or open firewall port struggles.
Will endpoints reporting from outside the corporate firewall (remote/WFH workers) need management?
Why: Determines if we need to deploy an Internet-Facing Relay (DMZ Relay) with public DNS records.
Common Pitfalls & Anti-Patterns
Hardcoding Server Names: Installing with a specific server name (e.g., srv-bf-prod-01) instead of a DNS CNAME alias (bigfix.company.com). If the server hardware dies, migrating the system requires manually updating the masthead on every single agent.
Neglecting Relay Cache Limits: Leaving relay cache limits at default values. In environments deploying heavy OS patches or major software rollouts, relays will run out of cache space, causing them to fetch payloads repeatedly across WAN links.
Ignoring UDP 52311: Assuming TCP is sufficient. Without UDP 52311 inbound to clients, operators cannot trigger “Instant” actions, severely impacting incident response speeds.
Insufficient Space in Temporary Directories: Installing without verifying disk space in /var/tmp (Linux) or C:\Windows\Temp (Windows). BigFix client downloads are staged here, and major patch actions will fail if these directories are constrained.
Podman Desktop is the graphical front end for Podman on macOS. Containers do not run natively on macOS — Podman launches a lightweight Linux VM (a Podman machine) and runs containers inside it. The desktop app handles onboarding, machine management, and optional Docker compatibility so existing Docker workflows keep working.
This guide covers installation, first-run setup, Docker compatibility, rootless vs rootful mode, and common gotchas — including the krunkit abort trap failure on Homebrew installs.
Prerequisites
Requirement
Notes
macOS 14+
Required for libkrun/krunkit on Apple Silicon
Apple Silicon or Intel
Universal .dmg works on both
Admin password
Needed during first-run Podman engine install
~4 GB RAM minimum
Default machine uses 2 GiB; increase for heavier workloads
Optional but useful:
Homebrew — alternative install path (see gotchas below)
Docker CLI — not required; Podman can serve the Docker API. Install via onboarding or brew install docker if you want the docker command
Choose an Install Path (Important)
Podman Desktop and the Podman CLI can be installed two ways. Pick one and stay on it.
# Check what you already havewhich podman
podman --version
brew list --cask podman-desktop 2>/dev/null
ls /Applications/Podman\ Desktop.app 2>/dev/null
Podman Desktop onboarding walks through engine install, machine creation, and optional CLIs. You can also finish setup later from Settings → Resources or the Dashboard notification.
Onboarding checklist
Install Podman engine — enter your macOS password when prompted.
Create a Podman machine — default name is podman-machine-default.
Install optional CLIs — kubectl and compose (Podman Compose).
Open Dashboard — confirm the machine shows Running under Settings → Resources.
CLI equivalent
If you prefer the terminal (or need to script setup):
podman machine init
podman machine start
podman info
podman run --rm hello-world
On Apple Silicon with krunkit installed, new machines default to libkrun (GPU-capable, uses Apple’s Hypervisor framework). Without krunkit, Podman falls back to applehv.
podman machine list
# VM TYPE column: libkrun or applehv
Rootless vs rootful
Every Podman machine runs containers in one of two modes. Rootless is the default.
Rootless (default)
Rootful
Container UID mapping
Runs as your user inside the VM
Runs as root inside the VM
Security
Smaller blast radius
Broader privileges in the VM
Privileged ports (< 1024)
Cannot bind (e.g. port 80, 443)
Can bind
Some Docker tools
May need extra socket/config
Better compatibility
Editing VM system files
Requires podman machine ssh as user
Easier access to /etc/containers/
When to stay rootless
General development and learning
Running apps on high ports (3000, 8080, etc.)
You want the safer default
When to switch to rootful
Binding ports below 1024
Tools that assume root-owned containers or the root Podman socket
Permission errors editing registries or storage inside the VM
How to switch
Podman Desktop: Settings → Resources → select your machine → toggle rootful mode, then restart the machine.
CLI:
podman machine stop
podman machine set --rootful
podman machine start
To revert:
podman machine stop
podman machine set --rootful=false
podman machine start
Podman maintains separate connection endpoints for each mode:
podman system connection list
# podman-machine-default → rootless socket# podman-machine-default-root → rootful socket
Switch the active connection if needed:
podman system connection default podman-machine-default-root
Docker compatibility
Podman exposes a Docker-compatible API so tools and scripts written for Docker can talk to Podman instead. On macOS, Podman Desktop enables Third-Party Docker Tool Compatibility by default.
What gets mapped
When the machine is running, macOS exposes a system socket symlink:
ls -la /var/run/docker.sock
# → ~/.local/share/containers/podman/machine/podman.sock
Podman Desktop reports socket status under Settings → Docker Compatibility.
Using the Docker CLI with Podman
Ensure the Podman machine is running.
Enable Docker compatibility in Podman Desktop (on by default on macOS).
Install the Docker CLI if you do not have it (onboarding or brew install docker).
Verify:
docker info --format '{{.ServerVersion}}'# Should report a Podman version, not Docker Desktop
DOCKER_HOST (explicit configuration)
Some tools (Gradle, Testcontainers, CI scripts) need an explicit socket path instead of relying on /var/run/docker.sock:
docker context create podman \
--docker "host=unix://${HOME}/.local/share/containers/podman/machine/podman.sock"docker context use podman
Compose
Podman Compose — installed during onboarding; invoke as podman compose (preferred on Podman).
Docker Compose — works against the Docker-compatible socket when compatibility is enabled.
podman compose version
podman compose up -d
Compose project naming and networking differ slightly from Docker Compose in edge cases; prefer podman compose for Podman-native behavior.
Coexistence with Docker Desktop
Do not run Docker Desktop and Podman Desktop socket forwarding at the same time — both compete for /var/run/docker.sock. Quit Docker Desktop (or disable its socket) before enabling Podman Docker compatibility.
Gotchas
1. krunkit abort trap (Homebrew + libkrun)
Symptom:podman machine start fails with krunkit was terminated by signal: abort trap.
Cause: Missing Homebrew libraries in the krunkit dependency chain (libepoxy, molten-vk, etc.).
krunkit --version # should print a version, not crash
2. Mixed Homebrew and DMG installs
Installing Podman via Homebrew and Podman Desktop via .dmg (or vice versa) leaves duplicate podman/krunkit binaries and conflicting machine state. Uninstall one path completely before adopting the other.
3. krunkit not found
Podman Desktop onboarding or podman machine init may report that krunkit is missing when using a partial Homebrew install.
brew tap slp/krun
brew install krunkit
The official .dmg bundles krunkit and avoids this.
4. Rootless privileged ports
Binding host port 80 in rootless mode fails silently or with permission errors. Switch to rootful or use a high port (8080) with a reverse proxy.
5. ARM64 image platform
On Apple Silicon, prefer multi-arch images or specify the platform:
podman run --platform linux/arm64 ...
Running x86-only images requires emulation and is slower.
6. Docker CLI still talks to Docker Desktop
If docker info shows Docker Desktop after switching to Podman:
Quit Docker Desktop
Confirm Podman machine is running
Check Settings → Docker Compatibility in Podman Desktop
Run docker context ls and switch away from desktop-linux
7. Machine resource defaults
The default 2 GiB RAM is tight for multiple containers or image builds. Increase before heavy use:
podman machine set --memory 8192 --cpus 4podman machine start
Changes apply on next start.
8. Registries and VM config edits
Files like /etc/containers/registries.conf live inside the VM, not on macOS. Edit from the host:
podman machine ssh
# or, if permission denied in rootless mode:podman machine set --rootful && podman machine start
Verification
Run this checklist after setup:
# Machine runningpodman machine list
# Client ↔ VM connectivitypodman version
# Basic containerpodman run --rm hello-world
# Docker API socket (when compatibility enabled)ls -la /var/run/docker.sock
# Optional: Docker CLI against Podmandocker info --format '{{.ServerVersion}}'
In Podman Desktop: Settings → Resources shows the machine as Running; Settings → Docker Compatibility shows the socket as reachable.
NVIDIA Container Toolkit with CDI (Container Device Interface) enables Docker containers to access physical GPUs. On Proxmox VMs with GPU PCI passthrough, CDI specs must be explicitly generated and Docker must be configured to use them.
Prerequisites
Proxmox VM with NVIDIA GPU PCI passthrough configured (IOMMU, vfio-pci)
NVIDIA driver installed and nvidia-smi working on the host
On Proxmox GPU passthrough VMs, Docker’s CDI resolution may also require an amd.yaml spec. If NemoClaw or other GPU-passthrough tools fail with “AMD CDI spec not found”, create a minimal stub:
NVIDIA vGPU Manager: build, integrated registry, and ClusterPolicy (OpenShift Virtualization)
This note validates a common on-cluster workflow against NVIDIA’s published steps, then records that workflow when Quay (or another external registry) is not used and images land in the integrated OpenShift image registry instead.
NVIDIA Licensing Portal → Software Downloads → Driver downloads → Linux KVM complete vGPU package for the Product Version you intend to support.
Unzip and locate NVIDIA-Linux-x86_64-*-vgpu-kvm.run (or the AIE variant renamed per NVIDIA).
2. Clone NVIDIA’s build context and copy the installer
git clone https://gitlab.com/nvidia/container-images/driver
cd driver
Change into the vgpu-manager subdirectory for your OS line (NVIDIA’s OpenShift example historically used vgpu-manager/rhel8; confirm the right path for your OCP / RHCOS generation):
cd vgpu-manager/rhel8 # example only — pick the path NVIDIA documents for your releasecp /path/to/extracted/NVIDIA-Linux-x86_64-*-vgpu-kvm.run ./
3. Point the build at the integrated registry and RHCOS
Set VERSION to the vGPU software / driver version from the portal (NVIDIA’s examples look like 510.73.06; use your shipped version string).
Set OS_TAG to rhcos4.<minor> matching your OpenShift workers (NVIDIA documents this pattern).
Set PRIVATE_REGISTRY to the registry host and namespace prefix you will push to. For the integrated registry, that is commonly the external route hostname (for pushes from your workstation) plus project path, for example:
Use a meaningful tag (here ${VERSION}-${OS_TAG}) so ClusterPolicy and support tickets stay unambiguous.
4. Reference the image from in-cluster pulls
Operands pull using the in-cluster registry DNS (typically image-registry.openshift-image-registry.svc:5000/<project>/vgpu-manager:<tag>). Your ClusterPolicyvgpuManager.repository / image / version (or equivalent fields) must match how your installed GPU Operator version assembles the pull spec. Always confirm with:
Conceptually you are wiring repository + image + version so they resolve to the same image you pushed (often image: vgpu-manager, version: tag string like 510.73.06-rhcos4.15 if that is what you pushed).
oc get clusterpolicy -o yaml | less
oc get pods -n nvidia-gpu-operator
oc get nodes -l nvidia.com/gpu.workload.config=vm-vgpu
When the vGPU Manager and sandbox operands are healthy and nodes are labeled, continue with NVIDIA’s HyperConverged mediated device steps and VM attachment.
Common pitfalls
Wrong vgpu-manager subdirectory or OS_TAG for your RHCOS line → build succeeds but driver mismatches the node OS.
ClusterPolicy fields do not match the pushed tag → ImagePullBackOff on daemonsets or operator-managed pods.
Image in project A, operands in nvidia-gpu-operator without system:image-puller (or a pull secret) → pull forbidden across namespaces.
Enabling vgpuManager before the image exists → transient failures; enable or point after push.
Using the integrated OpenShift container image registry
OpenShift ships with a cluster-internal container registry (the Image Registry Operator, namespace openshift-image-registry). You can push images there instead of an external registry (for example Quay.io), as long as workloads that need the image can pull from the namespace where the image lives.
This is the same pattern NVIDIA documents for hosting built images (for example vGPU Manager) on the cluster’s own registry.
What you get
In-cluster pull hostname (from any pod on the cluster):
Private by default: images are not public on the internet; access is via Kubernetes RBAC and image pull credentials where needed.
Prerequisites
Registry is running — ManagementState should be Managed:
oc get configs.imageregistry/cluster -o jsonpath='{.spec.managementState}{"\n"}'
Storage — the registry needs a PVC or other supported backend (install-time or day-2 configuration). If the registry is not ready, pushes fail until storage is fixed.
Pushing from your laptop — you need a route (or port-forward) and authentication. Check for the default route:
oc get route -n openshift-image-registry
If there is no route, an admin can enable the default route or you can push from a Job or Build inside the cluster that uses the internal service DNS and a service account token.
Basic workflow: push an image to the integrated registry
Create or pick a project (namespace) to hold the image, for example nvidia-gpu-operator or a dedicated gpu-images project.
Log in to the registry from your workstation (using the external route host Red Hat documents for your version), with an OpenShift user token:
Use docker instead of podman if that is what you use.
Tag and push using the external host for the push client (OpenShift documents the exact hostname pattern; it is often default-route-openshift-image-registry.apps.<cluster-domain>).
podman tag mylocal/vgpu-manager:9.0.0.0 "$REGISTRY/nvidia-gpu-operator/vgpu-manager:9.0.0.0"podman push "$REGISTRY/nvidia-gpu-operator/vgpu-manager:9.0.0.0"
Reference the image from manifests using the in-cluster form (*.svc:5000/...) so nodes and pods resolve it without depending on the apps route from inside the cluster.
Use explicit tags (for example 9.0.0.0 or a driver version string), not only latest, so rollbacks and support questions stay clear.
Pulling from another namespace (for example GPU Operator operands)
If the image lives in project gpu-images but pods run in nvidia-gpu-operator, those pods need permission to pull. Typical approaches:
Grant the nvidia-gpu-operator default service account (or the operand SA) access to pull from the other namespace (for example oc policy add-role-to-user system:image-puller system:serviceaccount:nvidia-gpu-operator:default -n gpu-images), or
Copy/mirror the image into nvidia-gpu-operator and reference it there, or
Use an image pull secret in nvidia-gpu-operator that can authenticate to the registry (often unnecessary for same-cluster internal pulls when RBAC is set correctly).
Exact policy depends on your org’s security model; the important part is: internal registry does not remove the need for RBAC across namespaces.
Point repository, image, and version (and imagePullSecrets if you use them) at the integrated registry path the same way you would for Quay. The operator concatenates fields according to its CRD; confirm with:
oc get configs.imageregistry/cluster -o yaml | less
oc get route -n openshift-image-registry
oc get imagestream -n nvidia-gpu-operator # if you use ImageStreams; direct push without IS is also common
Further reading
Red Hat: Accessing the registry (push/pull, routes, and authentication for your OCP version).
This guide covers setting up Visual Studio Code (VSCode) as your primary development environment on enterprise devices for working with OpenShift, Ansible, Python, YAML, and Bash.
Prerequisites
Before configuring VSCode, ensure the following core tools are installed on your system. While VSCode is assumed to be installed, these underlying dependencies are required for functionality.
1. Git (git-scm)
Download and install Git from the official git-scm.com site.
Windows: Use the Git for Windows installer. Ensure “Git from the command line and also from 3rd-party software” is selected during installation.
macOS: Install via Homebrew (brew install git) or by installing Xcode Command Line Tools (xcode-select --install).
2. Python 3
Install Python 3.x from python.org or your system’s package manager.
Ensure pip is included in the installation.
Add Python to your system PATH.
3. Ansible Lint
Once Python is installed, install ansible-lint to enable playbook validation in VSCode:
pip install ansible-lint
Authentication: Personal Access Tokens (PAT)
In an enterprise environment, you will typically use a Personal Access Token (PAT) instead of your account password for Git operations over HTTPS.
Generating a PAT (GitHub Example)
Log in to your GitHub account.
Go to Settings > Developer settings > Personal access tokens > Fine-grained tokens (or Tokens classic).
Click Generate new token.
Give it a descriptive name (e.g., “VSCode Enterprise Desktop”).
Select the required scopes (typically repo for full control of private repositories).
Copy the token immediately. You will not be able to see it again.
Using the PAT
When you clone a repository or push changes for the first time, Git will prompt for your credentials.
Username: Your GitHub username.
Password: Paste your PAT here.
Note: On Windows and macOS, the system credential manager will securely store this token for future use.
Recommended Extensions
To get the best experience, install the following extensions from the VSCode Marketplace.
Core Languages & Tools
Technology
Extension Name
Publisher
Description
Python
Python
Microsoft
Rich support for Python (IntelliSense, linting, debugging).
YAML
YAML
Red Hat
YAML Language Support with built-in Kubernetes/OpenShift syntax.
Ansible
Ansible
Red Hat
Language support, linting, and integration for Ansible playbooks.
Bash
Bash IDE
mads-hartmann
Bash language server for better autocomplete and linting.
OpenShift
OpenShift Connector
Red Hat
Interact with OpenShift clusters directly from VSCode.
Version Control & Collaboration
Extension Name
Publisher
Description
GitLens
GitKraken
Supercharge Git capabilities, view line history, and repository heatmaps.
GitHub PRs & Issues
GitHub
Create, review, and manage GitHub Pull Requests and Issues within VSCode.
Git Graph
mhutchie
View a Git Graph of your repository and perform Git actions from the graph.
Utilities
Error Lens: Highlights errors and warnings inline in your code.
Path Intellisense: Autocompletes filenames.
Dotenv: Support for .env file syntax highlighting.
Installing Extensions
To install any of the recommended extensions:
Click the Extensions icon in the Activity Bar on the side of VSCode (or press Ctrl + Shift + X).
Type the name of the extension in the search bar.
Click Install.
Recommended Settings
Add these to your user settings.json (Ctrl + Shift + P > “Open User Settings (JSON)”) for a smoother experience:
BigFix Agent DaemonSet Deployment - DaemonSet design model for executing BigFix security agents on underlying OpenShift worker nodes.
Consolidation Roadmaps
Infrastructure Consolidation Roadmap - Multi-phase migration plan for transitioning core homelab services and workloads from independent virtual machines to unified Kubernetes/OpenShift containers.
Subsections of Architecture & System Designs
GitOps Cluster Consolidation & Version Control Roadmap
This roadmap outlines the strategic phased approach for consolidating disparate, tag-drifted, or unmanaged GitOps clusters (environments) back into a fully version-controlled lifecycle managed off of a single main branch with tagging.
Objective
To eliminate repository drift, simplify environment configuration, and establish a clear promotion path by:
Baselining all clusters onto a single trunk (main) with no active tags or branches.
Cleaning up all unused/stale git branches and tags.
Separating testing environments onto a dedicated long-lived Testing branch.
Pinning all remaining environments (production, shared services) to a new, immutable release tag (v2.0).
High-Level Flowchart
flowchart TD
%% Current State
subgraph Current [Current State: Drift & Disparate Mapping]
C_Prod[Prod Clusters / Tags/Branches]
C_Test[Test Clusters / Diverse Tags]
C_Unmanaged[Unmanaged Clusters / No Sync]
end
%% Phase 1
subgraph Phase1 [Phase 1: Trunk Consolidation]
Main_Branch[(Git Repo: main branch)]
P1_Sync[Point ALL Cluster GitOps/ResourceSync to main branch]
P1_Clean[Prune all stale tags & inactive branches]
end
%% Phase 2
subgraph Phase2 [Phase 2: Testing Separation]
Test_Branch[(Git Repo: Testing branch)]
P2_Sync[Point Test Clusters to 'Testing' branch]
end
%% Phase 3
subgraph Phase3 [Phase 3: Version Stabilization]
V2_Tag[(Git Tag: v2.0)]
P3_Sync[Point Production & Other Clusters to 'v2.0' tag]
end
%% Transitions
Current -->|Align and Point to main| P1_Sync
P1_Sync -->|Prune old Git refs| P1_Clean
P1_Clean -->|Create 'Testing' from main| Test_Branch
Test_Branch -->|Configure Sync| P2_Sync
P1_Clean -->|Tag main as v2.0| V2_Tag
V2_Tag -->|Configure Sync| P3_Sync
%% Styling
style Current fill:#fdd,stroke:#f66,stroke-width:2px;
style Phase1 fill:#ffd,stroke:#dd6,stroke-width:2px;
style Phase2 fill:#dfd,stroke:#6b6,stroke-width:2px;
style Phase3 fill:#ddf,stroke:#66b,stroke-width:2px;
Goal: Consolidate all GitOps controllers/ResourceSync configurations to point strictly to the main branch with zero tag/revision overrides.
Map and Align Configurations:
Update all cluster GitOps configs (e.g., Komodo [[resource_sync]] target revisions or ArgoCD target revisions) to use the main branch.
Ensure all cluster-specific directories (e.g., komodo/prod/, komodo/test/, komodo/dev/) are unified on main.
Reconcile and Validate:
Allow GitOps controllers to reconcile. Confirm that all clusters are running stable configurations served purely from main.
Repository Pruning:
Delete all unused, stale, and tracking branches.
Purge old tags from the remote git origin to prevent metadata clutter and reference confusion.
Phase 2: Testing Isolation
Goal: Branch out the testing clusters to a dedicated, long-lived branch to isolate validation work from main production configurations.
Branch Creation:
Branch Testing directly from the cleaned and verified main branch.
Target Re-pointing:
Modify the GitOps sync configurations of your Test Clusters to track the Testing branch instead of main.
Promotion Workflow Baseline:
Features/workloads are committed to main (or individual feature branches), validated, merged to main, and then merged from main to Testing for soak tests.
Phase 3: Release Pinning (v2.0 Tag)
Goal: Establish release stability by pinning production and all non-test clusters to an immutable point-in-time release.
Tag Generation:
Tag the stable main branch with the new version tag: v2.0.
Target Re-pointing:
Update the GitOps sync configurations for all remaining clusters (e.g., Production, Shared Core Services) to target refs/tags/v2.0.
Establish Lifecycle Operations:
All subsequent production updates are promoted from Testing back to main, and then promoted to production by updating/moving the target tag (e.g., v2.1, v2.2) or updating the tag pointer.
Key Benefits
Clean Git Slate: Drastically reduces active git refs, making branch history easy to read and manage.
Predictable Promotion: Features move from main $\rightarrow$ Testing branch $\rightarrow$ v2.0 tag.
Auditability: Clear division of what is being tested versus what is running in production based purely on git references.
Long-Term Management
To ensure continuous operational stability and transparency, clusters are categorized by environment type and mapped to specific Git target references, utilizing a flat repository structure for environment settings.
Cluster Target References
Different environments track different Git references depending on their operational requirements and strictness levels:
Test Clusters:
Target Reference:Testing branch.
Usage: Serves as the immediate validation target for developers and GitOps sync, ensuring new features and configurations are continuously integrated and tested.
Pre-Production and Normal Production Clusters:
Target Reference:Main branch.
Usage: Serves as the primary stable branch. Pre-production acts as the final validation gate, while normal production tracks the main trunk directly for rapid, reliable delivery of verified features.
Strictly Managed Clusters:
Target Reference: Tagged releases (e.g., v2.x).
Usage: Pin to immutable release tags to guarantee absolute control over rollout schedules and facilitate rigorous change management.
Flat Repository Structure
Instead of deep hierarchies or branching drift for configuration variants, the repository is organized with a flat directory structure at its root or specific config directory (e.g., komodo/ or gitops/).
For each environment, a dedicated, self-contained subdirectory contains all specific configurations and override settings:
gitops-repo/
├── test/ # Settings specific to Test clusters (tracks 'Testing' branch)
├── preprod/ # Settings specific to Pre-production (tracks 'Main' branch)
├── prod/ # Settings specific to Normal Production (tracks 'Main' branch)
├── prod-strict/ # Settings specific to Strictly Managed Production (tracks immutable tags)
└── shared/ # Shared base resources or templates referenced by environments
This flat design guarantees that:
High Visibility: All environment-specific settings are completely visible in their respective subdirectories.
No Hidden Overrides: Eliminates nested inheritance or complex drift, making configurations easy to audit, compare, and modify.
Declarative Consistency: All target references can read from the same commit or release tag while cleanly resolving environment-specific configurations via their designated subdirectory.
BigFix Host Agent Client Deployment on OpenShift via GitOps
Overview
This guide details the architecture, design decisions, and configuration required to deploy the HCL BigFix Host Agent Client onto a Red Hat OpenShift Container Platform (OCP) cluster using Argo CD.
This implementation rigorously adheres to and matches the standardized Helm component structures, sync-waves, and folder layouts defined in the helm-component-pattern artifact collection.
The Host Agent Client runs directly on each physical/virtual cluster node. It monitors host operating system settings, hardware properties, and installed package inventories, and applies host-level configuration patches.
Architecture
Because the BigFix Host Agent inspects and manages the underlying Red Hat Enterprise Linux CoreOS (RHCOS) or RHEL host OS, it must execute with root/privileged credentials.
hostPID: true: Direct visibility into the host’s process tree.
hostNetwork: true: Direct communication with the network via host IP interfaces.
Host Volume Mounts:
Host / mounted to /host so the agent can read/write root configuration files.
Host /var/opt/BESClient mounted to persist client database, certificates, and logging state across container rebuilds.
OpenShift Security Posture & SCCs
By default, OpenShift strictly forbids containers from running as root, mounting host paths, or accessing host namespaces. To deploy a privileged host agent, you must grant the DaemonSet’s ServiceAccount the privileged Security Context Constraint (SCC).
This is achieved securely in GitOps by creating a RoleBinding within the target namespace that binds the ServiceAccount to the cluster-provided system:openshift:scc:privilegedClusterRole:
The deployment is self-contained under artifacts/openshift/bigfix-agent-deployment/.
Namespace & ServiceAccount:
The namespace hcl-bigfix is isolated with monitoring active.
Masthead Secret Injection:
Every BigFix client requires an actionsite.afxm masthead file to verify keys and trace upstream Relays. This is injected as a Kubernetes Secret of type Opaque.
DaemonSet Setup:
The DaemonSet includes explicit tolerations to ensure the client is deployed onto Master/Control-Plane nodes as well as worker nodes, ensuring 100% host coverage:
Missing Master Tolerations:
Without master/control-plane node tolerations, control-plane nodes will lack auditing coverage, creating a blind spot in compliance and security tracking.
State Persistence:
If the /var/opt/BESClient host volume mount is omitted, the client will generate a new unique ComputerID on every pod restart. This will bloat the BigFix Console with thousands of duplicate offline entries. Always ensure this directory is mapped to a host path (using DirectoryOrCreate).
BigFix Inventory (BFI) Discovery & NFD Deployment via GitOps
Overview
This guide details the architecture, design decisions, and configuration required to deploy the HCL BigFix Inventory (BFI) discovery and sub-capacity licensing integration on Red Hat OpenShift Container Platform (OCP).
This implementation rigorously adheres to and matches the standardized Helm component structures, sync-waves, and folder layouts defined in the helm-component-pattern artifact collection.
The BFI discovery integration is a headless, API-level integration. It does not install a resident server. Instead, it deploys a ServiceAccount and a long-lived API token that allows the external BigFix Inventory server to query OpenShift APIs. It also deploys the Red Hat Node Feature Discovery (NFD) Operator to map and label host CPU sockets and cores, which BFI queries to calculate Processor Value Unit (PVU) licensing.
Architecture & Integration Flow
The integration bridges the external BFI server and the OpenShift Control Plane:
API Discovery: The external BFI server connects via the big-fix-serviceaccount token to read nodes, namespaces, and virtualmachineinstances (to track OpenShift Virtualization VM cores).
CPU Topology Labeling: The NFD Operator discovers core and socket details on each bare-metal or virtual node, and matches them against an ILMT-specific NodeFeatureRule to apply standard capacity labels (e.g., ibm.ilmt.cores, ibm.ilmt.sockets). BFI queries these labels to generate compliant sub-capacity reports.
GitOps Sync-Wave Strategy
Applying operators and their dependent Custom Resources (CRs) simultaneously inside GitOps can cause synchronization deadlocks because the Custom Resource Definitions (CRDs) do not exist when the CR templates are parsed.
To ensure a seamless, zero-race-condition bootstrap, we separate the operator from its CR instances and structure the workloads across Argo CD Sync-Waves:
Wave
Component
Action
2
components/openshift-nfd/operator
Installs the NFD namespace, OLM OperatorGroup, and Subscription to register the CRDs.
3
components/openshift-nfd/instance
Installs the NodeFeatureDiscovery controller instance custom resource.
4
components/hcl-bigfix-discovery
Deploys the discovery namespace, API Token Secret, RBAC rules, and the NFD NodeFeatureRule CR.
Configuration Details
RBAC Permissions for BFI Server
The external BFI Server relies on the ServiceAccount token to authenticate and call APIs. The ClusterRoles must be isolated to read-only resource lists:
The NFD NodeFeatureRule injects values into labels using Go template notation ({{ }}). Because Helm also parses Go templates, you must escape these braces in Helm source templates:
This forces Helm to output the braces literally, allowing the NFD operator to interpret them at runtime.
Common Pitfalls
ServiceAccount Tokens in OpenShift 4.11+:
Kubernetes 1.24+ and OpenShift 4.11+ no longer generate static long-lived token Secrets automatically when a ServiceAccount is created. You must manually define a Secret of type kubernetes.io/service-account-token with the ServiceAccount annotation:
CRD Lifecycle Race Conditions:
Do not combine NFD Operators and NodeFeatureRule into a single Helm component. Always run them in separate waves or as distinct applications to ensure the operator is running and ready to handle the NodeFeatureRule Custom Resource.
This guide details how to set up an on-demand, dynamic Tailscale integration in OpenBao. When a client (such as a Docker container) requests a secret from docker/tailscale/auth-token/<name> (for example, docker/tailscale/auth-token/nginx), OpenBao will automatically call the Tailscale API to generate a short-lived, preauthorized, and ephemeral authentication key labeled for that specific container, returning it to the client immediately.
Architectural Choices & Routing
Can we support other paths later (e.g., docker/tailscale/idp)?
Yes, absolutely. You have two clean options to achieve this:
Option A: Extend the Plugin (Recommended)
Our plugin is modular. If you need a new path later, such as docker/tailscale/idp that interfaces with another service or maps configuration, you can simply add a new path handler inside the plugin code (e.g., creating path_idp.go and adding it to Paths in backend.go). This keeps all Tailscale-related integrations housed within a single secrets engine binary.
Option B: OpenBao Nested Mount Routing
OpenBao (and HashiCorp Vault) routes API requests based on the most specific matching path prefix.
If you mount our plugin at docker/tailscale, it owns everything under docker/tailscale/*.
However, you can mount a completely separate secrets engine (like a standard Key-Value store or a database engine) at a nested subpath, such as docker/tailscale/idp:
bao secrets enable -path=docker/tailscale/idp kv-v2
In this scenario, a request to docker/tailscale/idp/my-secret is routed to the KV engine, while a request to docker/tailscale/auth-token/nginx is routed to our Tailscale plugin.
Prerequisites
OpenBao running in dev mode on the host (127.0.0.1:8200 with root token dev-only-token).
Docker installed on the host (used to compile the plugin without requiring a Go installation on your machine).
A Tailscale API Key (e.g., example-api-key-here) and your Tailnet name.
Step-by-Step Configuration
Step 1: Compile the Plugin using Docker
Since Go is not required on your host, you can compile the plugin binaries for macOS and Linux using a containerized Go environment.
Navigate to the plugin source folder:
cd wip/openbao-tailscale-integration
Build the Docker builder image:
docker build -t openbao-tailscale-builder .
Run the builder to extract the binaries into a bin/ directory on your host:
docker run --rm -v "$(pwd)/bin:/out" openbao-tailscale-builder
This creates four binaries in your wip/openbao-tailscale-integration/bin/ folder:
To load external plugins, OpenBao must be configured with a plugin directory.
If running OpenBao in Dev Mode on your macOS host, start the server using the -dev-plugin-dir flag and point it to the absolute path of your built binaries:
# Create a dedicated plugin directorymkdir -p /Users/sam/Workspace/openbao-plugins
# Copy the appropriate binary to your plugins directory# For Apple Silicon macOS:cp bin/openbao-plugin-secrets-tailscale-darwin-arm64 /Users/sam/Workspace/openbao-plugins/openbao-plugin-secrets-tailscale
chmod +x /Users/sam/Workspace/openbao-plugins/openbao-plugin-secrets-tailscale
# Start OpenBao dev server with the plugin folder loadedbao server -dev -dev-root-token-id="dev-only-token" -dev-plugin-dir="/Users/sam/Workspace/openbao-plugins"
Step 3: Register the Plugin in the Catalog
Before enabling the secrets engine, OpenBao requires you to register the binary in its internal catalog with a SHA256 signature to guarantee code integrity.
This script can run as the entrypoint inside your docker container (in this case, for an nginx service) to automatically register itself on Tailscale using the token fetched from OpenBao:
#!/bin/sh
set -e
# Define the service nameSERVICE_NAME="nginx"echo "Fetching Tailscale auth token from OpenBao for ${SERVICE_NAME}..."# Fetch the dynamic auth token using curlBAO_RESPONSE=$(curl -s \
--header "X-Bao-Token: dev-only-token"\
http://host.docker.internal:8200/v1/docker/tailscale/auth-token/${SERVICE_NAME})# Extract the token value from the JSON responseTAILSCALE_AUTH_KEY=$(echo "$BAO_RESPONSE" | grep -o '"auth_token":"[^"]*' | grep -o '[^"]*$')if[ -z "$TAILSCALE_AUTH_KEY"]; then echo "Error: Failed to retrieve Tailscale auth key from OpenBao." exit 1fiecho "Successfully retrieved auth token. Starting Tailscale..."# Authenticate and bring up Tailscale node in the containertailscaled --state=mem: &
sleep 2tailscale up --authkey="${TAILSCALE_AUTH_KEY}" --accept-dns=false
echo "Tailscale is connected!"exec "$@"
Example: Docker Compose configuration
version: '3.8'services:
app-node:
image: alpine:latestcontainer_name: tailscale_nginxcap_add:
- NET_ADMINdevices:
- /dev/net/tun:/dev/net/tunvolumes:
- ./entrypoint.sh:/entrypoint.shentrypoint: ["/bin/sh", "/entrypoint.sh"]
command: ["ping", "google.com"]
extra_hosts:
- "host.docker.internal:host-gateway"# Resolves the host OpenBao address on Linux
Troubleshooting & Common Pitfalls
Permission Denied (Execution): If OpenBao logs fork/exec /Users/sam/Workspace/openbao-plugins/openbao-plugin-secrets-tailscale: permission denied, verify that the plugin file is executable by running chmod +x on it.
Architecture Mismatch: Ensure you copied the correct architecture binary. If OpenBao runs on Apple Silicon macOS, you must use the darwin-arm64 binary. If it runs inside a Linux Docker container, you must use the linux-amd64 or linux-arm64 binary depending on your host.
Invalid Tag Prefix: When requesting a token with tags, Tailscale API strictly requires tags to begin with tag: (e.g. tag:docker, not docker).
Openshift-Learning-Paths
Subsections of Openshift-Learning-Paths
Module 0 — Prerequisites & How to Use
Audience: Everyone starting the path — new hires and existing engineers upskilling.
Outcomes: Set up a personal lab, understand how verification works in this path, and adopt the “model before commands” mindset that every later module assumes.
Checklist — work through in order
Read Why this module exists and adopt the “model before commands” mindset
Confirm you know where completion is recorded and who reviews it
Pass the Verification checks at the bottom
Why this module exists
This path deliberately teaches models before commands. The single biggest obstacle to learning DevOps tooling is not missing knowledge — it is a mental model that needs rebuilding, not extending. Each module opens with the mental shift required. Treat the lab as non-negotiable: every concept should map to something you can inspect in a console or from a CLI.
Topics
Lab options — a single local cluster (or cloud trial) for Modules 1–4; a hub + managed cluster for Modules 5–6; bare-metal/simulated nodes for Module 8.
Verification philosophy — scenario-based checks, not flash-card recall. If you need notes to do the check, the module is not done.
Beginner’s mind — some prior knowledge transfers; much does not. Anchor too hard on old analogies (e.g., “clusters are like vCenters”) and the gaps get harder to find.
Pace — self-paced. Modules 1–2 are days; Modules 3–7 are weeks. Module 8 is a specialist track.
Resources
Verification
You have a working lab environment you can ssh/CLI into.
You can state, without notes, the difference between learning a command and learning a model, and why this path leads with the model.
You know where to record completion and who reviews it.
Module 1 — Git, GitHub & GitLab Best Practices
Audience: Anyone using Git day to day — developers, writers, infrastructure and platform engineers. If you’ve used Git but not internalized why the model works, this module is for you.
Outcomes: Understand how Git stores history (the inside-out mental model); use the daily workflow (clone → branch → commit → push → PR/MR → merge) without looking up commands; reason about git log, diff, revert; operate comfortably in both GitHub and GitLab with branch protection, CODEOWNERS, and SSO/SAML; and earn the GitHub Foundations certification.
Work the Mechanics checklist (clone/branch/commit/push/PR, diff, log, revert vs reset, CODEOWNERS, branch protection)
Complete enterprise hosting onboarding (SSO-authorized PAT, 2FA)
Certify: book GitHub Foundations (GitHub/PSI · $99 · 75 questions · 120 min · 700/1000 · no prerequisites). Study aids: Microsoft Learn GitHub Fundamentals, Pro Git, LadyKerr github-certification-guide
Pass the scenario-based Verification at the bottom
Mental model first — not just “how to Git”
Most Git tutorials start with commands. This module starts with the model, because commands make no sense without it. Git stores snapshots, not diffs; commits are immutable, content-addressed objects; branches are labels, not containers. Every confusing moment (detached HEAD, merge vs rebase, rejected pushes) is a model mismatch.
Do Stage 0 before anything else.
Git vs Git hosting platforms
Git = decentralized version control (created by Linus Torvalds, 2005). Every developer has the full history. Identical everywhere it’s installed.
GitHub / GitLab = hosting services on top of Git: web UI, PRs/MRs, issues, CI/CD, wikis. The collaborative layer differs; Git itself does not care which host you use.
Core workflow is identical on both:clone → checkout -b feature → add → commit → push -u origin feature → open PR (GitHub) / MR (GitLab).
GitHub
GitLab
Meaning
Pull Request (PR)
Merge Request (MR)
Proposed, reviewed change
GitHub Actions
GitLab CI
CI/CD (GitLab built-in, GitHub opt-in)
Organizations
Groups
Team management
gh CLI
glab CLI
Terminal tooling
Git as the tool for change management
Git doesn’t create governance — the workflows on top do. This reframe is the cultural core of everything later (especially GitOps).
Traditional process
Git / hosting-platform equivalent
Change ticket
PR / MR — the diff is the change; description is the body
Peer review / approval
PR/MR review — named, required approvers via branch protection
Approval record
Merge commit — named, timestamped, traceable
“Who changed this?”
git log, git blame, PR history
Emergency / break-glass
Emergency PR with post-hoc review; audit trail preserved
Rollback
git revert — both change and rollback preserved in history
Git makes governance faster, more traceable, and automatable at the enforcement layer.
Stage 0 — Mental model (free, ~2h)
Git For Ages 4 And Up — Michael Schwern (~1h 40m). Teaches objects → commits → labels → staging → remotes with physical props. After this, detached HEAD, rebase, and reset become logical consequences of the model.
Verification: On paper/whiteboard, draw the relationship between a blob, a tree, a commit object, a branch label, HEAD, and a remote ref. Explain what git commit physically does to those objects.
Verification: Clone a repo, create a branch, edit one line, commit, push, open a PR/MR. Write the description as a change ticket: what changed, why, risk, how to verify.
Deeper chapters for submodules, rerere, merge strategies
Verification: Complete “Introduction Sequence” + “Ramping Up” of learngitbranching. Explain, without notes, git rebase main vs git merge main on a feature branch.
Mechanics checklist (Day-to-day GitOps work)
Topic
Why it matters
Install Git; set user.name / user.email
Every commit attributed; matches policy/audit
Clone, remote, fetch vs pull
GitOps tooling reads from remotes; needs reproducible local copy
Branch / commit / push
Feature branch → PR → merge to main/env branch
PR/MR lifecycle
Where review/approval gate automation
Diff (git diff, IDE)
Constantly compare YAML to cluster behavior
git log / git log --oneline --graph
“What changed and when?”
git stash
Shelve WIP when switching branches
git revert vs git reset
revert safe on shared branches; reset rewrites history (local only)
You may not push to main — that is correct and intentional
Optional: gh / glab CLI
Open PRs/MRs, check CI from terminal
Enterprise hosting notes
Complete IT device/token onboarding before pushing — PATs often need one-time SSO authorization.
Prefer fine-grained PATs (or deploy keys / service-account tokens for CI) over classic repo-scoped tokens.
Certification
Certification
Aligns with
Notes
GitHub Foundations
Stage 0–2 + enterprise notes
Validates Git + GitHub fundamentals; pair with the Microsoft GitHub Fundamentals learn path as study material. Exam booked via
Verification (scenario-based)
Clone → branch → edit → push → PR: as above, with ticket-style description.
Explain the model: without notes — git commit (local) vs git push (publish); git pull (laptop) vs merging a PR/MR (review gates, approval record, audit).
Read history: from git log, answer who changed deployment.yaml two weeks ago, what, and was it reviewed?
Recover: make a bad commit on a feature branch; git revert it; explain why not git reset on a shared branch.
Module 2 — Containers & Registries
Audience: Engineers new to containerization, or coming from VM-centric operations.
Outcomes: Build and run container images; understand the layer model, image digests, and registries; reason about base images, tags vs digests, and registry authentication — the foundation for Kubernetes (Module 3).
Checklist — work through in order
Read the mental model below (images, layers, tags vs digests, Podman vs Docker)
Learn containers before Kubernetes — do a Docker/Podman fundamentals course: KodeKloud “Docker for the Absolute Beginner” or Podman docs getting-started
Build and run your first image from a minimal base (UBI/distroless)
DO180 (Red Hat: Containers, Kubernetes & OpenShift) as the formal path — free Red Hat Developer tutorials as a no-cost alternative
Practice registry push/pull, tags vs digests, and image scanning
Pass the scenario-based Verification at the bottom
Mental model
A container is an isolated, runnable instance of an image — an immutable, layered filesystem plus metadata. Unlike a VM, a container shares the host kernel and has no hypervisor. The unit Kubernetes reasons about is the container (packed into Pods), not the VM.
Key shifts from VM thinking:
Images are immutable artifacts, not running state. You rebuild, not “snapshot and edit.”
Layers are content-addressed and shared — changes only add new layers.
Tags are mutable pointers; digests are immutable. Production should pin by digest.
Write a Containerfile for a small HTTP app from a minimal base image; build it; run it; curl the exposed port.
Inspect layers (podman history / docker history); explain which instruction added which layer and why caching matters.
Pull an image by tag, then by digest; explain why the digest is reproducible and the tag is not.
Push an image to a registry; explain auth and how a cluster later pulls it (ties to Module 3 image pulls + Module 8 disconnected mirrors).
Module 3 — Kubernetes & OpenShift Core
Audience: Infrastructure/platform engineers and developers operating or deploying on Kubernetes/OpenShift. (VMware admins: this is where you rebuild the mental model from Kubernetes up — see the mapping scaffold, then discard it.)
Outcomes: Read and reason about YAML; navigate console + CLI; deploy and diagnose a stateless app; operate cluster-level concerns — cluster operators, MachineConfig, nodes, auth, SCCs, OLM, multi-tenancy, networking, storage, observability, and backup/DR.
Checklist — work through in order
Prereq: basic Linux (Red Hat RH124 or equivalent) — you will live on the CLI
Read the Mental shift below and internalize desired-state / nodes-are-cattle
3.4 Storage — StorageClass, PV/PVC, CSI, ODF, RWX vs RWO
Courses: KodeKloud “Kubernetes for the Absolute Beginners” / CKA / CKAD (Mumshad) for vendor-neutral depth; DO180 → DO280 for the OpenShift track
Certify (optional, vendor-neutral): CNCF KCNA (entry) → CKAD or CKA (performance-based, 66% to pass, $445). OpenShift track: EX280 (Module 9)
Pass the scenario-based Verification in each sub-section
Mental shift (read first)
OpenShift is a Kubernetes platform. OpenShift Virtualization (Module 4) is a Kubernetes add-on that runs VMs inside Kubernetes. The substrate is Kubernetes — not a hypervisor.
Treat vCenter/ESXi analogies as a temporary scaffold only. They break:
Console is read/observe, not source of truth — Git is (Modules 5+).
Nodes are cattle — drainable/replaceable; state lives in PVCs.
Manifests are desired-state — controllers reconcile continuously; divergence is auto-corrected.
SR-IOV — hardware virt for high-throughput/low-latency (telco/RAN).
Verification: Create a NAD bridge; attach a pod; confirm secondary interface; explain which component provisioned each. Write a default-deny + explicit-allow NetworkPolicy; confirm it blocks unlabeled traffic; explain why it is not an NSX firewall rule.
3.4 — Storage deep dive
StorageClass — provisioner (CSI) + params; the datastore-type choice.
PVC / PV — request vs provisioned; PVC persists independently of the pod.
CSI — plugin API to storage back ends; swap back end = StorageClass change.
ODF — Ceph-based SDS; RWX (CephFS) required for live migration (Module 4).
DataVolume / CDI — how every VM disk is imported (Module 4).
Verification: Create PVC from default StorageClass; mount in pod; write file; delete pod; confirm persistence. Set reclaim Retain on another PVC; delete; find retained PV; explain next step. Explain why live migration needs RWX.
Module 4 — OpenShift Virtualization
Audience: Platform engineers who need to run and migrate VMs on Kubernetes/OpenShift.
Outcomes: Create, start, stop, and migrate VMs; connect storage/networking; understand KubeVirt primitives (VirtualMachine, VirtualMachineInstance, DataVolume) as Kubernetes resources — because that is what they are; perform live migration and plan bulk migration from vSphere (MTV).
Prerequisite: Module 3 substantially complete. A VirtualMachineInstanceis a Pod — you cannot operate it without understanding Pods, scheduling, networking, and storage.
Checklist — work through in order
Confirm Module 3 is substantially complete (a VMI is a Pod)
Pass the scenario-based Verification at the bottom
Mental model
OpenShift Virtualization runs VMs inside Kubernetes via KubeVirt. The hypervisor is a process inside a Pod. The scheduler does not know or care it’s a VM. This is why Modules 2–3 come first.
Topics
KubeVirt primitives — VirtualMachine (desired-state), VirtualMachineInstance (running Pod with hypervisor), DataVolume (managed PVC + import pipeline)
VM networking — secondary networks via NAD; pod overlay (OVN) vs bridge-attached VLANs
VM storage — DataVolume import (HTTP/registry/PVC clone); RWX requirement for live migration; VolumeSnapshot for disk backups
Live migration — mechanism, objects (VirtualMachineInstanceMigration), shared-storage + node-scheduling requirements
Create a VM from template/YAML; console/SSH in; find the backing VMI Pod via oc get pods; describe it; confirm it’s what Kubernetes schedules.
Live migration(multi-node + RWX required; not on SNO): migrate between workers; explain which K8s mechanisms determined placement. SNO fallback: trace every object (VirtualMachineInstanceMigration, source/target VMI pods, shared RWX PVC, QEMU channel); explain why SNO can’t (no valid target node) and the minimum topology.
Walk one MTV planning chapter: source inventory, network/storage maps, cutover — even if you migrate only one small VM.
Module 5 — CI/CD & GitOps
Audience: Engineers ready to apply Git-as-change-management to cluster configuration and application delivery.
Outcomes: Build CI/CD pipelines; use Argo CD (OpenShift GitOps) as the reconciliation engine; structure a multi-cluster GitOps repo; handle secrets safely; diagnose sync/drift and failure modes.
Depends on: Module 1 (Git) and Module 3 (cluster operations). Lab: one cluster + one Git repo is sufficient.
Read the mental model (Git is source of truth; console is observe-only)
Learn GitOps + Argo CD:Akuity Academy — Intro to GitOps & Argo CD (free, by the Argo creators) or KodeKloud/Coursera “Argo CD for the Absolute Beginners”. Argo CD is a CNCF Graduated project.
Deploy your first Argo CD Application from Git; test drift + self-heal
Learn ApplicationSet and app-of-apps; build the components/groups/clusters repo structure from the redhat-cop template
Pick a secrets pattern (Sealed Secrets / External Secrets Operator)
CI side: build a pipeline (GitHub Actions / GitLab CI / Tekton) that publishes images + manifests
Pass the scenario-based Verification at the bottom
Mental model
A PR merge replaces a change ticket. A git revertreplaces an emergency rollback. Argo CD closes the loop: it continuously reconciles what is in Git against what runs in the cluster. The console is read/observe only — changes made there do not survive a sync.
GitOps repo structure — get this right early. Use the components / groups / clusters pattern:
components/ # atomic, reusable building blocks — one concern per subfolder
groups/ # composable cluster profiles (Kustomize Component, not overlay)
clusters/ # cluster-specific config — selects which groups to compose
Groups are Kustomize kind: Component (composable, not inherited) — a cluster can belong to all + non-prod + geo-east simultaneously. Reference redhat-cop/gitops-standards-repo-template and the 80+ component library redhat-cop/gitops-catalog as remote bases instead of writing operator YAML from scratch. A Helm variant uses mustMergeOverwrite with component-<name> keys.
Mono-repo vs multi-repo — most teams start mono-repo, split when access control forces it.
Secrets in GitOps — never commit plaintext/base64 secrets. Three patterns: (1) Sealed Secrets (asymmetric, in-cluster decrypt — simple); (2) External Secrets Operator (vault-backed, preferred for prod); (3) Helm Secrets / SOPS (encrypted values).
CI/CD (pipelines)
Build/test/publish images and manifests in CI; let GitOps deliver to clusters.
Create an Argo CD Application from Git. Change replica count/image tag in Git; watch convergence. Roll back via git revert; watch follow.
Introduce drift — change a resource via oc, bypassing Git. Confirm Argo CD detects/remediates per sync policy; explain why the console change didn’t survive.
Create an ApplicationSet generating one Application per directory; add a directory; confirm auto-creation.
Failure modes: push broken YAML (syntax error / missing field) — what state? How to find the sync error without console? Deploy a crashlooping pod — distinguish OutOfSync (differs from Git) from Degraded (matches Git but unhealthy); explain how both can be true.
Repo structure: clone the COP template; map each folder from kustomization.yaml only. Design groups/ for all + virt-enabled + edge-sno; explain how a cluster in both virt-enabled and edge-sno references both via kind: Component.
Catalog: find NMState/MetalLB/ESO bases in gitops-catalog; create components/nmstate/kustomization.yaml referencing the catalog; point an Application at it; confirm install.
Secrets: a teammate committed a base64 Secret. Explain exposure, remediate with Sealed Secrets vs ESO (why), and prevent recurrence via pre-commit hook or CODEOWNERS.
Module 6 — Fleet Management with ACM
Audience: Engineers operating multiple clusters who need consistency, compliance, and declarative correction at fleet scale.
Outcomes: Govern a fleet declaratively with RHACM — import clusters, target via labels, report compliance, enforce or inform, and bridge ACM + Argo CD (hybrid). Understand the decision framework: when should a config NOT be in Argo CD?
Prerequisite: Modules 3 and 5. Lab: hub cluster with ACM + OpenShift GitOps, plus ≥1 managed cluster. Single-node lab is insufficient.
Checklist — work through in order
Read the fleet-thinking mental shift below (policy-first, not cluster-first)
Stand up the lab: hub cluster with RHACM + OpenShift GitOps + ≥1 managed cluster
RHACM governance & policy — ACM governance docs; import a cluster, label it, write your first policy
Learn PolicyGenerator (YAML → policies) and Placements/PlacementBindings for label targeting
Practice inform vs enforce and the Argo CD Application vs ACM Policy decision table
DO432 (Multicluster Management with Red Hat Advanced Cluster Management for Kubernetes) → validates with EX432
Complete the scenario-based verification at the bottom
Mental shift — fleet thinking
Single-cluster instinct
Fleet-operator reframe
“I’ll fix this cluster”
“I’ll fix the policy; ACM applies it everywhere”
“Who changed that?”
“Which commit changed the policy? Which clusters are non-compliant?”
“I’ll deploy this operator”
“All virt=enabled clusters should have the Virt operator — write a policy”
Console change then document
Console is observe; Git is the fleet change record
Topics
Cluster lifecycle with ACM — import, label for targeting, view fleet compliance
PolicyGenerator — YAML → RHACM policies via kustomize plugin
Secret management — ESO + Vault back end (prereq for Module 8)
Key decision: Argo CD Application vs ACM Policy
Signal
Argo CD / operators-installer
ACM Policy
Who decides it exists?
A team owns it
Platform mandate for all of type X
If missing?
Workload breaks; team fixes
Compliance violation; platform must know
Enforcement required?
Reconciliation sufficient
Drift detected/auto-corrected
Environment variation?
Yes (dev alpha, prod stable)
No (same baseline)
Audit needed?
Git log + PR
ACM dashboard + policy report + Git log
Lifecycle stage
Day 2 workload
Day 1 bootstrap / org baseline
Use ACM Policy for: NMState, cert-manager, Virt operator, file-integrity, OAuth, kubelet config, kubeadmin removal, pull-secret distribution. Use Argo CD for: team-owned operators varying by env/team.
Grey zone: a mandate on a heterogeneous fleet needing version variation → use ACM policy templating (Go-template against cluster labels), not a switch to Argo CD. The hybrid pattern: ACM delivers an Argo CD ApplicationSet to every cluster; ACM ensures the object exists, Argo CD reconciles content.
Import a second cluster; label it; write a PolicyGenerator enforcing an RBAC/alertmanager rule on that label; confirm compliance in ACM console; flip inform→enforce; observe difference.
Explain fleet compliance without notes: what “non-compliant” means, who’s notified, monitoring vs remediation policy.
Scenario: Virt operator on all prod clusters (mandate) + Strimzi only on one team’s clusters. Argue the tool for each; what changes if Virt becomes a regulatory requirement?
Module 7 — Observability & Day-2 Operations
Audience: Engineers responsible for keeping workloads and clusters healthy, observable, and recoverable.
Outcomes: Monitor with Prometheus/Alertmanager; aggregate logs with Loki; define SLO-style alerts; back up and recover workloads (OADP) and the control plane (etcd); reason about the two recovery models.
Builds on: Module 3.2 (observability + backup/DR sections).
Checklist — work through in order
Read the Day-2 mental model below (detect and recover, not click and hope)
Logs — deploy and query LokiStack (current logging backend)
Diagnostics — practice oc adm must-gather vs oc adm inspect
Backup & DR — OADP/Velero for workloads + etcd backup for the control plane; understand the two recovery models (restore vs rebuild)
Complete the scenario-based verification at the bottom
Mental model
Day-2 operations is detect and recover, not click and hope. Observability tells you what’s wrong; backup/DR tells you how to get back. Both are declarative and version-controllable.
Topics
Metrics — Prometheus + Alertmanager (installed by default); user-workload monitoring is opt-in (enableUserWorkload: true in cluster-monitoring ConfigMap). Key objects: PrometheusRule, AlertmanagerConfig.
Logs — LokiStack is the current logging backend (replaced EFK/Elasticsearch).
Diagnostics — oc adm must-gather (full snapshot for support/post-incident) vs oc adm inspect (targeted resource inspection).
Write a PrometheusRule firing when any namespace has >10 pods Pending >5min; confirm in Alertmanager. (Prereq: enable user-workload monitoring.)
Explain what oc adm must-gather collects and when to use it vs oc adm inspect.
Run/trace an etcd backup; describe contents, storage location, restore procedure. Compare to a vSphere snapshot: what etcd preserves that GitOps rebuild can’t, and vice versa.
Explain the OADP backup flow for a Virt VM and why the KubeVirt Velero plugin is required for consistent VM backup.
Module 8 — Zero Touch Provisioning (Specialist Track)
Who this is for: Teams managing 50+ clusters, especially remote/edge sites where manual install isn’t viable (retail, utility, telco). This is a distinct engineering specialty — Day 0/1/2 automation — beyond fleet operations. If you manage 3–20 clusters in a datacenter, Module 6 is sufficient; return here when provisioning becomes a bottleneck.
Outcomes: Automate the full cluster lifecycle — bare-metal prep through install into Day-2 — via the GitOps ZTP pipeline (SiteConfig, PolicyGenTemplate, TALM, ClusterGroupUpgrade, ClusterCurator/AAP, ESO).
Depends on: Modules 3–6 complete. ZTP uses Pods, operators, ACM policy, and Argo CD Applications internally.
Checklist — work through in order
Specialist track — complete Modules 3–6 first. This is Day 0/1/2 automation for 50+ cluster / edge fleets.
Read the Day 0 / 1 / 2 framework below and the ZTP pipeline diagram
Git commit (SiteConfig / PolicyGenTemplate)
→ Argo CD (hub) applies CRs to ACM
→ ACM + Assisted Installer provisions cluster (Day 1)
→ TALM applies Day 2 policies via ClusterGroupUpgrade
→ Managed cluster: running, compliant, GitOps-managed
Day 0 — ClusterCurator pre/post hooks run AAP jobs (network/storage/firmware checks).
Day 1 — SiteConfig (+ AgentClusterInstall, infraenv, ManagedCluster, NMStateConfig) committed to Git; ApplicationSet auto-creates an Application per cluster directory.
Day 2 — PolicyGenTemplate produces ACM policies; TALM orchestrates via ClusterGroupUpgrade (concurrency, canary, timeout). Upgrades follow the same Git-commit pattern.
Upgrade strategy depends on hardware: TALM in-place canary waves (no spare capacity), serial/parallel blue-green (spare pool or fast provisioning). Canary designation is the primary risk control for SNO fleets.
Secrets — ESO pulls pull-secrets, BMH creds, ingress certs, Htpasswd, ACM tokens from a vault at runtime. Never in Git.
EX316 — Red Hat Certified Specialist in OpenShift Virtualization
Red Hat
Module 4 depth
Included in DO317 bundle; covers VM management with KubeVirt
EX432 — Red Hat Certified Specialist in OpenShift Advanced Cluster Management
Red Hat
Module 6 depth
RHACM, policy, fleet governance; prep with DO432; replaces retired EX480. Red Hat lists DO380 (OpenShift Admin III) as a recommended prereq — optional but useful for deeper cluster-scaling context
DO180 / DO280 / DO317 / DO432
Red Hat (courses)
Modules 2 / 3 / 4 / 6
Formal Red Hat courses aligned to each module; DO317 bundles EX316 exam
EX280 after Module 3 — validates core OCP admin on our stack.
EX432 after Module 6 — validates fleet governance for platform roles. Red Hat recommends DO280 → DO380 (optional) → DO432 as the prep path; DO380 covers cluster scaling and is useful but not required to attempt the exam.
Role-specific: DO317 (Virt, includes EX316) for virtualization focus; CKS (after CKA) for security; ZTP/specialist for edge fleets.
Beyond this path
Contribute to internal runbooks; mentor the next cohort through Modules 0–1.
Deepen weakest verification checks — they mark real operational gaps.
Track upstream changes (course renumbers, ZTP tooling, ACM policy API) and update this path.
OpenShift Learning Curriculum
Purpose
This curriculum provides structured, role-based learning paths for engineers and support staff working with the OpenShift / RHOSV platform. Each path draws from a shared set of modules; the modules differ in depth and scope by role.
Philosophy: Learn the model before the commands. Most failures come from a wrong mental model, not a missing command. Every module front-loads the mental shift, then the mechanics. Treat the lab as non-negotiable — concepts should map to something you can inspect in a console or from a CLI.
Modules are maintained in learning-path/modules/. Each module page is self-contained: audience, outcomes, topics, curated resources, scenario-based verification, and <!-- ORG-SPECIFIC --> slots for internal details.
Role Paths
Platform / Infrastructure Engineers
Audience: Server engineers, platform engineers, and infrastructure engineers who build, operate, and extend the OpenShift platform.
Outcome: Full platform-engineering foundation — from Linux and Git fundamentals through cluster operations, virtualization, GitOps-driven delivery, fleet governance, observability, and (for specialists) zero-touch provisioning.
Suggested sequence: Complete modules in order. Module 3 is the largest — work through its sub-sections (app layer → cluster ops → networking → storage) before moving to Module 4.
Expected outcomes:
Perform essential Linux administration tasks and live on the CLI
Work confidently with Git, GitHub, and GitLab-based collaboration workflows
Build, run, and reason about container images and registries
Deploy, diagnose, and operate workloads and cluster-level concerns on Kubernetes/OpenShift
Run and migrate VMs on Kubernetes via OpenShift Virtualization (KubeVirt)
Build CI/CD pipelines and drive cluster state declaratively with Argo CD (GitOps)
Govern a multi-cluster fleet with RHACM policies and compliance
Monitor, alert, back up, and recover workloads and clusters
(Specialist) Automate full cluster lifecycle via the GitOps ZTP pipeline
Support Engineers
Audience: Support staff who diagnose, triage, and escalate issues on the OpenShift platform. Operational and diagnostic depth; does not include pipeline engineering, GitOps authoring, fleet governance, or ZTP.
Outcome: Solid operational foundation — understand the platform model, navigate the CLI and console confidently, diagnose workload and cluster issues, and apply observability tooling to identify and escalate problems.
Modules 5 (CI/CD & GitOps), 6 (Fleet Management), and 8 (ZTP) are not included in the support path. Support staff should be consumers of the GitOps/fleet model — understanding that Git is the source of truth and the console is read/observe — but are not expected to author pipelines or ACM policies.
Suggested sequence: Complete modules in order. Skip from Module 4 directly to Module 7 — observability and diagnostics are the primary day-to-day toolset for support work.
Expected outcomes:
Navigate the OpenShift console and CLI to inspect cluster and workload state
Understand the container and Kubernetes model well enough to reason about failures
Diagnose and triage VM issues on OpenShift Virtualization
Use oc adm must-gather, oc adm inspect, Prometheus, and LokiStack to gather diagnostic data
Understand backup/DR models (OADP/Velero, etcd) to support restore operations
Escalate effectively with structured, evidence-based incident summaries
Module Library
All modules live in learning-path/modules/. Each is self-contained and can be read independently, though the paths above define the recommended sequence.