Skip to main content

Documentation

Graduated documentation from brainstorming sessions, troubleshooting investigations, and project work.

Structure

docs/
├── gitops/            # GitOps consolidation, roadmaps, workflows, and drift management
├── platform/          # Platform architecture and operator models
├── homelab/           # Homelab planning (see homelab/README.md for topic index)
│   ├── overview/      # Map + visual graphic
│   ├── network/       # Tailscale, UniFi
│   ├── platform/      # Architecture, environments, hardware, repo layout
│   ├── observability/ # Public status + operator dashboard plans
│   ├── planning/      # Decisions, roadmap, scratchpad
│   └── iot/           # Smart home
├── troubleshooting/   # Remediation guides (from Troubleshoot sessions)
├── guides/            # Setup and prevention guides
│   ├── openshift/     # OpenShift / OCP how-tos (registry, GPU Operator, etc.)
│   └── dev-environment/ # VSCode setup, local tools, etc.
└── notes/             # Personal notes (events, etc.)

How Documentation Gets Here

Documentation graduates from wip/ sessions:

  1. Storm Sessions → Project documentation (e.g., docs/homelab/)
  2. Troubleshoot Sessions → Remediation docs (docs/troubleshooting/) and guides (docs/guides/)
  3. 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.

Current Projects

ProjectDescriptionStatus
HomelabInfrastructure architecture for greenfield homelab rebuildArchitecture complete
GitOps ConsolidationStrategic roadmap for returning clusters to trunk version control with taggingCompleted
OpenShift readiness (Ansible)Multi-play validation pattern, role skeleton, example roles; long-form authoring guide in-repoGraduated from wip/
Sep 3, 2026

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:

EnvironmentPurposeManagement
ProdProduction workloadsGitOps (Komodo)
TestPre-production validationGitOps (Komodo)
DevDevelopment, experimentation, OCP clusterMixed (Komodo for Docker, unmanaged for VMs/OCP)
UserPersonal workstationN/A
IoTSmart home, camerasIsolated
DMZPublic-facing services (qBittorrent)Isolated, restricted

Prod, Test, and Dev (Docker containers) form a promotion path managed by Komodo. Dev also contains unmanaged nodes (VMs, OCP cluster) for experimentation.


Overview

Orienting views of the whole lab.

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

Network

Connectivity, edge, and remote access.

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

Platform

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

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

Observability

Public status and operator dashboards.

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

Planning

Decisions, roadmap, ADRs, and working notes.

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

AI VM

Dedicated Debian VM with Quadro P5000 GPU passthrough, running LlamaFarm as the AI model management layer. Disposable — Ansible provisions, persistent storage holds models and data.

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

Smart home (IoT)

DocumentContents
IoT documentationHome Assistant, Zigbee, automations, device decisions

Tooling stack

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

Key principles

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

Subsections of Homelab Infrastructure

Leantime MCP Server — Local Setup

How the Leantime MCP server is set up and running on this machine (sams-macbook-air), for use as an opencode MCP tool.

Overview

Leantime is a self-hosted project management tool running in the homelab. It exposes an MCP endpoint at /mcp. This machine runs the leantime-mcp npm bridge as a stdio-to-HTTP proxy: it speaks the MCP protocol over stdio to the client (opencode), then forwards every tool call as HTTP JSON-RPC to the remote Leantime endpoint using a personal access token.

Architecture

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

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

Configuration

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

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

Bridge binary

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

Config resolution (src/config.ts)

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

Proxy behavior (src/proxy.ts)

  • Lazily initializes the Leantime session on first tool list (MCP initialize + notifications/initialized).
  • Every client tool call is proxied to the remote /mcp endpoint with the token.
  • Retries failures with backoff, caches tool responses for 5 minutes.

Leantime server

  • Self-hosted at http://leantime.samplayskeys.ts.net/mcp, reachable only over Tailscale.
  • MagicDNS resolves the name to 100.88.126.2; tailscale ping reports it is served from fedora-dev (100.125.91.5). No Tailscale Serve config exists on this Mac — serving happens on the host side.
  • The /mcp endpoint is auth-gated: unauthenticated requests return 401/406; the root path 302-redirects. Requests must include Authorization: Bearer <token>.
  • Requires the Leantime MCP Server plugin (paid, marketplace) for the endpoint and Advanced Auth for personal access tokens (token needed for user-specific queries like “get my tasks”).

History / context

Originally explored for OpenClaw in the WIP session wip/ai-leantime-integration/ (see context.md, mcp-path.md, refs/). The bridge ended up wired into opencode on a local machine instead.

Pitfalls / notes

  • The bridge must run on a machine that can reach both the AI client and the Leantime instance (Tailscale overlay covers this).
  • Leantime MCP tools are read+write by default — consider tool filters if destructive operations should be restricted.
  • API keys act as service accounts and cannot answer user-specific queries; prefer a personal access token.
Sep 3, 2026

Architecture decision records (homelab)

ADRs capture decisions that are stable enough to reference later, with context and trade-offs. They supplement the narrative Decisions page.

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

Subsections of Architecture decision records (homelab)

ADR: Tailnet-per-environment (Prod / Test / Dev) with declarative node sharing

Status

Rejected. The homelab will not split Prod/Test/Dev into separate tailnets. Environment separation stays as it is today: a single tailnet with tag-based grants.

The investigation is retained in full because the rejection is the useful part — it documents why an appealing structural-isolation story doesn’t survive contact with an already-working Services deployment.

Superseded in direction by ADR-0007: Workload isolation in a single tailnet — access pools, which keeps the workload/homelab separation inside the tailnet as ACL-defined access pools, with a separate tailnet retained only as a deferred off-ramp. Its core finding — that a tailnet boundary pays off where paths should be closed anyway (non-homelab workload), not between homelab environments — still stands.

Context

Today the homelab runs a single tailnet with tag-based environment separation. Services are already the primary exposure mechanism for containers across Prod/Test/Dev — advertised via Docktail labels and ScaleTail sidecars, with per-service MagicDNS names, per tailscale.md. That part of the design is settled and this ADR does not revisit it.

What is not settled is the environment boundary. Per tailscale-grants.md, isolation between Prod/Test/Dev is enforced entirely by grant rules:

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

That works, but the isolation is one policy-file edit deep. A bad merge, a mis-tagged node, or a fat-fingered tag:* destination collapses Prod/Test/Dev into one flat network. The blast radius of a policy mistake is the entire homelab.

Two Tailscale capabilities change the calculus:

  1. Additional tailnets via the API. POST /api/v2/organizations/-/tailnets provisions a fully isolated tailnet under the same account, no console clicks. The response carries a tailnet-scoped OAuth client — creation is org-scoped, but administration and deletion require that per-tailnet credential (worked example).
  2. Declarative node sharing (docs, alpha, waitlist-gated). Cross-tailnet access becomes a policy-file construct — externalTailnets plus grants referencing group://<alias>/<name> or tag://<alias>/<name>. It is explicitly designed for machine-to-machine access between trusted tailnets, which is exactly the homelab’s shape.

The question this ADR frames: is a tailnet boundary the right isolation primitive for the homelab’s environments, and is the cross-tailnet seam cheap enough to live with?

What a tailnet split does to the existing Services layer

Because Services are already the exposure model, the split’s real cost lands here rather than on greenfield design:

  • Service names become tailnet-qualified. Every service currently resolves under one tailnet’s MagicDNS suffix. Three tailnets means three suffixes, and the naming convention documented in tailscale.md needs an environment-aware revision. OPEN: does plex.<prod-tailnet>.ts.net replace the current flat convention, or does an alias layer hide the split?
  • httpsEnabled is per-tailnet and off by default. Any new tailnet needs the setting turned on before TLS-terminating services work at all — a provisioning step that doesn’t exist today because the current tailnet was configured once, long ago.
  • Docktail/ScaleTail config becomes tailnet-scoped. Sidecars and label-driven advertisement authenticate to a specific tailnet. Auth keys, tags, and tagOwners all become per-tailnet concerns.
  • Cross-environment service consumption is the seam. Anything in one environment consuming a service in another (observability, backups, promotion tooling) stops being an in-tailnet lookup and becomes an externalTailnets grant.

OPEN and worth answering early: whether a Service can be published across a tailnet boundary. If yes, the boundary contract is service-level and clean. If not, cross-environment access is node-and-port level, which is coarser than the exposure model already in use — a genuine regression against current practice, and arguably the strongest argument for staying on one tailnet.

Correction to an earlier framing

An earlier draft of this ADR analyzed the seam using link-based sharing (sharing docs) and concluded the model was largely unworkable — tags stripped, shares user-scoped so tagged machines can’t accept them, quarantine by default. Those constraints are real but belong to link-based sharing, not declarative node sharing. Declarative sharing operates at the policy level and supports tag references and machine-to-machine access directly. The two mechanisms coexist; picking the wrong one produces the wrong conclusion, which is why they’re separated explicitly below.

Drivers

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

The two sharing mechanisms

Getting this distinction right is most of the decision.

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

Consequence: declarative sharing is the mechanism this design should be built on. Link-based sharing remains the right tool only for the partner/guest case — handing one machine to one outside human.

Shape of the config

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

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

Sharing tailnet accepts the relationship and writes the grant:

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

A grant referencing something absent from the other side’s allowExternalReferencesTo is silently ignored — no error, just no connectivity. That failure mode needs a verification step in any runbook.

Options

Retained as the investigation record. Outcome noted on each.

Option A — Status quo: one tailnet, tag-based separation — CHOSEN

  • Pros: zero migration; flat MagicDNS; one policy file; single console; no alpha dependency; Services and access model untouched.
  • Cons: isolation is advisory, not structural; one bad grant flattens all environments; no per-environment audit boundary.

Option B — Three tailnets (Prod / Test / Dev), declarative sharing between them — rejected

  • Pros: isolation is structural — a Dev policy mistake cannot reach Prod; per-tailnet audit logs, auth keys, and tagOwners; Dev becomes genuinely disposable; whole lifecycle is API-drivable (create, httpsEnabled, policy push, scoped auth keys); tags and m2m survive the boundary; double opt-in means neither side can unilaterally widen access.
  • Cons: depends on an alpha, waitlist-gated feature; three policy files and three scoped OAuth credentials to manage; FQDNs become tailnet-qualified; silent-ignore failure mode on mismatched references; per-tailnet settings (notably HTTPS certs, off by default) must be provisioned explicitly; rewrites a working Services exposure surface.

Option C — Two tailnets: Prod isolated, Test+Dev together — rejected

  • Pros: captures the blast-radius win that matters at two-thirds the overhead; Test↔Dev promotion stays in-tailnet.
  • Cons: Test loses a hard boundary from Dev, so “does Test mirror Prod?” gets fuzzier; still carries the full alpha dependency for one seam — paying the same risk for less of the benefit.

Option D — One tailnet, hardened — CARRIED FORWARD (with A)

Policy-file CI with ACL tests in GitHub Actions, mandatory review on the policy file, ban tag:* destinations, and tighten per-service grants on the Services already in place.

  • Pros: near-zero migration; no alpha dependency; converts soft isolation into tested isolation.
  • Cons: still one flat trust domain; a mistake that passes tests still lands everywhere; audit boundary unchanged.

Decision

Rejected — keep the current single-tailnet structure with tag-based environment separation.

The structural-isolation argument is real, and declarative node sharing does make tailnet-per-environment technically achievable in a way link-based sharing never did. It is still the wrong trade for this homelab. Four reasons, in order of weight:

1. It breaks access that currently works, to solve a problem that hasn’t bitten

Services are already the exposure mechanism for most containers, and access works today — admin tiers, partner access, per-service MagicDNS names. A tailnet split rewrites that working surface: tailnet-qualified DNS suffixes, tailnet-scoped Docktail/ScaleTail sidecar auth, per-tailnet httpsEnabled, and per-tailnet auth keys and tagOwners. The failure it prevents — a fat-fingered grant flattening environments — has not actually occurred. Trading working access for hypothetical blast-radius reduction is the wrong direction.

2. Cross-boundary sharing is coarser than what’s already in place

The unresolved question from the investigation is decisive: if a Service cannot be published across a tailnet boundary, then cross-environment access degrades to node-and-port grants. That is coarser than the per-service exposure model already running. And if the shareable app has to physically live in the tailnet it’s shared from, containers move off the Prod Docker host — which drags in Komodo stack placement and the promotion path from ADR-0001. Either answer is worse than the status quo for a boundary nobody asked for.

3. The complexity is not proportionate to a single-operator homelab

Three tailnets means three policy files, three consoles, three sets of tailnet-scoped OAuth credentials, three auth-key rotations, and per-tailnet provisioning steps that don’t exist today. Add the silent-ignore failure mode — a grant referencing something absent from the other side’s allowExternalReferencesTo produces no error, just no connectivity — and routine debugging gets materially harder. Isolation that is expensive to operate gets worked around, and a worked-around boundary is worse than an honest tag.

4. The alpha dependency lands on the wrong path

Declarative node sharing is alpha and waitlist-gated. Under this ADR it would sit on the path between the Komodo controller and Prod periphery agents — meaning homelab manageability depends on an alpha feature. That inverts the risk this ADR set out to reduce.

What is kept

  • Environment separation stays tag-based (tag:prod / tag:test / tag:dev) in one tailnet. No grants rewrite, no tag taxonomy change, no Services churn.
  • Partner access stays link-based sharing, per the adversarial model in tailscale.md. Outside humans are not modeled as external tailnets.
  • Policy-file CI with ACL tests is still worth doing (the former Option D) and is the only recommendation carried forward from this ADR. It converts advisory isolation into tested isolation at near-zero cost, and it is independently useful.

Future option: collapse three environments to two

Noted, not decided: the Prod/Test/Dev split could later collapse to Test and Prod only. Under ADR-0007, Dev’s remaining purpose is being reconsidered — if non-homelab work moves to a workload environment, a third homelab environment may not be earning its keep. That is a separate decision and deliberately out of scope here.

Where the tailnet boundary does belong

The investigation’s real finding is that the boundary axis was wrong. Splitting environments imposes a boundary across paths that need to stay open (admin, Komodo, observability, backups). Splitting workload from homelab puts the boundary where the paths should be closed anyway. See ADR-0007.

Cross-environment flows that made the split expensive

Each of these works today only because everything is one tailnet. Each would have needed an explicit externalTailnets/grant design — collectively, this list is the concrete form of reason #1 above:

  • Komodo controller (NUC, tag:admin) → Prod/Test/Dev periphery agents — the canonical m2m case; technically workable via tag:// references, but it puts homelab manageability behind an alpha feature
  • Promotion path DevDocker → Test → Prod (per ADR-0001, promotion is a file move; the network path is currently implicit and would have to become explicit)
  • Observability scrape/push paths across environments — direction matters against allowIncomingConnections
  • Backup flows to tag:storage NAS (UnRaid = Prod, Synology = Test)
  • Admin access from the operator’s devices to all three — one identity in three tailnets, or a device per tailnet, neither cheap
  • Exit node — one per tailnet, or one shared
  • Partner/guest access — stays on link-based sharing regardless
  • ProxMox host, which hosts the Dev VM but is itself infrastructure

Trade-offs that would have been accepted (and were judged too costly)

  • Alpha-feature dependency on the critical path for cross-environment automation.
  • Longer FQDNs and a documentation sweep to match.
  • Per-tailnet provisioning steps that don’t exist today: httpsEnabled PATCH, tagOwners, auth keys, policy push.
  • Tailnet-scoped OAuth credentials must be captured at creation — the org token cannot administer or delete a tailnet it created.
  • Cross-environment automation gets deliberately harder. Intended for environments; unhelpful for admin paths.

Consequences / follow-on work

Because the split is rejected, most of the original follow-on work is moot. What remains:

  • Add policy-file CI with ACL tests. The one carried-forward recommendation. Ban tag:* destinations, require review on the policy file, run tests in GitHub Actions.
  • No changes to tailscale-grants.md or tailscale.md from this ADR — the tag taxonomy and Services naming convention stand as documented.
  • Record the rejection so the option isn’t re-litigated from scratch; the mechanism comparison above is the reusable part.
  • Continue in ADR-0007 for the workload boundary, where the provisioning artifact and waitlist question actually apply.

Open questions — resolved or relocated

The rejection closes most of these. Recorded so the option isn’t re-litigated from scratch:

  1. Plan/cost implications of additional tailnets — moot here; relocated to ADR-0007.
  2. Waitlist timing for declarative node sharing — no longer gating this decision; still worth joining for ADR-0007.
  3. Quarantine semantics vs. allowIncomingConnections for declaratively shared nodes — unresolved; relocated to ADR-0007, where it actually matters.
  4. Operator identity/devices across three tailnets — moot; admin access stays in one tailnet, which is the point of the rejection.
  5. Komodo topology across a boundary — moot; controller and periphery agents stay in one tailnet.
  6. Can a Service be published across a tailnet boundary? — unresolved and now load-bearing for ADR-0007. Answering it decides whether shareable apps can stay on their current host or must physically move.
  7. Does Test lose meaning as a Prod rehearsal under Option C? — moot; Option C rejected. Related but distinct question of collapsing to Test+Prod is noted as a future option above.
  8. Migration order — moot; no migration.
  9. Synced groups (SCIM/Google) can’t be referenced across tailnets — relocated to ADR-0007; only relevant where a boundary exists.

References

Primary sources

  • Declarative node sharing — the mechanism this ADR is built on. Alpha, waitlist-gated. externalTailnets, double opt-in, tag:///group:// external reference syntax, allowExternalReferencesTo, allowIncomingConnections. Also the source for the SCIM/Google synced-group limitation.
  • summer-with-tailscale — worked, runnable examples for the API-driven path. Modules used here:
    • 01-tailnet-sandboxes — OAuth token exchange, POST /organizations/-/tailnets, and the tailnet-scoped OAuth credential that the org token cannot substitute for on delete
    • 02-tailnet-membership — tsnet embedded membership; relevant if services join tailnets directly rather than via host daemons
    • 03-declarative-sharing — externalTailnets config walkthrough and scoping external access by group/tag instead of blanket dst: ["*"]
    • 04-api-is-the-way — policy file as data via GET/POST /tailnet/-/acl, the httpsEnabled PATCH, scoped/tagged/short-lived auth keys, plus end-to-end.sh

Supporting

Aug 26, 2026

ADR: Workload isolation in a single tailnet — access pools

Status

Proposed. Direction settled: workload/dev traffic stays in the current tailnet, separated by ACLs organized as access pools. The separate-tailnet option is deferred to a named trigger, not discarded.

Extends ADR-0006, which rejected splitting Prod/Test/Dev into separate tailnets. That investigation’s useful finding was that the boundary axis was wrong, not the boundary idea. This ADR takes the next step of that finding: separation of workload from homelab is real, but the mechanism is pools within one boundary, with structural separation preserved as an off-ramp.

Context

Two distinct things are currently conflated inside one tailnet:

  1. The homelab — Prod and Test infrastructure, private applications, the Komodo GitOps pipeline, storage, admin interfaces. Everything here is either operator-only or shared with the partner as a trusted user.
  2. Dev — nominally a homelab environment, but in practice where non-homelab work happens. It has no promotion relationship worth preserving with Test/Prod for that class of work.

The earlier draft of this ADR weighed two mechanisms: a separate tailnet for workload (structural isolation, independent policy, clean audit boundary) against staying in the shared tailnet with tag separation (zero migration, isolation only advisory). The pivotal consideration — that a boundary’s alpha dependency sits on a path that is allowed to degrade, unlike ADR-0006’s admin/Komodo paths — is retained.

The judgment has since shifted on the trigger. The conditions that would justify a boundary — outside resources managing elements, sustained workload or external-party growth — are not real today. Building the structural split now pays complexity for a hypothetical, and it puts reachability that does not yet need to exist on an alpha, waitlist-gated feature (declarative node sharing). The boundary is kept as the off-ramp, priced and designed, but not built.

Decision

Single tailnet, workload separated by access pools. The structural split is deferred, not discarded.

1. One tailnet for now

Workload/Dev elements stay in the current tailnet, protected by ACLs rather than a boundary. Nothing new to provision, no per-tailnet settings (httpsEnabled, tagOwners, auth keys), and the Services/Docktail/ScaleTail exposure layer works exactly as today. External sharing, when it happens, stays link-based — one machine handed to one invited user — which covers ad-hoc guest access without committing to a separate tailnet.

2. Scope framing shifts from “homelab first” to “environment with pools”

The tailnet is no longer scoped as the homelab with everything else grafted on. It is the environment housing purpose-defined pools — provided services as one pool, development/workloads as another. The pool set is open: additional pools are named when a need clears the bar; none are pre-declared here (see Open Questions). This reframing drives everything that follows: nodes are organized by purpose AND access, and the ACL question becomes “which pools can which users and services reach.”

3. ACLs are pool-based, not environment-tag-based

The access model follows the pools: grants are written in terms of who may reach which pool, in which direction. Two concept principles, without enumerating the pools themselves:

  • One pool per node. A node belongs to exactly one pool. Cross-pool reach is an explicit, named grant — never a second membership. This keeps the ACL table non-ambiguous: a node’s pool is its identity, and exceptions are visible rules rather than overlapping tags.
  • Directional by default. Workload → provided services is the natural direction and the common grant (dev needs to reach the things the environment provides). Services → workload is a named exception, not part of the default posture. Reverse-direction access is the thing that erodes the separation, so it must always be a visible rule.

The pool taxonomy itself — names, membership, concrete grants — is deliberately unspecified in this ADR. It is a follow-on design task (see Consequences).

4. Separate tailnet = deferred off-ramp

If the environment grows and outside resources need to manage elements, the workload moves to a new set of devices in a separate tailnet — the homelab tailnet untouched. The earlier draft’s separate-tailnet analysis is retained as the off-ramp design: structural isolation, independent policy file, clean audit boundary, external-group sharing without exposing private applications. Its alpha dependency (declarative node sharing) is acceptable once the boundary is real, because it then sits on a workload path that is allowed to degrade — the same risk posture, just correctly timed.

The pool taxonomy chosen now is the seam that cut will follow: pools map cleanly to future tailnet membership, and a split becomes a placement/policy exercise rather than a redesign. Policy-file CI should be in place first — a split that is mostly policy movement is only cheap if the policy is already tested.

Why this succeeds where ADR-0006 failed

ConcernADR-0006 (environment split)This ADR (pools)
Admin/Komodo pathsCut by the boundary — homelab manageability behind an alpha featureUntouched; all stay in the homelab tailnet
Existing Services layerRewritten across three tailnetsUntouched
Partner/guest accessComplicated by tailnet-qualified DNSLink-based sharing, unchanged from today
Alpha dependencyOn the critical management pathOn the deferred off-ramp only
Migration costThree tailnets, full grants rewriteZero now; the split is a future, triggered exercise
Blast radiusHypothetical (bad grant)Contained within pool grants — and CI-testable

Future option: separate tailnet (deferred, priced)

Noted, not built. When the trigger fires — outside resources managing elements, or sustained external-party growth — execute the off-ramp: new device set in a separate tailnet, provisioned via the API chain (OAuth exchange → create tailnet → httpsEnabled → tagOwners/policy → scoped auth keys), homelab tailnet untouched, cross-boundary access via declarative node sharing. Joining the waitlist for declarative sharing is only needed when the trigger becomes real; doing it now starts a clock for a feature nothing active depends on.

Open questions

  1. Pool taxonomy — deliberately unspecified here. The concrete pools, names, membership, and grants are the follow-on design task. Implicit constraint from this ADR: no “miscellaneous” pool; a catch-all pool is how pools rot into a flat network.
  2. Does the partner need any workload access? Default assumption: no — workload pools are operator-only. Link-based sharing remains available per-need; the answer affects whether the future split needs external-group modeling at all.
  3. Exit node — does workload need its own, or use none?
  4. Split trigger threshold — what concretely constitutes “outside resources managing elements”? Naming the threshold now makes the off-ramp decision unemotional later.
  5. Quarantine semantics / synced groups — relocated to the off-ramp; only apply once a boundary exists.

Consequences / follow-on work

  • Design the pool taxonomy and draft the HuJSON policy — tracked TODO, not done here. A policy skeleton with the pool structure and directional grants, reviewed against the principles above.
  • Policy-file CI with ACL tests — now more load-bearing than under either prior framing: pools keep isolation advisory, and CI is what converts it into tested isolation. Ban tag:* destinations, require review on the policy file, map pool grants to tests, run in GitHub Actions.
  • Update tailscale-grants.md once the pool taxonomy is concrete; convert the environment-tag framing to pool framing.
  • Update tailscale.md to document workload pools, link-based sharing for guests, and the deferred-split framing.
  • Do not join the declarative-sharing waitlist yet — retracted from the earlier draft; only needed once the off-ramp trigger is real.
  • Rename Dev → Workload in homelab docs, and be explicit that it is outside the promotion chain, so it isn’t mistaken for a pipeline stage.
  • Revisit ADR-0001: if Dev leaves the promotion chain, the per-environment ResourceSync branch layout has one fewer environment to steer.

References

Aug 26, 2026

ADR: Orchestration Model — Docker-native vs Kubernetes/ArgoCD for the Homelab

Context

The homelab currently standardizes on Docker (rootful, on Fedora Server baremetal hosts) managed through Komodo for GitOps. The prior narrative decision (“Komodo over Kubernetes (K3s + ArgoCD)” in planning/decisions.md) chose Docker-native Komodo over a K3s + ArgoCD stack on the grounds of complexity, risk tolerance, and concept alignment with existing familiarity with compose files.

This ADR reopens that axis. Two things have shifted the picture:

  1. Declarative lifecycle is now a hard requirement, not a nice-to-have. The goal is to easily and declaratively spin up / tear down / manage resources across environments. That is exactly what native GitOps (ArgoCD reconciling manifests) does out of the box — and what Komodo does with more manual steering (ResourceSync branch gymnastics, per-environment TOML edits on merge, see ADR 0001).
  2. ArgoCD is the more common / more documented route than Komodo. If the operational surface is “declare desired state, let a controller reconcile,” ArgoCD is the industry-default implementation. Komodo gets us most of the way but is a smaller ecosystem with rougher edges (Podman compat, stack-status quirks).

So the earlier “Kubernetes is overkill for a homelab” conclusion is being stress-tested against a simpler question: is the thing we’re optimizing for simplicity of the runtime or simplicity of declarative management? Those pull in different directions.

The tension

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

The salient counterpoint to the original decision: Docker is less complex to run, but it can mean more steps to manage compared to native GitOps. The “overkill” argument was about runtime footprint; it does not directly address declarative management ergonomics, which is now the priority.

Options considered

Option A: All Docker-native (Komodo)

Standardize everything on Docker + Komodo ResourceSync.

  • Pros: lowest runtime complexity; builds on existing Compose investment; Dev OCP cluster still provides separate K8s skills.
  • Cons: declarative management is real but requires the merge-hygiene rituals from ADR 0001; smaller ecosystem; no first-class “one command spins up the whole graph.”

Option B: All Kubernetes (K3s/K8s + ArgoCD)

Replace Komodo with a K3s or K8s control plane and ArgoCD as the GitOps controller for every workload.

  • Pros: single, ubiquitous declarative model; ArgoCD is the common, well-documented path; spin up/down is a Git commit; one pane of glass for everything.
  • Cons: real operational complexity (control plane, CRDs, more moving parts); the original “overkill for homelab” risk is still valid; more failure modes to own 24/7.

Option C: Split model (leading direction, not yet decided)

Use Docker for “prod apps” and Kubernetes/ArgoCD for “workload apps.”

  • Keeps the low-friction Docker path for stable, user-facing services (Plex, etc.) where runtime simplicity matters most.
  • Puts the heavier / more dynamic / more experiment-driven workloads on K8s/ArgoCD where declarative reconciliation pays off and aligns with work skills.
  • The only clear advantage of “all Kubernetes” (Option B) over this split is the single pane of glass — ArgoCD as the one view of everything. That is a real UX win, but it is currently the only decisive argument for unifying onto K8s, and it may not outweigh the standing complexity cost.

Decision

No conclusion yet, this ADR is intentionally left open.

The leading direction is Option C (split model), driven by the desire for easy declarative management without forcing the entire homelab onto a K8s control plane it may not need. The single strongest argument for abandoning the split and going all-in on Kubernetes (Option B) is the unified ArgoCD dashboard; that has not yet been judged worth the operational cost.

What would move this from Draft to a decision:

  • A concrete definition of which apps count as “prod apps” (Docker) vs “workload apps” (K8s); the boundary is currently intuitive, not enumerated.
  • A validated K3s + ArgoCD bring-up path on the homelab hardware (the original decision flagged K3s-in-prod as risky; that risk needs a fresh look).
  • A judgment on whether “single pane of glass” is worth the control-plane burden, or whether two dashboards (Komodo + ArgoCD) is acceptable.

Consequences (potential)

If Option C is adopted:

  • Two management planes to operate (Komodo for Docker, ArgoCD for K8s) — more tooling surface, but each stays within its comfort zone.
  • Prod apps keep the low-ceremony Docker/Compose lifecycle.
  • Workload apps get first-class declarative reconciliation and stronger skills transfer to the work OCP stack.
  • Loses the “one ArgoCD view of everything” that Option B would provide.

If Option B is adopted instead:

  • Single declarative model and dashboard for the whole homelab.
  • Pays the standing K3s operational cost the original decision sought to avoid.
  • Supersedes the “Komodo over Kubernetes” narrative in decisions.md — that section would need revisiting.

Open Questions

  • Where exactly is the “prod app” vs “workload app” line drawn?
  • Does “prod app” vs “workload app” need to be an treated as alternative approach to my originally declared “Prod/Test/Dev” environments from ADR 0001?
    • Idea: If this is the case, it would call into question the order of evolution in regards to hardware, with “Prod” still running on a dedicated device, having “Test” running within the ProxMox cluster, and having “Workload/Dev” on a larger version of the OCP cluster.
  • Is the K3s-in-prod risk from the original decision still valid, or has the operational picture changed?
  • If my personal work is focused largely on kubernetes, will it be better to have everything involved with kubernetes, or will having “prod apps” in docker (option C) encourage me to not mess with prod apps and focus my time on just the kubernetes side of the field?
  • Is there a “single pane of glass” option that can manage both docker and kubernetes, or at least have visibility on both?
  • Does losing a single pane of glass (under the split) actually hurt day-to-day, or is it tolerable?
  • Does this reopen the runtime question (ADR 0003 / 0002) for the K8s-hosted workloads, or do they stay rootful Docker-in-VM / containerd as K8s dictates?

References

Aug 25, 2026

ADR: Skip local Docker registry, focus on git pipeline

Context

The homelab needs a strategy for managing container images between the local environment and production. Initial considerations included running a local container registry (Artifactory/Lightwell analog) or relying on Docker Hub with version pinning. The homelab already uses Komodo GitOps, GitHub Actions for rendering, and Ansible validation playbooks.

Goals:

  1. Avoid infrastructure overhead — A local registry adds a running service, storage management, and maintenance burden for a feature (offline access) that isn’t actually required.
  2. Leverage existing pipeline investment — GitHub Actions, Ansible validation, and Komodo ResourceSync are already in place and can be extended to cover image governance.
  3. Security evaluation through PR process — Image scanning and review can happen in Git before anything gets deployed, rather than after images sit in a registry.
  4. Version pinning is already the pattern — Komodo configs and Git branches already provide effective version control; a registry would be redundant.

Decision

Skip running a local Docker registry as a stop-gap. Instead:

  1. Pin images explicitly in Komodo TOML configs (e.g., nginx:1.25-alpine instead of latest) — this is the primary guardrail.
  2. Extend the existing Git pipeline to include:
    • Image scan on PR (Trivy or Anchore)
    • Push approved images to Docker Hub / existing registry with pinned tags
    • Komodo picks up the new commit SHA / tag on sync
  3. Use Docker Hub rate-limit-friendly practices — pull once, cache locally via Docker’s built-in store, and pin versions.
  4. Revisit local registry later only if offline access or rate limits become actual problems.

Considered alternatives

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

Consequences

Positive

  • No extra running service or storage to manage
  • Security gates move left — images are scanned and reviewed in PR before any deployment
  • Existing Git/GitHub Actions investment leveraged; no new infra needed
  • Version pinning already the pattern via Komodo configs; registry would be redundant
  • Simpler onboarding for new services — just add to pipeline, no registry config needed

Negative / risks

  • Image scanning depends on CI runner availability and speed
  • If Docker Hub rate limits become problematic, no local cache (but this hasn’t been an issue in practice)
  • Security evaluation relies on PR review quality — same as any code change

Mitigations

  • Add Trivy scan to existing GitHub Action as a checklist step — fail PR if critical vulnerabilities found
  • Cache scanned results in GitHub Actions artifact so subsequent scans are fast
  • If rate limits bite, add a registry later as a targeted fix (not infrastructure upfront)
  • Document the “pinning is the guardrail” principle so teams don’t revert to latest

Status

Accepted — proceed with extending the Git pipeline for image governance instead of running a local registry.

References

Aug 19, 2026

ADR: Baremetal Docker Host OS Selection

Context

The homelab needs a host OS for baremetal Docker nodes (LenovoMini 1 for Prod, LenovoMini 2 for Test, and the DevDocker VM on ProxMox). Requirements:

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

Options Considered

Host OS

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

Container Runtime

FactorDockerPodman
Komodo Periphery supportFirst-class (native socket or API)Rough — community workarounds only (DOCKER_HOST to socket emulation)
RootlessAvailable (opt-in via dockerd-rootless-setuptool.sh)Built-in, rootless by default
ComposeDocker Compose v2podman-compose or podman play kube

Research Findings

Podman + Komodo Compatibility

After investigation, Podman support in Komodo relies on workarounds:

  • Requires DOCKER_HOST env pointing at Podman’s Docker API socket (/run/user/$UID/podman/podman.sock)
  • Stacks may incorrectly show as “DOWN” after successful deploy due to compose network label differences (GitHub issue #504)
  • No first-class testing or documentation from Komodo maintainers
  • Conclusion: Support is too rough for a low-maintenance setup

Rootful vs Rootless Decision

Rootless Docker was initially preferred for security, but three factors drove the switch to rootful:

  • No practical benefit for a homelab — all workloads are user-deployed and trusted. The socket is already protected by host-level access controls (SSH, firewall, physical security).
  • Avoids community-driven maintenance burden — rootless Docker with Komodo Periphery relies on community patterns not official docs. Rootful is the documented, tested path.
  • Simplifies Ansible automation — rootful Docker is a single dnf install + daemon enable. No dockerd-rootless-setuptool.sh, no loginctl enable-linger, no environment variable wiring.

Host OS Alignment

  • Fedora Server ships recent kernel + DNF, packages are fresh enough for both the Docker CE repo and Ansible management
  • Rocky/Alma would work but are conservative; no advantage for this use case
  • Fedora CoreOS is immutable — no package install at runtime, conflicts with Ansible-based provisioning

Decision (Provisional)

  1. Fedora Server for the host OS — DNF-native, recent kernel, familiar tooling, easy Ansible provisioning.
  2. Docker (not Podman) for the container runtime — Komodo’s first-class support removes any reason to fight Podman compatibility.
  3. Rootful Docker — simplicity wins for a homelab with trusted workloads. No port restrictions, no privileged container limitations, no extra setup steps.
  4. Containerized Periphery — deployed as a Docker container managed by Ansible, with /var/run/docker.sock mounted for API access. Updates via image pull, not manual binary replacement.

Implementation: Containerized Periphery via Ansible

Periphery runs as a Docker container, managed by the same Ansible playbook that provisions the host. The Komodo maintainer’s filesystem semantics concern (discussion #220) applies to rootless Docker where user namespaces cause path remapping — with rootful Docker, container and host paths are the same, so the concern doesn’t apply.

Ansible Role Structure

The host provisioning playbook will include a role komodo-periphery that handles Periphery as a container:

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

Tasks (main.yml)

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

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

Key choices:

  • network_mode: host — Periphery needs to reach containers by their host-level ports for monitoring. Host networking avoids port mapping conflicts and simplifies the network topology.
  • /var/run/docker.sock mount — gives Periphery the standard Docker API access. With rootful Docker this is the native socket, no indirection needed.
  • /:/mnt/host:ro — bind mounts the host root so Periphery can read filesystem stats for disk monitoring.
  • restart_policy: unless-stopped — survives reboots and daemon restarts.

Config Template (periphery.config.toml.j2)

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

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

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

Ansible Playbook Structure

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

Gotchas

  1. SELinux may block container socket access. On Fedora Server with SELinux enforcing, the Periphery container needs the :z flag on volume mounts or a dedicated SELinux policy. If Periphery fails to connect, check ausearch -m avc and add :z to the socket mount: "/var/run/docker.sock:/var/run/docker.sock:z".

  2. Host networking limits to one Periphery per host. With network_mode: host, port 8120 is consumed directly on the host. Only one Periphery instance can run per machine. This is the expected topology — one agent per host.

  3. Periphery container image tag strategy. Use a specific version tag (e.g., :0.2.5) not :latest for reproducible deployments. Update by bumping the version in Ansible vars and re-running the playbook.

  4. Onboarding key rotates. The onboarding_key in [[connections]] is a one-time use key from the Komodo Core UI. After first connection, Periphery persists its session — the key is no longer needed. If re-deploying from scratch, generate a new key.

  5. Config changes require container restart. If periphery.config.toml changes, Ansible must trigger a container restart. The docker_container module handles this automatically when config changes are detected via template diff.

  6. Ansible vault for secrets. The onboarding key is sensitive and should be stored in Ansible vault, not in plaintext group vars.

Consequences

Positive:

  • Familiar RPM/DNF management
  • Komodo Periphery has native, first-class support for rootful Docker
  • All workloads work without restriction — privileged containers, ports <1024, cgroup limits
  • Containerized Periphery auto-updates via docker pull; Ansible handles version pinning
  • Ansible-driven provisioning means new hosts are reproducible
  • No special setup beyond standard Docker CE install

Negative / risks:

  • Docker socket is root-equivalent — compromised container has host access. Mitigated by homelab trust model (all workloads are user-deployed).
  • Containerized Periphery adds a Docker-in-Docker layer that the Komodo maintainer flags for potential filesystem confusion, but with rootful Docker (no user namespace) this is not a practical concern.
  • Fedora ~13mo support window means OS upgrades every ~6-12mo (acceptable for homelab).
  • Periphery config changes need container restart — automated via Ansible.

Open Questions

  • Which Periphery image tag to pin initially? Start with :latest during bring-up, pin to a specific version once stable.
  • How to handle Periphery version upgrades? Ansible var bump + playbook re-run. Need a process for checking upstream releases.

References

Jun 9, 2026

ADR: Container Runtime Tradeoffs — Docker (Rootful), Docker (Rootless), and Podman

Context

Selecting a container runtime for baremetal Docker hosts involves balancing security, operational convenience, and tooling compatibility. This ADR breaks down three approaches and the fundamental tension between central-socket management and daemonless isolation.

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

Runtime Options

Docker (Rootful)

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

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

Docker (Rootless)

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

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

Podman (Daemonless)

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

AspectDetail
SocketNone by default — containers are child processes of the user’s shell
DaemonNo daemon — podman system service can emulate a Docker API socket on demand
Root modelRootless by default — no user namespace needed, containers run as the calling user
Komodo supportWorkarounds only — DOCKER_HOST to emulated socket, known bugs (stack status, network labels)
Setup complexityMinimal for CLI — dnf install podman. Complex for management tooling (need podman system service as a systemd unit)
Composepodman-compose or podman play kube — not drop-in compatible

Analysis

Security: The Central Socket Risk

A Docker socket is equivalent to root access on the host. Any process that can write to the socket can:

  • Start containers with host filesystem mounts (read/write any file)
  • Create privileged containers that break out of isolation
  • Modify iptables, network namespaces, cgroups
  • Access secrets mounted into any container

Rootful Docker places this power on a single, well-known path (/var/run/docker.sock). The docker group is effectively root. The blast radius of a compromised process with socket access is the entire host.

Rootless Docker mitigates this through user namespaces. The daemon and all containers run within the user’s namespace — root inside a container is the user outside. A compromised socket can’t escape the user namespace, so the blast radius is that user’s resources only. However, it’s still a central daemon — if the rootless dockerd process is exploited, all that user’s containers are compromised.

Podman eliminates the central socket entirely. No process management attachment, no daemon to target. Each container is a child of its launching process. The blast radius is per container — no socket to poison. To use management tooling, you must explicitly start a podman system service socket, which reintroduces a central endpoint — but it’s opt-in, not the default.

Management: Daemonless vs. Central Socket

This is the inverse of the security tradeoff:

RuntimeManagement modelTooling friction
Docker (rootful)One socket, one daemon, everything worksZero — every tool speaks Docker API natively
Docker (rootless)One socket per user, one daemon per userLow — set DOCKER_HOST, same API
PodmanNo socket, no daemon, containers are child processesHigher — no standard API without emulation layer

Without a central socket, management tooling faces a discovery problem:

  • How does Komodo’s Periphery find the container runtime? It can’t — it needs an explicit path to podman system service socket, which must be running as a systemd user service.
  • How does docker ps / podman ps enumerate running containers across a session? Podman uses a per-user boltdb database, but there’s no persistent daemon registering container state.
  • How do compose stacks get tracked? Podman’s compose support (podman-compose) uses a different project model than Docker Compose — Komodo’s stack status detection breaks (issue #504).

With a central socket, management is trivial: connect to the well-known path, issue standard API calls, get predictable results. The cost is the security risk described above.

Operational Considerations

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

This ADR does not make a standalone decision — it informs ADR 0002. The current direction:

Docker (rootful) for maximum simplicity and tooling compatibility. The socket-security risk is accepted as an acceptable tradeoff for a homelab with trusted workloads.

Podman is the more secure architecture by design, and rootless Docker is a middle ground. Rootful Docker optimizes for operational simplicity — no setup ceremony, no port restrictions, no privileged container limitations. For a homelab where all workloads are user-deployed and trusted, the central-socket risk is manageable.

If the homelab expands to include untrusted or multi-tenant workloads, this should be revisited — rootless Docker or Podman would contain blast radius in a way rootful Docker cannot.

Consequences

If Docker (rootful) is adopted (current direction):

  • All management tooling works natively with zero configuration — standard Docker API on the well-known socket
  • Setup is minimal — dnf install docker, enable daemon, run containers
  • No workload restrictions — privileged containers, ports <1024, cgroup limits all work
  • Socket is root-equivalent — any process with socket access owns the host. Acceptable for homelab trusted workloads.

If Docker (rootless) or Podman is reconsidered later:

  • Stronger isolation — compromise is contained to a user namespace or individual container
  • Some workloads (privileged containers, Swarm mode) are restricted
  • Management complexity increases — socket discovery, env vars, emulation layers required for Komodo
  • Ports <1024 need workarounds

References

Jun 9, 2026

ADR: Per-environment Git branches via Komodo ResourceSync

Context

The homelab uses Komodo with ResourceSync to declare servers, stacks, deployments, and related resources from Git (an “app-of-apps” style flow: Git remains the audit trail; Komodo reconciles).

Goals:

  1. main shows the whole picture — dev/, test/, and prod/ (or equivalent komodo/{dev,test,prod}/) layouts exist together on main so reviews and long-lived docs match reality after merges.
  2. Per-environment steering — each environment’s sync can follow main or follow a named branch (for example to soak a stack change in Dev before it lands on main).
  3. Homelab-realistic ergonomics — accept some manual merge hygiene instead of building full release automation up front.

An earlier sketch of this approach lived in wip/komodo-artifact-templates/scratch/branch_management_ideas.md.

Decision

  1. Single infrastructure repo; main is the default line of truth
    After work merges, main holds the canonical layout for all managed environments (folder-separated configs, per existing repo structure docs).

  2. One ResourceSync configuration per Komodo-managed environment (Dev, Test, Prod — or whichever environments are GitOps-managed)
    Each [[resource_sync]] (or equivalent Komodo sync configuration) sets, at minimum:

    • Repository (and git provider / account as required).
    • Branch (or other ref field supported by Komodo’s ResourceSync schema — see Resource sync config) independently per environment.
    • Resource path scope limited to that environment’s subtree (for example only komodo/dev/** for Dev), so a branch used for experiments does not accidentally apply Prod definitions.
  3. Branch-testing workflow

    • Create a feature branch for the change under test.
    • Point the target environment’s ResourceSync at that branch (for example Dev follows feature/xyz while Test and Prod continue to follow main).
    • After validation, merge the feature branch to main.
    • Manually reconcile the ResourceSync metadata on main so environments that should track main again have their branch/ref set back to main (or another agreed default).

    Known quirk (accepted): the ResourceSync TOML that sets branch = "feature/xyz" lives in Git. On the feature branch that file typically must reference the same branch so the sync pulls a self-consistent tree. After merge to main, that same file’s branch field must be updated to main (or removed to default) for environments that should no longer track the feature branch. This is intentional small, explicit edits on main, not hidden automation, until/unless you add tooling.

  4. Releases / tags as a pin for Prod (optional, not required now)
    Using tags or GitHub Releases so Prod tracks refs/tags/v… while main moves forward is a valid pattern when you want Prod immutability without branch gymnastics. With separate per-environment directories and per-sync branch control, the urgency is lower than in a single-shared-path model. We defer mandatory release-based promotion; revisit if main churn becomes painful for Prod.

Consequences

Positive

  • Clear mental model: folders = promotion path; branch on ResourceSync = “which revision of that tree is live for this environment.”
  • Feature work can soak on Dev/Test without forking the whole repo layout conceptually on main.
  • Decisions stay in Git and stay reviewable.

Negative / risks

  • Merge hygiene: forgetting to repoint a ResourceSync after merge can leave an environment tracking a stale or abandoned branch.
  • Self-consistency: sync config and synced paths must agree on branch, or behavior is confusing during transitions.

Mitigations (recommended, non-blocking)

  • Short checklist when merging infra PRs: “ResourceSync branches updated for Dev/Test/Prod as intended?”
  • Keep ResourceSync definitions in small, dedicated files (for example under komodo/sync/ or similar) so diffs are obvious.
  • Optionally add CI later (grep/assert expected branch for Prod) if mistakes recur.

Alternatives considered

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

References

May 12, 2026

Tailscale

Network connectivity strategy for the homelab using Tailscale mesh VPN.


Overview

Tailscale is a mesh VPN built on WireGuard that creates a secure network (called a “tailnet”) across devices regardless of their physical location. Unlike traditional VPNs, there’s no central server that all traffic flows through — devices connect directly to each other.

This homelab leverages Tailscale Services as the primary mechanism for exposing applications, with ACL-based access control separating user-facing interfaces from administrative access.

Key Concepts

  • Tailnet — Your private network of connected devices
  • MagicDNS — Automatic DNS for devices (e.g., device-name.tailnet-name.ts.net)
  • Tailscale Services — Named services advertised to the tailnet, accessible via MagicDNS
  • ACLs — Access control lists defining who can reach what
  • Funnel — Expose services to the public internet through Tailscale
  • Serve — Expose local services to your tailnet

Advantages Over Traditional VPNs

Traditional VPNTailscale
Hub-and-spoke — all traffic through central serverMesh — direct device-to-device connections
Central server is bottleneck and single point of failureNo central infrastructure to fail
Complex setup (certificates, port forwarding, firewall rules)Zero-config in most cases
Requires exposing a port to the internetNAT traversal built-in, no exposed ports
VPN server needs to be always onCoordination server only for handshake, not traffic
All-or-nothing accessGranular ACLs per user/device/service

Why this matters for homelab:

  • No need to open ports on home router
  • Access homelab from anywhere without complex setup
  • Granular control over who accesses what
  • Works even when home IP changes

Tailscale Services

Tailscale Services are the primary mechanism for exposing applications in this homelab. Rather than exposing entire hosts or subnets, individual services are advertised to the tailnet with their own DNS names.

Why Services Over Hosts

ApproachExposureGranularity
Host-basedEntire machine accessibleAll ports, all services
Subnet routingEntire network accessibleEverything on the subnet
ServicesSingle application endpointOne port, one purpose

Services provide the tightest blast radius — if a service is compromised, the attacker gains access to that service only, not the host or network.

Service Naming Convention

Services are accessible via MagicDNS:

<service-name>.tailnet-name.ts.net

Examples:

  • actual.tailnet.ts.net — Actual Budget
  • plex.tailnet.ts.net — Plex Media Server
  • komodo.tailnet.ts.net — Komodo UI (admin-only)

Container Exposure Patterns

Three patterns for connecting containerized services to Tailscale:

Docktail — Service Advertisement

Docktail advertises containers as Tailscale Services. Use this when:

  • The application needs to be reachable by name from the tailnet
  • Multiple users/devices will access the service
  • You want the service in MagicDNS

Docktail watches for Docker labels and advertises matching containers as services.

ScaleTail — Per-Service Sidecar Exposure

ScaleTail provides Docker Compose configurations using the sidecar pattern — each service gets its own Tailscale container via network_mode: service:. The sidecar can advertise the service to the tailnet with Tailscale Serve.

Use this when:

  • You want per-service Tailscale instances (not centralized)
  • The container needs both inbound and outbound tailnet access
  • You want explicit control per service

Outbound-Only Sidecar

A sidecar container that joins the tailnet but does not advertise a service. Use this when:

  • The container needs to reach other tailnet resources
  • The container should not be directly addressable
  • You need tailnet access for configuration pulls, secrets, etc.

Example: A container that pulls secrets from Vault over Tailscale but doesn’t need inbound connections.

Choosing Between Them

NeedPattern
Users access this app, want centralizedDocktail (labels-based)
Per-service control, inbound + outboundScaleTail (sidecar pattern)
App needs tailnet access, no inboundOutbound-Only Sidecar
Both inbound and outbound (centralized)Docktail (service already has tailnet access)

High Availability with Services

Tailscale Services can be advertised from multiple machines for high availability. When the same service name is advertised from multiple nodes, Tailscale routes traffic to an available instance.

Multi-Node Service Advertisement

┌─────────────────┐     ┌─────────────────┐
│   Prod-Node-1   │     │   Prod-Node-2   │
│                 │     │                 │
│  ┌───────────┐  │     │  ┌───────────┐  │
│  │  Docktail │  │     │  │  Docktail │  │
│  │           │  │     │  │           │  │
│  │ advertises│  │     │  │ advertises│  │
│  │ "plex"    │  │     │  │ "plex"    │  │
│  └─────┬─────┘  │     │  └─────┬─────┘  │
└────────┼────────┘     └────────┼────────┘
         │                       │
         ▼                       ▼
    ┌─────────────────────────────────┐
    │  plex.tailnet.ts.net            │
    │  (routes to available node)     │
    └─────────────────────────────────┘

HA Candidates

Services that benefit from multi-node advertisement:

  • Reverse proxies / ingress
  • Stateless APIs
  • Load-balanced workers

Services that should remain single-node:

  • Stateful databases (unless using clustering)
  • Apps with local storage dependencies

Access Tiers

Not all users should access all interfaces. This homelab distinguishes between user-facing UIs and admin portals.

Role Definitions

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

Interface Separation

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

Implementation Approach

ACLs enforce access tiers by:

  1. Grouping users into roles (groups in ACL policy)
  2. Tagging services by interface type
  3. Granting access based on role → tag mappings

See [[tailscale-grants]] for the full grants policy document — this section defines the model, not the implementation.


Subnet Access Strategy

Subnet routing exposes entire network ranges to the tailnet. This is powerful but dangerous — it bypasses per-service access control.

When to Use Subnets

Subnet routing is appropriate for:

  • Admin management of devices that can’t run Tailscale (switches, APs, IoT devices)
  • Legacy systems that predate the Tailscale deployment
  • Temporary access during migrations

Admin-Only Subnet Access

Subnet routes are advertised but restricted to admins only via ACLs. Regular users and managers cannot reach devices via subnet routing — they must use advertised services.

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

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

Subnets vs Services

Access MethodUserManagerAdmin
Tailscale ServicesPer-ACLPer-ACLYes
Subnet RoutesNoNoYes

Exit Node Strategy

Exit nodes route all device traffic through a tailnet node. Useful for:

  • Appearing to be “at home” when traveling
  • Using home DNS/Pi-hole from anywhere
  • Accessing geo-restricted content

The Exposure Risk

An exit node that also advertises subnet routes or services creates a risk: a device using the exit node could potentially reach resources it shouldn’t.

Mitigation: Dedicated Exit Node

Run exit node functionality on a dedicated node that:

  • Advertises itself as an exit node
  • Does not advertise subnet routes
  • Does not run Docktail or advertise services
  • Has minimal local services

This isolates the exit node function from service exposure. Traffic exits to the internet through the node, but doesn’t gain access to internal resources.

┌─────────────────────────────────┐
│  Exit Node (dedicated)          │
│                                 │
│  ✓ Exit node enabled            │
│  ✗ No subnet routes             │
│  ✗ No Docktail                  │
│  ✗ No local services            │
│                                 │
│  Internet ◀── traffic exits     │
└─────────────────────────────────┘

Alternative: ACL-Controlled Exit

If a dedicated node isn’t practical, ACLs can restrict what exit node users can reach. But this is more complex and easier to misconfigure — the dedicated node approach is preferred.


Use Cases

Remote Application Access

Applications are exposed as Tailscale Services via Docktail:

Phone/Laptop ──(Tailscale)──▶ actual.tailnet.ts.net ──▶ Actual Budget container
  • No public exposure
  • Accessible from any device on the tailnet
  • Access controlled by ACLs based on user role

Remote Device Access

Tailscale SSH:

  • SSH into any device on the tailnet without managing SSH keys
  • Tailscale handles authentication via identity provider
  • Restricted to admins via ACLs
  • ssh user@device.tailnet.ts.net

Management UIs (admin-only):

  • Proxmox, Komodo, router admin panels
  • Accessible only to admin role
  • Either via advertised service or subnet route

Remote File Access

SMB shares:

  • Access Windows/Samba shares from anywhere
  • smb://nas.tailnet.ts.net/share
  • ACLs control which shares are accessible to which roles

NFS mounts:

  • Mount NFS shares over Tailscale
  • Performance may degrade over high-latency connections

Shared Access (Partner)

Partner has her own Tailscale account and devices, shared into the tailnet with controlled access. She is a member of the user role.

Adversarial Access Model

Treat partner’s access as adversarial by default — not because of distrust, but because:

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

Partner Access (User Role)

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

Environment-Based Protections

EnvironmentPartner Access
ProdUser Web UIs only
TestNo
DevNo

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


Docktail Implementation

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

Architecture

┌─────────────────────────────────────────┐
│            Docker Host                  │
│                                         │
│  ┌─────────────┐    ┌─────────────┐     │
│  │  Docktail   │    │  App        │     │
│  │             │◀───│  Container  │     │
│  │             │    │             │     │
│  │ tailscale   │    │ label:      │     │
 │  │  connected   │    │ docktail.*  │     │
│  └──────┬──────┘    └─────────────┘     │
│         │                               │
└─────────┼───────────────────────────────┘
          │
          ▼
    Tailscale Service
    (MagicDNS name)

Docker Labels

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

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

Why Docktail

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

Installation Strategy by Node Type

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

Komodo Controller (NUC)

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

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

ProxMox Host

Direct Tailscale installation. Admin-only access.

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

OCP Cluster (Dev)

Uses the Tailscale Kubernetes Operator for Kubernetes-native integration:

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

Docker Hosts (Prod/Test/Dev)

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

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

Dedicated Exit Node

A minimal node dedicated to exit node functionality:

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

Decisions

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

Open Questions

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

Aug 3, 2026

ADR: Podman vs Docker for Container Runtime

Status

Draft

Context

We need a container runtime for local development, CI/CD pipelines, and homelab deployment. The ecosystem has two primary open-source contenders: Docker (the incumbent) and Podman (the rising alternative). Both implement OCI standards and can run the same container images, but differ significantly in architecture, security model, and ecosystem integration.

Key evaluation criteria:

  • Security posture (rootless vs rootful by default)
  • Daemon architecture (monolithic vs fork/exec)
  • Docker-compatibility and migration friction
  • Desktop GUI experience (Docker Desktop vs Podman Desktop)
  • Orchestration integration (Kubernetes, Compose, Swarm)
  • Licensing and corporate governance

Decision

Defer — no single winner. The choice depends on the deployment context:

ContextRecommendationWhy
Local dev (macOS)Docker DesktopBetter UX, tighter IDE integration, mature volume mounts
Local dev (Linux)PodmanNative rootless, no daemon tax, distro-packaged
CI/CD pipelinesDocker-in-DockerIndustry standard, broadest runner support
Homelab serverPodmanSystemd integration, rootless by default, lighter footprint
Kubernetes devPodmanBuilt-in pod concept, better k8s alignment

Rationale

Security

  • Podman: Rootless by default. Each user gets their own namespace — no central daemon with root-equivalent access. This is a fundamental architectural advantage for multi-tenant systems and security-conscious deployments.
  • Docker: The daemon (dockerd) runs as root by default. Adding users to the docker group is effectively root-equivalent. Rootless mode exists but is opt-in and less mature.

Architecture

  • Docker: Client-server model. docker CLI → dockerd daemon → containerd → runc. The daemon is a single point of failure and a persistent resource drain.
  • Podman: Fork/exec model. Each podman command directly spawns container processes via conmon + runc/crun. No persistent daemon. This means containers are systemd-manageable and don’t die if a daemon crashes.

Docker-Compatibility

  • Podman implements the Docker CLI API (alias docker=podman works for most commands), supports docker-compose (via podman-compose or podman machine), and can pull/build/run any OCI image.
  • Edge cases exist: Volume mount semantics differ, networking stacks diverge (podman uses pasta/slirp4netns vs Docker’s built-in bridge), and some Docker-specific features (Swarm mode, BuildKit advanced features) have no Podman equivalent.

Desktop Experience

  • Docker Desktop: Polished, all-in-one GUI. Includes Kubernetes cluster, volume management, registry browser, Dev Environments, and extensions ecosystem. Paid license for commercial use >250 employees or $10M+ revenue.
  • Podman Desktop: Open-source GUI, improving rapidly. Supports Podman and Docker backends, Kubernetes integration, extensions. Lighter but less feature-rich. Free for all use.

Ecosystem & Governance

  • Docker: Docker, Inc. Owns the developer mindshare. Vast documentation, tutorials, Stack Overflow presence. Docker Hub is the default registry. Licensing changes (2021 commercial license) caused community friction.
  • Podman: Red Hat–backed, part of the libpod project. CNI-aligned networking, systemd-native. Growing adoption in enterprise Linux spaces (RHEL, Fedora). No corporate licensing tension.

Consequences

  • We may need to support both runtimes depending on environment. A docker alias or abstraction layer (CONTAINER_RUNTIME env var) can ease transitions.
  • Compose v3 files are broadly portable; v4 features may not be. Stick to v3 for cross-runtime compatibility.
  • Podman’s macOS experience relies on a Linux VM (via podman machine or podman desktop), similar to Docker Desktop but less polished. Volume mounts and port forwarding are slower on macOS.
  • If Kubernetes is the long-term target, Podman’s pod-level semantics map more naturally to k8s concepts.
  • Docker is still the default for most CI/CD providers. Using Podman in CI requires explicit runner configuration.

AI VM Architecture

Overview

A dedicated Debian VM with an NVIDIA Quadro P5000 passed through, running LlamaFarm natively as the AI management layer. The VM is fully disposable — Ansible provisions the OS and installs tooling; models and data live on persistent storage. If the VM is destroyed, a fresh provision + persistent volume mount restores full capability in minutes.

VM Specifications

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

Quadro P5000 Profile

SpecValue
VRAM16 GB GDDR5X
CUDA cores2560 (Pascal architecture — compute capability 6.1)
CUDA toolkit11.x or 12.x (Pascal supports both; use 12.x for upstream llama.cpp compatibility)
PCIeGen3 x16 — ensure host has an available slot and IOMMU group isolation
TDP180 W — account for cooling in the chassis

Important: Pascal (6.1) does not support bfloat16 natively. LlamaFarm’s Universal Runtime will use fp16 or fp32 fallback. Use GGUF quantized models (Q4_K_M) for best VRAM fit.

Storage Layout

Filesystem                  Contents                    Size         Persistent?
/dev/sda1 (root)            OS + lf CLI + PyApp bins    40-64 GB     No — recreated
/dev/sdb1 or NFS mount      Models + data               100-256 GB   Yes — survives

Persistent volume contents (/var/lib/llamafarm or mount at ~/.llamafarm):

~/.llamafarm/               # LF_DATA_DIR
├── bin/                    # PyApp service binaries (server, rag, runtime)
├── projects/               # Project configs, Chroma vector store, session state
└── addons/                 # ML addon wheels

~/.cache/huggingface/       # TRANSFORMERS_CACHE
└── hub/                    # Downloaded model weights (the real space hog)

Size estimate with a typical setup:

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

Software Stack

┌─────────────────────────────────────────────────────┐
│                    User / Client                      │
│  (Tailscale SSH, chat UI at :14345, REST API)        │
│           Direct HTTP — no reverse proxy             │
├─────────────────────────────────────────────────────┤
│              LlamaFarm Server (:14345)                │
│           OpenAI-compatible REST API                  │
│           Designer Web UI                             │
├─────────────────────────────────────────────────────┤
│                                                     │
│   ┌──────────────┐   ┌──────────────┐              │
│   │ RAG Worker   │   │ Universal    │              │
│   │ (Celery)     │   │ Runtime      │              │
│   │              │   │ (:11540)     │              │
│   │ Parsing      │   │ HF Models    │              │
│   │ Chunking     │   │ GGUF via     │              │
│   │ Embedding    │   │ llama.cpp   │              │
│   │ ChromaStore  │   │ OCR/Classify │              │
│   └──────────────┘   └──────┬───────┘              │
│                              │                       │
│                    ┌─────────▼─────────┐            │
│                    │   NVIDIA CUDA      │            │
│                    │   Quadro P5000    │            │
│                    │   16 GB VRAM      │            │
│                    └───────────────────┘            │
├─────────────────────────────────────────────────────┤
|              Debian (latest stable)                   |
├─────────────────────────────────────────────────────┤
|              Hypervisor (Proxmox/ESXi)               |
└─────────────────────────────────────────────────────┘

Provisioning Flow

Day 0: Hypervisor

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

Day 0: Ansible

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

The playbook:

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

Day 1: LlamaFarm

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

Model Configuration

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

runtime:
  default_model: balanced

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

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

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

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

Disposability Strategy

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

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

Recovery sequence:

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

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

Service Management

Service managed via systemd:

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

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

[Install]
WantedBy=multi-user.target

Ansible manages this unit file and can enable/start it.

Network Topology

Client ─── Tailscale ─── AI VM (:14345)
(laptop)    (overlay)     ├─ LlamaFarm Server / Designer UI
                          ├─ Universal Runtime (:11540)
                          └─ RAG worker (internal)
  • LlamaFarm API and Designer UI accessed directly over Tailscale at http://ai-vm:14345
  • No reverse proxy needed — LlamaFarm serves HTTP natively
  • Ports 14345 and 11540 bind to the Tailscale interface only
  • No ports exposed to LAN or WAN directly

Backup Strategy

WhatFrequencyMethod
~/.cache/huggingface/ (models)Once (re-downloadable)Backup only if slow link. Models are cachable artifacts.
~/.llamafarm/projects/ (configs + vector DB)Dailyrclone to NAS (or any S3-compatible target)
Ansible playbooksOn changeGit — push to private forge

Current Limitations & Future Considerations

  • Pascal architecture (compute 6.1) means no bfloat16. All inference is fp16 or fp32. Not a blocker, but newer GPUs (Turing/Ampere+) would be ~2x faster for the same model.
  • Single GPU — can serve one model at a time efficiently. For multi-model serving concurrently, split VRAM across instances or add a second GPU.
  • LlamaFarm pre-1.0 — expect occasional breaking changes. Pin the lf version and test upgrades in a disposable LXC first.
  • Monitoring — not yet addressed. Consider Prometheus + node_exporter or Uptime Kuma for health checks.

Architecture

System diagrams and visual documentation for the homelab infrastructure.


Environment Map

┌─────────────────────────────────────────────────────────────────────────────┐
│                        KOMODO CONTROLLER (NUC)                              │
│                     ┌──────────────┬───────────┐                            │
│                     │ Komodo Core  │  MongoDB  │                            │
│                     └──────┬───────┴───────────┘                            │
│                            │ manages                                        │
└────────────────────────────┼────────────────────────────────────────────────┘
                             │
        ┌────────────────────┼────────────────────┐
        │                    │                    │
        ▼                    ▼                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                      GITOPS MANAGED ENVIRONMENTS                            │
│                                                                             │
│  ┌─────────────────────┐ ┌─────────────────────┐ ┌───────────────────────┐  │
│  │        PROD         │ │        TEST         │ │          DEV          │  │
│  │                     │ │                     │ │                       │  │
│  │  ┌───────────────┐  │ │  ┌───────────────┐  │ │  ┌─────────────────┐  │  │
│  │  │  Prod MiniPC  │  │ │  │  Test MiniPC  │  │ │  │ DevDocker VM    │  │  │
│  │  │ [Periphery]   │  │ │  │ [Periphery]   │  │ │  │ (on ProxMox)    │  │  │
│  │  │               │  │ │  │               │  │ │  │ [Periphery]     │  │  │
│  │  │ • Plex        │  │ │  │ • Plex (test) │  │ │  │                 │  │  │
│  │  │ • *arr Suite  │  │ │  │ • Test Apps   │  │ │  │ • Experimental  │  │  │
│  │  │ • ...         │  │ │  │               │  │ │  │   containers    │  │  │
│  │  │               │  │ │  │               │  │ │  │                 │  │  │
│  │  └───────────────┘  │ │  └───────────────┘  │ │  └─────────────────┘  │  │
│  │         ▲           │ │         ▲           │ │                       │  │
│  │         │ NFS/SMB   │ │         │ NFS/SMB   │ │                       │  │
│  │  ┌──────┴────────┐  │ │  ┌──────┴────────┐  │ │                       │  │
│  │  │  UnRaid NAS   │  │ │  │ Synology NAS  │  │ │                       │  │
│  │  │   (Media)     │  │ │  │ (Test Data)   │  │ │                       │  │
│  │  └───────────────┘  │ │  └───────────────┘  │ │                       │  │
│  └─────────────────────┘ └─────────────────────┘ └───────────────────────┘  │
│                                                                             │
│  Promotion path: DEV ──────────────▶ TEST ──────────────▶ PROD              │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│                     UNMANAGED / SANDBOX ENVIRONMENTS                        │
│                                                                             │
│  ┌────────────────────────────────────┐  ┌───────────────────────────────┐  │
│  │     DEVNODE (ProxMox VMs)          │  │    DEVOCP (Isolated)          │  │
│  │                                    │  │                               │  │
│  │  • Test VM 1                       │  │  3x MiniPC Cluster:           │  │
│  │  • Test VM 2                       │  │  • Control Plane              │  │
│  │  • ...                             │  │  • Worker 1                   │  │
│  │                                    │  │  • Worker 2                   │  │
│  │  (bugfixes, themes, ad-hoc)        │  │                               │  │
│  │                                    │  │  (OpenShift - work projects)  │  │
│  └────────────────────────────────────┘  └───────────────────────────────┘  │
│                                                                             │
│  No Komodo. No GitOps. Just sandboxes.                                      │
└─────────────────────────────────────────────────────────────────────────────┘

Key Points

  • Komodo Controller sits outside the environment groups — it manages but is not part of workload infrastructure
  • Managed Docker hosts (Prod, Test, DevDocker) have Periphery agents and are GitOps-controlled
  • DevDocker VM runs on ProxMox but is still Komodo-managed — promotion path to Test → Prod
  • Unmanaged nodes in Dev — DevNode VMs and OCP cluster have no Komodo integration
  • Storage is accessed via network mounts from UnRaid NAS (Prod) and Synology NAS (Test)

GitOps Flow

Each managed environment is driven by Komodo ResourceSync, which can target main or a named branch independently (so Dev can track a feature branch while Prod tracks main). The full branch-steering workflow and merge hygiene are in the ADR on per-environment ResourceSync branches; repo layout and promotion folders are in Planned repository structure.

┌──────────────────┐
│    Developer     │
│                  │
│  Edit TOML or    │
│  Compose file    │
│        │         │
│        ▼         │
│    Git Push      │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│   GitHub Repo    │
│                  │
│  Repo Updated    │
└────────┬─────────┘
         │
         │ (polled by)
         ▼
┌──────────────────────────────────────────────────────┐
│                  KOMODO CONTROLLER                   │
│                                                      │
│  ┌────────────┐    ┌─────────────────────────────┐   │
│  │   Komodo   │───▶│  Fetch latest from Git      │   │
│  │    Core    │    └──────────────┬──────────────┘   │
│  │            │                   │                  │
│  │  (polling) │    ┌──────────────▼──────────────┐   │
│  │            │    │  Diff against current state │   │
│  └────────────┘    └──────────────┬──────────────┘   │
│                                   │                  │
│                    ┌──────────────▼──────────────┐   │
│                    │      Changes detected?      │   │
│                    └──────────────┬──────────────┘   │
│                          No ┌─────┴─────┐ Yes        │
│                             │           │            │
│                          (loop)  ┌──────▼────────┐   │
│                                  │ deploy: true? │   │
│                                  └───────┬───────┘   │
│                                 No ┌─────┴─────┐ Yes │
│                                    │           │     │
│                    ┌───────────────▼───┐ ┌─────▼───┐ │
│                    │ Alert: Pending    │ │  Auto   │ │
│                    │ (manual approval) │ │ Deploy  │ │
│                    └─────────┬─────────┘ └────┬────┘ │
│                              │                │      │
│                              └───────┬────────┘      │
└──────────────────────────────────────┼───────────────┘
                                       │
                                       ▼
┌──────────────────────────────────────────────────────┐
│                    TARGET NODE                       │
│                                                      │
│  ┌─────────────┐   ┌─────────────┐   ┌───────────┐   │
│  │  Periphery  │──▶│ Pull Image  │──▶│  Start/   │   │
│  │    Agent    │   │             │   │  Update   │   │
│  └─────────────┘   └─────────────┘   └─────┬─────┘   │
│                                            │         │
│                              ┌─────────────▼───────┐ │
│                              │    Health Check     │ │
│                              └──────────┬──────────┘ │
│                           Healthy ┌─────┴─────┐ Fail │
│                                   │           │      │
│                           ┌───────▼───┐ ┌─────▼────┐ │
│                           │  Running  │ │  Alert/  │ │
│                           │           │ │ Rollback │ │
│                           └─────┬─────┘ └──────────┘ │
└─────────────────────────────────┼────────────────────┘
                                  │
                                  ▼
┌──────────────────────────────────────────────────────┐
│               CONTINUOUS RECONCILIATION              │
│                                                      │
│      Komodo monitors ──▶ Drift detected? ──▶ Fix     │
│            ▲                                  │      │
│            └──────────────────────────────────┘      │
└──────────────────────────────────────────────────────┘

Flow Explanation

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

Hardware Summary

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

Network Topology

To be documented — network segmentation, VLANs, access controls between environments.

Architecture Decision Records — AI VM Management

ADRs here capture decisions for the AI VM homelab project.

#TitleStatus
0001LlamaFarm as AI model management layerProposed

Subsections of Architecture Decision Records — AI VM Management

ADR 0001: LlamaFarm as AI model management layer

Status: Proposed
Date: 2026-07-14

Context

The homelab has a dedicated AI VM running Ubuntu with assorted AI tools installed ad-hoc. There is no consistent model management layer — models are pulled manually, runtimes are configured individually (Ollama, vLLM, raw HuggingFace), and there is no unified API surface or RAG pipeline.

As part of a broader effort to make this VM “more than just a box with AI tools on it,” a management layer is needed for:

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

Decision Drivers

  1. Disposable VM model — the VM should be recreatable from Ansible + persistent data volumes without manual recovery steps.
  2. Local-first, offline-capable — the AI layer must work without internet after models are cached.
  3. GPU passthrough — quad-port P5000 needs CUDA support with minimal friction.
  4. Config-driven, not script-driven — model selection, prompt templates, and RAG pipelines should live in version-controlled config files, not shell scripts.
  5. OpenAI-compatible API — any tool that speaks OpenAI’s API should be able to target this VM.

Options Considered

Option 1: Ansible/Terraform/Semaphore only

Provision the VM, install Ollama/vLLM directly, manage models via shell scripts or ad-hoc commands.

  • Pro: Mature tooling, full homelab coverage.
  • Con: No model lifecycle abstraction — still need glue scripts for model switching, RAG pipelines, etc. Ansible does not natively understand “serve this model” as a primitive.

Option 2: LlamaFarm as the AI layer (chosen)

Provision the VM with Ansible, then run LlamaFarm natively (no containers) to manage models, RAG, and API serving.

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

Option 3: Kubernetes + Kubeflow / KServe

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

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

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

Orchestrate individual OSS components via Compose.

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

Decision

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

Ansible handles:

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

LlamaFarm handles:

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

Consequences

Positive

  • Single config file (llamafarm.yaml) is the source of truth for all AI behavior — model selection, prompts, RAG, MCP servers.
  • Truly disposable VM — models and data live on persistent volumes; a fresh Debian install + one lf init + lf start + pointing at cached models restores full capability.
  • Built-in RAG — no need to wire up a separate vector DB, embedder, and chunker; it’s all in the config.
  • Native deployment — LlamaFarm runs without Docker/K8s, keeping the footprint lean and the GPU path simple.
  • Offline-capable — after initial model download, all inference is local with no egress.

Negative / Risks

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

Mitigations

MitigationDetail
Pin LlamaFarm versionUse lf bundle --version v0.0.x to create offline install bundles; avoid latest drift.
Persistent model cache$TRANSFORMERS_CACHE and $LF_DATA_DIR on NAS-backed NFS or dedicated disk — models survive VM recreation.
Ansible as day-0 onlyAnsible provisions the VM and installs lf; LlamaFarm’s yaml is the AI config. Clear boundary reduces coupling.
Monitor upstreamKeep an eye on breaking changes before applying lf upgrades. Test in a disposable LXC first if uncertain.
NVIDIA driver pinningPin CUDA toolkit version in Ansible to match LlamaFarm’s PyTorch requirement.

References

Decisions

Key architectural and tooling decisions, with rationale.


Komodo over Kubernetes (K3s + ArgoCD)

Decision: Use Komodo for container GitOps instead of deploying K3s with ArgoCD.

Context: Needed GitOps capabilities (drift detection, continuous reconciliation) for Prod/Test environments. Current approach (GitHub Actions → Ansible playbooks) works but lacks:

  • Drift detection
  • Continuous reconciliation
  • Sustainable operational model

Options considered:

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

Decision rationale:

  1. Complexity: Komodo config is ~67 lines for a Plex stack vs ~176 lines for equivalent K8s manifests
  2. Risk tolerance: K3s in production homelab felt risky — more moving parts, less familiar failure modes
  3. Concept alignment: Docker Compose is already known; Komodo extends it rather than replacing it
  4. Operational simplicity: Single Komodo Core + Periphery agents vs. K8s control plane + ArgoCD + supporting infrastructure

Trade-offs accepted:

  • Komodo is a newer, smaller project than ArgoCD
  • Less community support and fewer integrations
  • If K8s skills are needed for work, the OCP cluster in Dev provides that separately

Three-Tier Managed Docker Hosts (Dev → Test → Prod)

Decision: DevDocker VM is Komodo-managed, not a sandbox.

Context: Initially considered all dev nodes as unmanaged sandboxes. Reconsidered based on promotion path needs.

Rationale:

  • DevDocker is where container work starts before going to production
  • Having it in Komodo creates a consistent workflow: same TOML format, same tooling
  • Promotion is literally moving a file between directories
  • Other Dev nodes (VMs, OCP cluster) don’t need this — they’re for different purposes

Dedicated Komodo Controller (NUC)

Decision: Run Komodo Core on dedicated hardware with no workloads.

Rationale:

  • Management plane should be separate from workload infrastructure
  • If a managed node has issues, the controller can still observe and remediate
  • Clean failure domain — controller failures don’t affect running workloads (they just stop being managed temporarily)
  • NUC is low-power, can run 24/7 without significant cost

Terraform + Ansible (Not Just Ansible)

Decision: Use Terraform for provisioning, Ansible for configuration.

Context: Already comfortable with Ansible. Question was whether to use Ansible for everything or split responsibilities.

Boundary:

  • Terraform: Provision VMs, network resources, base infrastructure
  • Ansible: System configuration, package installation, Komodo Periphery agent deployment

Rationale:

  • Terraform has better state management for infrastructure
  • Ansible is better for configuration management and idempotent system setup
  • Clean separation of concerns — “what exists” (Terraform) vs “how it’s configured” (Ansible)

Poll-Based GitOps (Not Webhooks)

Decision: Use Komodo’s default poll-based sync rather than GitHub webhooks.

Context: Komodo supports both:

  • Poll-based: Core periodically checks Git for changes
  • Webhook-based: GitHub notifies Core immediately on push

Rationale:

  • Simpler setup — no webhook configuration needed
  • Works even if GitHub can’t reach the homelab (no public endpoint required)
  • Slight delay (poll interval) is acceptable for homelab use case
  • Can add webhooks later if immediate deployment becomes important

Per-environment Git branches (ResourceSync)

Decision: Use separate Komodo ResourceSync configurations per environment, each with its own Git branch / ref, so main can hold all environment folders at once while Dev (or Test) temporarily tracks a feature branch for validation.

Context: Feature work needs to soak in a managed environment without blocking main or copying configs outside Git. ResourceSync already supports repo + branch + scoped paths.

Operational trade-off: ResourceSync TOML that lives in Git may reference the feature branch while that branch is active; after merge to main, small manual edits on main repoint environments back to main where intended. Releases/tags for Prod-only pinning are optional and deferred until needed.

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


References

FoxxMD — Migrating to Komodo (Nov 2024, updated Aug 2025)

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

An opinionated migration guide covering a homelab with 5 servers, 20+ stacks, and 60+ containers. Relevant to our setup given similar topology.

What it covers:

  • Storage strategy: Monorepo layout for stacks (stacks/serverN/) and Komodo resource TOMLs (komodo/resources/main.toml). Git repo-based for all resources (backup via commits on save).
  • Rootless Periphery (community pattern): Docker socket-proxy (linuxserver/socket-proxy) with a non-root Periphery container, controlled via DOCKER_HOST: tcp://socket-proxy:2375. Also covers building a custom Periphery image for git user config when running non-root.
  • Periphery deployment evolution: Started containerized, switched to systemd agent after 3+ months — “systemd makes Docker interactions simpler.” Notes terminal/shell access differences between container and systemd agents.
  • Stack creation: Configuring Linked Repos for monorepo, Run Directory per stack, converting standalone containers via docker-autocompose.
  • Environment variables & Secrets: How Komodo’s .env interpolation works, secrets via [[SecretName]] syntax.
  • Resource Sync: Setting up bi-directional sync between Komodo and a Git repo for full topology backup. Tags for scope limiting, Execute vs Commit modes.
  • Docker data agnostic location: Using $DOCKER_DATA ENV per host for bind mount paths, making compose files portable across servers.

Follow-up post (also by FoxxMD): Komodo FAQ, Tips, and Tricks — covers container exec shortcuts, troubleshooting, and deeper operational patterns.

Environments

Detailed breakdown of each environment (VLAN) in the homelab.


Managed Environments (GitOps)

These environments have Komodo Periphery agents on Docker hosts, managed via GitOps. Changes flow through Git → Komodo Core → Periphery Agent → Container.

Prod

Purpose: Production workloads — services that need to be reliable and always available.

VLAN: 10.0.1.0/24

Hardware:

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

Workloads:

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

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


Test

Purpose: Pre-production validation — test changes before promoting to Prod.

VLAN: 10.0.2.0/24

Hardware:

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

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

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


Dev

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

VLAN: 10.0.3.0/24

Hardware:

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

Managed vs Unmanaged:

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

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


Other Environments

These are network segments without Komodo-managed workloads.

User

Purpose: Personal workstation.

VLAN: 10.0.10.0/24

Hardware: Gaming PC

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


IoT

Purpose: Smart home devices and cameras.

VLAN: 10.0.4.0/24

Hardware: Reolink cameras, Google Home devices

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


DMZ

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

VLAN: 10.0.5.0/24

Hardware: Two deployment options:

Option A — Shared NICs via VLAN tagging:

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

Option B — Dedicated seed box:

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

Shared Storage:

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

Firewall:

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

Environment Comparison

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

Homelab Hardware

Overview

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


Physical Machines

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

Virtual Machines

Hosted on ProxMox (Tailscale installed on ProxMox host):

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

Networking

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

Homelab Map

Environments

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

VLANs & Subnets

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

Network Topology

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

Tailscale Overlay: All nodes

Services & Dependencies

Tailscale Services (on all Docker hosts):

  • docktail (logging)
  • uptimekuma (monitoring)

Dev Services (ProxMox DevDocker VM):

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

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

  • komga (media)

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

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

Prod-Only Services (LenovoMini 1):

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

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

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

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

LenovoMini 2 (Test)
├── Periphery Agent
├── Docker services
│   ├── komga          ├── transmission   ├── Signal
│   ├── uptimekuma     ├── mongodb        ├── Vault/tsidp
│   ├── docktail       ├── miniflux       ├── syncthing
│   └── scaletail
└── Synology NAS (mounted)

ProxMox Host (Dev)
├── DevDocker VM
│   ├── Periphery Agent
│   ├── Forgejo (Git)
│   ├── tsflow (CI/CD)
│   └── Experimental containers
└── DevNode VMs (unmanaged sandbox)

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

Future

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

Observability (in progress)

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

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

Homelab Repository Structure

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


Overview

The infrastructure repository has three main sections:

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

Full Structure

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

Terraform Section

Provisions the underlying infrastructure.

terraform/
├── prod/           # Production environment resources
├── test/           # Test environment resources
├── dev/            # Dev environment resources (DevDocker VM)
└── modules/        # Reusable modules

Managed resources:

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

Ansible Section

Configures systems after provisioning.

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

Responsibilities:

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

Komodo Section

Defines what containers run where.

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

Each environment has:

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

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

Server definition example:

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

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

Stack example (multi-container app):

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

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

Simple app example (single container, no compose — flat file):

# prod/homepage.toml
[[deployment]]
name = "homepage"
description = "Dashboard"
tags = ["dashboard", "prod"]

[deployment.config]
server = "prod-docker-01"
image = "ghcr.io/gethomepage/homepage:latest"
deploy = true

Shared Variables (shared/)

Variables that can be interpolated across environments.

# shared/variables.toml
[[variable]]
name = "TZ"
value = "America/New_York"

[[variable]]
name = "PUID"
value = "1000"

[[variable]]
name = "PGID"
value = "1000"

Promotion Flow

Moving apps between environments is a folder operation:

dev/new-app/
        │
        ▼ (validate in dev)
test/new-app/
        │
        ▼ (validate in test)
prod/new-app/

To promote: Copy or move the app’s folder to the next environment’s directory. Update the server field in the TOML to point to the correct host.

To deploy to multiple environments: Copy the folder to each environment. Each copy can have environment-specific tweaks (different compose settings, config values, etc.).


Git branch steering

Folder promotion (dev/ → test/ → prod/) answers which subtree on main is canonical after merge. Which Git revision each environment applies is separate: each Komodo ResourceSync sets its own branch / ref and scoped paths against the infrastructure repo.

Homelab default:

  • main holds the full komodo/ tree (every environment directory) for reviewability and docs.
  • Each environment has its own ResourceSync so, for example, Dev can track feature/xyz while Prod tracks main.
  • After merging a feature branch, manually repoint ResourceSync metadata on main for any environment that should follow main again (sync TOML on the feature branch usually names that branch while work is open — see ADR).

Releases / tags to pin Prod are optional later; per-environment folders + branch knobs reduce the urgency.

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


Komodo Controller

The Komodo Controller (dedicated NUC) runs:

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

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

IoT Plan — New House

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

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


Platform Stack

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

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


Phase 1 — Initial Deployment

Target Rooms

Garage:

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

Bedroom:

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

Whole House:

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

Shopping List

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

Already owned:

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

On order:

  • Unifi Network equipment
  • Unifi Protect + cameras

Switch Notes

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

Deferred Items

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

See decisions/ for detailed rationale.


Integrations

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

Key Automations

See automations.md for full workflow definitions.

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

Network Considerations

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

Stretch Goals

Automated HA Failover

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

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

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

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


Future Expansion

When ready to grow:

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

References

Subsections of IoT Plan — New House

ADR: Home Assistant Deployment

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


Context

Home Assistant (HA) is the central hub for IoT automation in the new house. It needs to integrate with:

  • Zigbee devices (switches, sensors, buttons) via USB coordinator
  • Tapo cameras (RTSP/Tapo integration)
  • Unifi Protect (running on UDM)
  • Google Home (TTS announcements)
  • Sensi thermostats (cloud API)

The homelab has a defined Prod/Test/Dev architecture with Komodo-managed Docker hosts. Home Assistant must fit into this architecture while meeting its unique requirements.

Requirements

  1. USB access — Zigbee coordinator must be physically attached to host
  2. Network access — Must reach IoT VLAN (10.0.4.0/24) for cameras, Google Home, etc.
  3. Unifi Protect access — Must reach UDM for Protect integration
  4. Reliability — Automation failures directly impact daily life
  5. Manageability — Should fit into existing GitOps/Komodo workflow where possible
  6. Backup/Recovery — Must be recoverable quickly if host fails

Constraints

  • IoT VLAN (10.0.4.0/24) is isolated from other VLANs by design
  • Zigbee coordinator is USB-bound — cannot easily fail over to another host
  • Home Assistant does not support native clustering/HA

Decision

Deploy Home Assistant as a container on LenovoMini 1 (Prod Docker host) with USB passthrough for the Zigbee coordinator.

Phased Approach

Phase 1 — Bootstrap (RPi):

  • Run HA on existing Raspberry Pi 4
  • Place on Prod VLAN (10.0.1.0/24)
  • Configure firewall rules for IoT access
  • Validates the setup before full integration

Phase 2 — Production Migration:

  • Migrate HA to container on LenovoMini 1
  • USB passthrough for Zigbee coordinator
  • Komodo-managed deployment
  • Automated backups to UnRaid NAS

Options Considered

Option 1: Dedicated Raspberry Pi

Description: Run Home Assistant OS on a dedicated RPi 4.

Pros:

  • Simple setup
  • Direct USB access (no passthrough complexity)
  • Isolated from other workloads

Cons:

  • Another device to manage outside Komodo
  • SD card reliability concerns
  • Limited compute for future expansion (Frigate, etc.)
  • No integration with existing backup infrastructure

Verdict: Good for bootstrap, not ideal long-term.


Option 2: Container on Prod Docker Host (LenovoMini 1)

Description: Run HA as a Docker container on the existing Prod host, managed via Komodo.

Pros:

  • Better hardware (8th Gen i7, 16GB RAM, NVIDIA GPU)
  • Integrated with existing management (Komodo, GitOps)
  • Backups to UnRaid NAS
  • Network already on Prod VLAN with access to other services
  • GPU available for future Frigate integration

Cons:

  • USB passthrough required (adds configuration)
  • Shares resources with Plex/*arr (mitigated by resource limits)
  • Single point of failure (mitigated by backups)

Verdict: Best fit for long-term architecture.


Option 3: VM on ProxMox

Description: Run HA in a dedicated VM on the ProxMox host.

Pros:

  • Isolated resources
  • ProxMox has snapshot/backup features

Cons:

  • ProxMox is on Dev VLAN (10.0.3.0/24) — wrong network segment
  • USB passthrough to VM is more complex than Docker
  • Not on the Komodo-managed path

Verdict: Network placement is wrong. Rejected.


Option 4: Dedicated Mini Host on Prod VLAN

Description: Purchase a dedicated small device (NUC, mini PC) for HA.

Pros:

  • Dedicated to home automation
  • Direct USB, proper network placement

Cons:

  • Additional hardware cost
  • Another device outside existing management
  • Overkill given available capacity on LenovoMini 1

Verdict: Unnecessary given existing infrastructure.


Consequences

Positive

  • Integrated management: HA deployment is Komodo-managed, follows GitOps patterns
  • Better hardware: More headroom for future expansion (Frigate, more integrations)
  • Unified backups: HA config/data backed up to UnRaid alongside other Prod data
  • Network positioning: Prod VLAN has (or will have) appropriate access to IoT and UDM

Negative

  • USB passthrough complexity: Requires privileged container or explicit device mapping
  • Shared host: HA competes with Plex/*arr for resources (mitigated with resource limits)
  • Single point of failure: If LenovoMini 1 goes down, HA is down (mitigated by backups + quick restore)

Neutral

  • No native HA failover: Accepted limitation — most homelabs operate this way
  • Zigbee bound to host: Coordinator cannot fail over, but Zigbee direct bindings provide local fallback

Implementation

Phase 1: Bootstrap on RPi

Timeline: Immediate (while homelab is being set up)

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

Backup: Manual snapshots to USB or network share


Phase 2: Migrate to Prod Docker

Timeline: When homelab Prod environment is stable

  1. Prepare LenovoMini 1:

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

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

    Notes:

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

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

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

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

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

Network Configuration

Firewall rules needed (on UDM):

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

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


Backup & Recovery

Backup strategy:

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

Recovery procedure:

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

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


Resilience Notes

What survives HA downtime:

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

What fails:

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

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



Phase 3: Automated Failover (Stretch Goal)

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

Concept

Implement a warm/cold spare HA instance on the Test environment (LenovoMini 2) with automated failover capability. When the primary (Prod) instance has unfixable issues, a single-click remediation brings up the spare and updates network rules.

Architecture

                     Normal Operation
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   Prod VLAN (10.0.1.0/24)              IoT VLAN (10.0.4.0/24)
│   ┌─────────────────────┐              ┌─────────────────┐  │
│   │   LenovoMini 1      │   allowed    │  Zigbee devices │  │
│   │   [HA - Active]     │─────────────▶│  Cameras        │  │
│   │   [Zigbee Coord 1]  │              │  Google Home    │  │
│   └─────────────────────┘              └─────────────────┘  │
│                                                 ▲           │
│   Test VLAN (10.0.2.0/24)               blocked │           │
│   ┌─────────────────────┐                       │           │
│   │   LenovoMini 2      │───────────────────────┘           │
│   │   [HA - Standby]    │                                   │
│   │   [Zigbee Coord 2]  │                                   │
│   └─────────────────────┘                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

                     Failover State
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   Prod VLAN (10.0.1.0/24)              IoT VLAN (10.0.4.0/24)
│   ┌─────────────────────┐              ┌─────────────────┐  │
│   │   LenovoMini 1      │   blocked    │  Zigbee devices │  │
│   │   [HA - DOWN]       │──────────X   │  Cameras        │  │
│   │                     │              │  Google Home    │  │
│   └─────────────────────┘              └─────────────────┘  │
│                                                 ▲           │
│   Test VLAN (10.0.2.0/24)               allowed │           │
│   ┌─────────────────────┐                       │           │
│   │   LenovoMini 2      │───────────────────────┘           │
│   │   [HA - Active]     │                                   │
│   │   [Zigbee Coord 2]  │                                   │
│   └─────────────────────┘                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Components

1. Standby HA Instance (LenovoMini 2)

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

2. Config Synchronization

  • Option A: Scheduled rsync of /opt/homeassistant/config from Prod → Test (via NAS or direct)
  • Option B: Shared config on NAS mount (both read, only active writes)
  • Option C: Git-based config with HA Git integration

3. Zigbee Network Portability

  • Use Zigbee2MQTT (not ZHA) — stores network config in coordinator_backup.json
  • Backup can be restored to second coordinator
  • Devices don’t need re-pairing if network key is preserved
  • Caveat: Second coordinator must be same model or compatible

4. Firewall Automation (Ansible/Terraform)

# ansible playbook: failover-to-test.yml
- name: Failover HA to Test environment
  hosts: localhost
  tasks:
    - name: Update UDM firewall - block Prod to IoT
      community.general.udm_firewall_rule:
        name: "Prod-to-IoT"
        state: disabled
      
    - name: Update UDM firewall - allow Test to IoT
      community.general.udm_firewall_rule:
        name: "Test-to-IoT"
        state: enabled
        
    - name: Start HA container on Test
      community.docker.docker_container:
        name: homeassistant
        state: started
      delegate_to: lenovomini2
      
    - name: Stop HA container on Prod (if reachable)
      community.docker.docker_container:
        name: homeassistant
        state: stopped
      delegate_to: lenovomini1
      ignore_errors: yes

5. Failback Procedure

  • Reverse the process when Prod is healthy
  • Sync any config changes from Test back to Prod
  • Re-enable Prod firewall rules, disable Test

Spare Modes

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

Recommendation: Start with Cold Spare. Warm Spare adds complexity for marginal time savings.

Triggering Failover

Manual (Phase 3a):

  • Single-click: Run Ansible playbook from Komodo UI or CLI
  • Human detects issue, initiates failover

Automated (Phase 3b, future):

  • Health check monitors HA (e.g., /api/ endpoint)
  • Consecutive failures trigger alert
  • Optional: Auto-failover after N failures (risky, needs tuning)

Prerequisites

Before implementing:

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

Risks & Mitigations

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

Cost

ItemCost
Second Zigbee coordinator~$25
Time to implementMedium
Ongoing maintenanceLow (once stable)

Decision

Defer to Phase 3. Implement after Phase 2 is stable and proven. The complexity is justified only after experiencing reliability issues or when uptime requirements increase.


References

Decision: Door Locks

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


Context

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


Decision

Defer smart locks. Focus on detection, not prevention.

Rationale

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

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

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

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


Future Considerations

Revisit when one of these use cases becomes valuable:

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

Options to Evaluate Later

Consumer (HA compatible):

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

Enterprise:

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

When Adding Locks

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

Decision: Smart Switch Protocol

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


Context

Planning IoT lighting for new house. Requirements:

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

Neutral wire is present.


Options Considered

Zigbee — Inovelli Blue Series

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

Pros:

  • Works with existing dumb switch in 3-way config
  • Large device ecosystem (sensors, buttons, plugs)
  • Cheap wireless buttons ($7-15)
  • Direct Zigbee binding (button controls switch without hub)
  • Configurable LED notification bar
  • Scene/multi-tap support
  • Mesh network extends coverage

Cons:

  • Higher per-switch cost
  • 2.4GHz interference possible (manageable)

Z-Wave — Zooz Switches

Protocol: Z-Wave Plus
Switch Cost: ~$30-35
Coordinator Cost: ~$35-45

Pros:

  • Works with existing dumb switch in 3-way config
  • Lower per-switch cost
  • Dedicated frequency (no WiFi interference)
  • Mature, reliable protocol

Cons:

  • Smaller device ecosystem
  • Wireless buttons limited and expensive ($40+)
  • Less feature-rich

Also Considered

Lutron Caseta: Excellent reliability, but requires proprietary bridge. Less flexible ecosystem.

WiFi switches: Cloud dependency or complex local setup. Doesn’t align with self-hosted goals.


Decision

Zigbee with Inovelli Blue switches.

Rationale

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

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

  3. Feature set: LED notifications, direct binding, scene control — useful for automations.

  4. 3-way handling: Single switch replacement works cleanly.


Implementation

  • Coordinator: Sonoff Zigbee 3.0 USB Dongle Plus
  • Integration: ZHA or Zigbee2MQTT (both well-supported)
  • Placement: Central location or USB extension to avoid interference

Home Assistant Automations

Workflow definitions for IoT automations. Copy/adapt these for Home Assistant.


Door Sensor Automations

Door Left Open Alert

Announces via Google Home when a door has been open for 5 minutes.

alias: "Alert - Door Left Open"
description: "Announce when a door has been open too long"
trigger:
  - platform: state
    entity_id:
      - binary_sensor.front_door
      - binary_sensor.back_door
      - binary_sensor.office_door
      - binary_sensor.garage_entry_door
    to: "on"
    for:
      minutes: 5
action:
  - service: tts.google_translate_say
    target:
      entity_id: media_player.living_room_speaker
    data:
      message: "{{ trigger.to_state.attributes.friendly_name }} has been open for 5 minutes."
mode: parallel

Door Still Open Reminder

Repeats announcement every 5 minutes while an exterior door remains open.

alias: "Alert - Door Still Open Reminder"
description: "Repeat reminder every 5 minutes while door is open"
trigger:
  - platform: state
    entity_id:
      - binary_sensor.front_door
      - binary_sensor.back_door
    to: "on"
    for:
      minutes: 5
action:
  - repeat:
      while:
        - condition: state
          entity_id: "{{ trigger.entity_id }}"
          state: "on"
      sequence:
        - service: tts.google_translate_say
          target:
            entity_id: media_player.living_room_speaker
          data:
            message: "Reminder: {{ trigger.to_state.attributes.friendly_name }} is still open."
        - delay:
            minutes: 5
mode: parallel

Garage Entry Night Alert

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

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

Lighting Automations

Garage Light Button

Wireless button in adjacent room toggles garage light.

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

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


Goodnight Scene

Single button or voice command triggers bedtime routine.

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

Presence Automations

Arriving Home

Turns on entry lights when arriving home after sunset.

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

Presence detection options:

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

Climate Automations (Future)

With Sensi thermostats integrated, potential automations:

Away Mode

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

Pre-condition Before Arrival

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

Integration Notes

Google Cast TTS

Uses tts.google_translate_say service. Requires:

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

Tapo Camera Snapshots

Requires Tapo integration or generic camera with RTSP. Snapshots save to /config/www/ for web access.

Zigbee Events

Button events use zha_event (for ZHA) or mqtt triggers (for Zigbee2MQTT). Device IDs and commands vary by button model — check HA device page after pairing.


Receipt Automation

Receipt Fridge Launch — NFC App Launch

Tap the fridge NFC tag to open the receipt scanner app directly.

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

The NFC tag is the entry point. The app handles the actual capture and any per-receipt workflow.

Considerations:

  • If the app supports deep links or URL schemes, prefer that over a generic notification.
  • If people use different phones/scanner apps, map multiple notify targets or a choose block by person/presence.

Service Check / Router Status NFC

Router Status — NFC Status Page

Opens a locally hosted status page showing service health.

alias: "Status - Router NFC"
description: "Serve local status page for router/service health"
trigger:
  - platform: nfc
    tag_id: <router_status_nfc_uid>
action:
  - service: browser_mod.popup
    data:
      title: "Service Status"
      # Serve from a local/static endpoint; no external CDN deps
      url: "/local/status/index.html"
mode: single

Implementation notes:

  • Host status/index.html on the same host that serves HA or a lightweight internal web server.
  • Keep the page plain HTML with no external dependencies so it works when the internet is down.
  • Display gateway ping, DNS resolution result, Pi-hole stats, and a one-click router reboot if supported.

Consumable Replacement NFC

Consumable Tag — Reorder Trigger

Opens the replacement item’s reorder path.

alias: "Consumables - Replace"
description: "Open consumable replacement action"
trigger:
  - platform: nfc
    tag_id: <consumable_nfc_uid>
action:
  - service: browser_mod.popup
    data:
      title: "Replace Air Filter"
      # Prefer a stable local/HA endpoint; the destination can rotate behind this URL
      url: "/local/consumables/air-filter.html"
mode: single

Instead of encoding a retailer URL directly, use a stable local or Home Assistant destination so the reorder page can change without rewriting the tag.


Pi-hole Temporary Disable NFC

Pi-hole — 30 Minute Disable

Disables Pi-hole time-boxed for 30 minutes.

alias: "Pi-hole - Temp Disable 30m"
description: "Disable Pi-hole for 30 minutes from NFC tap"
trigger:
  - platform: nfc
    tag_id: <pihole_temp_disable_nfc_uid>
action:
  - service: rest_command.pihole_disable_30m
mode: single

Time-box the disable window so ad-blocking is re-enabled automatically. Surface the remaining time on the local status page so the household can see the countdown.

Observability

Plans for public user-facing status and private operator dashboards (metrics, deep links, navigation to Komodo and Uptime Kuma).

DocumentContents
Status site & operator dashboardPhases, success criteria, sequencing, status.samplayskeys.com, single pane of glass

For hardware, VLANs, and which services run where, start at the homelab map.

Subsections of Observability

Public status site and operator dashboard

Plans for user-facing public status (status.samplayskeys.com) and a private operator “single pane of glass,” with acceptance criteria, sequencing, and open decisions.


Phase 1 — Public status site

URL: status.samplayskeys.com
Audience: Partner and other users of user-facing services (not homelab admins).
Tone / UX: Similar to GitHub Status: components (or service groups), overall status, recent incident history, minimal jargon.

Intent

  • User-available observability: clear “is the thing I use up?” without exposing internal topology or admin tools.
  • Survives local outage: host on a VPS (candidate: Oracle Cloud free tier) so the page stays reachable if home internet or the lab is down.

Success criteria

  1. Public HTTPS at status.samplayskeys.com with a sensible TLS story (e.g. reverse proxy + Let’s Encrypt on the VPS).
  2. Lists only non-admin, user-relevant resources (e.g. Plex, Signal, Miniflux, Syncthing client-facing checks — exact list is yours to curate).
  3. Does not link to or name internal admin URLs, VLANs, or management UIs.
  4. Status data remains trustworthy when the house is offline: checks must run from the VPS (or another off-LAN path) against reachable endpoints (public hostname, reverse proxy, or approved tunnel — not 10.x.x.x).
  5. Optional but valuable: brief incident log / maintenance notices you can edit without redeploying the whole stack (even a static markdown or small API is fine).

Open decisions (Phase 1)

TopicNotes
Product / implementationOptions include self-hosted status engines (e.g. Cachet, Statping-ng, OpenStatus-style), static site + scheduled probe publishing JSON, or minimal custom static + GitHub-style updates. Pick based on maintenance appetite and whether you want a DB on the VPS.
Source of truth vs Uptime KumaKuma may stay authoritative for alerting on the LAN; the public site can duplicate checks from the VPS or subscribe to exported state if you add a safe integration. Avoid coupling public page to LAN-only Kuma without a tunnel or push path.
DNSstatus subdomain DNS at your registrar → VPS public IP; confirm Oracle (or chosen host) outbound checks allowed to your public endpoints.

Non-goals (Phase 1)

  • Full metrics graphs, Prometheus, or environmental sensors (already deferred elsewhere).
  • Authenticated “admin view” of the same page (keep public surface boring and safe).

Phase 2 — Single pane of glass (operator)

Audience: You (operator), not the general public.
Primary job: One place to orient and jump to deeper tools.

Intent

  1. Overview of the entire lab — health/signal at a glance (CPU/load/disk on key hosts, maybe Docker summary), possibly via Glances or equivalent on hosts plus an aggregation story.
  2. Curated links (always visible):
    • Uptime Kuma (alerting / synthetics).
    • Komodo (deployments / stacks).
  3. Dynamic or data-driven links to management URLs: Unraid UI, container admin UIs, UniFi, Synology DSM, etc. — ideally driven from the same source of truth you use for Komodo or a small YAML manifest so new services do not require hand-editing HTML.

Success criteria

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

Open decisions (Phase 2)

TopicNotes
Glances vs hybridGlances is strong per-host; “whole lab” may mean Glances on LenovoMinis + NUC + ProxMox with a portal (Homer, Dashy, Heimdall, Homarr) that embeds or links. Pure Glances multi-server exists but compare ops cost vs a small static portal + Glances where you care most.
HostingTypically homelab-side (or Tailscale-only) — not the same trust boundary as the public status site.
Secrets in URLsManagement UIs often should not be in public git; prefer env-backed or encrypted manifest if URLs contain tokens (usually they should not — use SSO or Tailscale Serve).

Relationship to Phase 1

Phase 1 (public status)Phase 2 (operator dashboard)
HostVPSHomelab / Tailscale
AudienceUsersYou
DataSynthetic / public checksRicher metrics + deep links
AlertingInformational; Kuma can still alert youKuma primary; dashboard is navigation

Suggested sequencing

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

Pre-mortem hooks (when you pick tools)

  • Status on VPS: “Checks pass from Oracle but users still can’t reach the app” — split your connectivity vs user path (DNS, CDN, home IP change).
  • Operator dashboard: “I pasted every admin URL into git” — rotation and leak risk; prefer Tailscale MagicDNS names + no secrets in repo.

When you name concrete software for Phase 1 or 2, run a short Pre-Mortem before locking in.

Roadmap

Open questions, future work, and ideas for the homelab.


Open Questions

These need to be resolved as the build progresses.

Network Topology

  • Network segmentation between environments (VLANs) — defined in homelab-map.md
  • Firewall rules between Prod/Test/Dev/IoT/DMZ — defined in unifi-configurations.md
  • DNS strategy (internal DNS, split-horizon?)
  • Reverse proxy / ingress strategy (Traefik, Caddy, nginx?)

Storage Strategy

  • UnRaid NAS role in Prod — just media, or also app data?
  • Synology NAS role in Test — mirror of UnRaid structure, or different?
  • Backup strategy across environments
  • How does DevDocker access test data (if at all)?

Terraform + Ansible Boundary

  • Exactly which resources does Terraform manage?
  • Does Terraform manage the physical MiniPCs, or just VMs on ProxMox?
  • How do we bootstrap the Komodo Controller itself?

DMZ Strategy

  • DMZ VLAN defined (VLAN 5, 10.0.5.0/24) — see homelab-map.md
  • Decide on shared NIC vs dedicated seed box hardware
  • qBittorrent deployment (Docker on shared NIC or baremetal on seed box)
  • NFS export for shared storage (DMZ → UnRaid NAS)
  • Lock down firewall rules — strictly limit DMZ → Internal to NFS only

Monitoring & Alerting

  • Where does monitoring live? (Separate from workloads?)
  • Komodo has built-in alerting — is that sufficient?
  • Do we need a dedicated metrics/visualization stack (e.g. Prometheus/Grafana), or is Komodo + Uptime Kuma (+ existing logging) enough for the foreseeable future? (Prometheus/Grafana/Influx-style stack explicitly deferred — not pursuing yet.)

Secrets Management

  • How do secrets get into Komodo configs?
  • Komodo supports variables — are those sufficient for secrets?
  • Do we need external secrets management (Vault, etc.)?

Planned Workloads

Apps and stacks to deploy once infrastructure is ready.

Prod

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

DMZ

WorkloadTypePriority
qBittorrent (Internet Archive torrent backups)AppMedium
TBD——

Test

Mirror of Prod where validation is needed.

Dev

Experimental containers, new apps being evaluated.


Future Ideas

Things to consider but not immediately necessary.

Multi-repo Strategy

Currently planning a single infrastructure/ repo. Could split into:

  • infra-terraform/ — Just Terraform
  • infra-ansible/ — Just Ansible
  • homelab-apps/ — Just Komodo configs

Tradeoff: More repos = more isolation but more complexity. Single repo is simpler to start.

CI/CD for Infra Repo

  • Lint Terraform/Ansible/TOML on PR
  • Plan Terraform changes on PR, apply on merge
  • Validate Komodo TOML syntax before merge

Automated Testing

  • Spin up ephemeral Test environment for PRs
  • Run smoke tests against deployed containers
  • Tear down after validation

Disaster Recovery

  • Document recovery procedures for each environment
  • Automate rebuild from scratch (Terraform + Ansible + Komodo)
  • Backup strategy for stateful data (Plex metadata, *arr databases, etc.)

Hardware Upgrades

  • Upgrade path for MiniPCs if more compute needed
  • GPU passthrough for Plex transcoding (currently planned for Prod MiniPC?)
  • Additional storage nodes

Decisions Still Pending

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

Scratchpad


type: Idea

Machine List

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

Synology NAS - 4GB RAM, 6TB storage

Intel NUC - Celeron CPU, 8GB of RAM

Raspberry Pi 4 - 4GB RAM

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

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

Machine Assignments

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

JONSBO NAS - Prod, Tailscale container, mapped to LenovoMini 1

Synology NAS - Test, Tailscale installed, mapped to LenovoMini 2

ProxMox Host - Tailscale installed directly, several VMs available

  • VM 100 - Bluefin (remote work VM)
    • 4 vCPU, 8GB RAM, 50GB disk (sdb / bluefin-lvm)
    • Tailscale installed directly
  • VM 101 - (reserved for Windows VM)
  • VM 103 - Fedora Server (dev VM)
    • 8 vCPU, 16GB RAM, 50GB disk (nvme0n1 / nvme-lvm)
    • NVIDIA Quadro P5000 passthrough
  • “Debian Test”
    • Test VM, running debian, tools installed, used only for testing packages on Debian. Mostly stays powered off.
  • Docker VM
    • mirror of the test and prod VMs, running Komodo periphery, testing docker containers and workloads
  • Remaining resources will be used for testing out VM ideas. No remote windows machine is needed.

Intel NUC - Technically part of the prod environment, this runs the komodo instance that does the management for everything else. Additionally, it will be the main host that the ansible and terraform commands will be run from. It will have a GitHub runner installed to handle executing the management tasks.

Gaming PC - Not technically part of any environment (it will be part of a separate “user” environment) it will still function as my remote windows machine when needed.

NOTE: At some point, it may become worth it to leverage on of my spare laptops as a stand-in device for running an image for work/testing, but that is a future problem. For now, I will not include a machine to use as a “daily work” machine in my plans.

Unifi Zone-Based Firewall (ZBF) Guide

Overview

This guide walks through configuring the homelab network using UniFi’s Zone-Based Firewalls (ZBF), available in UniFi Network 9.0+.

ZBF shifts away from traditional LAN In/Out rules by grouping networks into Zones and defining Policies that dictate traffic flow between those zones. This is visualized in the Zone Matrix and is vastly superior for environment segmentation.

1. Define Custom Zones

By default, UniFi places all local networks into the built-in Internal zone. To segment our environments, we will create custom zones that perfectly map to our VLANs.

Go to Settings > Security > Firewall > Zones and create the following custom zones:

Zone NameAssigned Network (VLAN)Purpose
ProdProd (1)Core services, Home Assistant
TestTest (2)Pre-production validation
DevDev (3)OCP and Sandbox nodes
IoTIoT (4)Cameras, smart plugs
DMZDMZ (5)Public-facing services (qBittorrent)
UserUser (10)Laptops, phones, trusted devices

(Note: Ensure each VLAN is moved out of the default Internal zone and into its respective custom zone.)

Built-in Zones

Alongside our custom zones, the following built-in UniFi zones remain actively used:

  • Hotspot: Contains the Guest network (VLAN 20). UniFi automatically isolates this zone (blocks access to all other zones) while allowing internet access, perfectly mapping to guest network needs.
  • Gateway: Represents the router itself. Essential for all zones to access DHCP, DNS, and Gateway functions.
  • External: Represents the Internet.

2. Configure the Zone Matrix (Policies)

Instead of a linear list of “Drop” rules, ZBF uses a matrix (source zone on the Y-axis, destination zone on the X-axis). We define explicit policies for allowed paths and lock down the rest.

Go to Settings > Security > Firewall > Policy Table (or click the cells in the Zone Matrix) and configure the following policies:

Policy 1: HA Control over IoT

Allows Home Assistant to reach and control IoT devices.

  • Name: Allow HA to IoT
  • Action: Allow
  • Source Zone: Prod
  • Destination Zone: IoT
  • Source Device/IP: 10.0.1.X (Home Assistant IP)
  • Auto Allow Return Traffic: Checked (Ensures IoT devices can respond to HA)

Policy 2: User Local Access to Prod (Optional for Latency)

Allows User VLAN devices fast local access to specific Prod services (bypassing Tailscale).

  • Name: Allow User to Prod Apps
  • Action: Allow
  • Source Zone: User
  • Destination Zone: Prod
  • Destination Port: Select specific ports (e.g., 8123 for HA, 32400 for Plex)
  • Auto Allow Return Traffic: Checked

Policy 3: Allow User to IoT (Chromecast/Casting)

Allows phones/laptops on User VLAN to cast media to Chromecasts and smart speakers on IoT VLAN.

  • Name: Allow User to IoT Casting
  • Action: Allow
  • Source Zone: User
  • Destination Zone: IoT
  • Destination Port: 8008, 8009 (TCP - Chromecast control ports)
  • Auto Allow Return Traffic: Checked

Policy 4: Prod to Test Synchronization

Allows Prod services to sync with Test.

  • Name: Allow Prod to Test
  • Action: Allow
  • Source Zone: Prod
  • Destination Zone: Test
  • Auto Allow Return Traffic: Checked

Policy 5: Test to Dev Validation

Allows Test services to reach Dev.

  • Name: Allow Test to Dev
  • Action: Allow
  • Source Zone: Test
  • Destination Zone: Dev
  • Auto Allow Return Traffic: Checked

Policy 6: DMZ to External (Internet)

Allows DMZ services (e.g. qBittorrent) to reach the internet for torrent traffic and updates.

  • Name: Allow DMZ to External
  • Action: Allow
  • Source Zone: DMZ
  • Destination Zone: External
  • Auto Allow Return Traffic: Checked

Policy 7: External to DMZ (Specific Ports)

Allows incoming connections to DMZ services on required ports. Tightly scoped to only what’s needed.

  • Name: Allow External to DMZ Services
  • Action: Allow
  • Source Zone: External
  • Destination Zone: DMZ
  • Destination Port: 6881 (TCP — torrent); 6881 (UDP — DHT); add more as new services are added
  • Auto Allow Return Traffic: Checked
  • Notes: This is the only inbound path from the internet into the lab. Review and restrict per-service.

Policy 8: DMZ to UnRaid NAS (Shared Storage)

Allows DMZ services to write downloaded content to the UnRaid NAS NFS export so Prod services can access it.

  • Name: Allow DMZ to UnRaid NFS
  • Action: Allow
  • Source Zone: DMZ
  • Destination Zone: Prod
  • Source Device/IP: DMZ subnet (10.0.5.0/24)
  • Destination IP: UnRaid NAS IP (10.0.1.X)
  • Destination Port: 2049 (TCP — NFS)
  • Auto Allow Return Traffic: Checked
  • Notes: This is the only exception to DMZ → Internal block. Tightly scoped to a single IP + port.

3. Enable Multicast DNS (mDNS) for Discovery

For casting to work across VLANs, devices on the User VLAN must be able to discover the Chromecasts on the IoT VLAN. UniFi handles this via a built-in mDNS reflector.

Go to Settings > Networks > Global Network Settings (or the specific network settings) and ensure Multicast DNS is enabled for the User and IoT networks.

(Note: The UniFi Gateway automatically permits mDNS discovery traffic under the hood when this feature is turned on, so no explicit ZBF policy is needed for the discovery phase—only for the streaming connection, which Policy 3 handles).

4. Enforce Default Deny Between Custom Zones

To complete the segmentation, we must ensure that all other inter-zone traffic is blocked. UniFi’s matrix allows you to set the baseline interaction between zones.

For any zone pair that shouldn’t communicate (e.g., IoT to User, User to Dev, DMZ to any internal zone), create a catch-all block policy:

  • Name: Block Inter-Zone Traffic
  • Action: Block
  • Source Zone: Select all custom zones (Prod, Test, Dev, IoT, User, DMZ)
  • Destination Zone: Select all custom zones (Prod, Test, Dev, IoT, User)
  • Note: Place this policy at the very bottom of the Policy Table so our explicit “Allow” rules above take precedence. (The built-in Hotspot zone is natively blocked from other zones by UniFi, so it does not need to be included in this block list).

5. Port Forwarding — qBittorrent

Port forwarding is required for qBittorrent to accept incoming peer connections, which improves swarm connectivity and satisfies tracker connectability requirements.

Configure Port Forward Rule

Go to Settings > Security > Traffic Rules > Port Forwarding and create:

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

qBittorrent Configuration

In qBittorrent, set the incoming port to match the forward port:

Settings > Connection > Listening Port: 51820

Relationship to ZBF Policies

  • The port forward rule implicitly creates a firewall exception on the WAN interface.
  • ZBF Policy 7 (External → DMZ) still governs east-west traffic once the packet arrives at the DMZ VLAN.
  • If Policy 7’s destination port is locked to 6881, you must update it to match your chosen forward port (51820), or widen it to cover both.

Optional: Static DHCP Lease

Ensure the DMZ machine’s IP does not change:

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

  • Add a fixed lease for the qBittorrent MAC → 10.0.5.X

6. Internet Access (External Zone)

The External zone represents the Internet.

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

Guides

Aug 1, 2026

Subsections of Guides

Checking OpenShift Certificates

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:

ComponentNamespaceSecret Name
API Serveropenshift-kube-apiserverkube-apiserver-cert-key-pair
Ingress (default)openshift-ingressrouter-certs-default
Controller Manageropenshift-kube-controller-managerVarious cert secrets
Scheduleropenshift-kube-schedulerVarious cert secrets

Quick Checks

Use these one-liners to manually inspect specific certificates.

API Server Certificate

To check the expiration date directly:

oc get secret -n openshift-kube-apiserver kube-apiserver-cert-key-pair \
  -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -enddate -noout

To view full certificate details including the validity period:

oc get secret -n openshift-kube-apiserver kube-apiserver-to-kubelet-signer \
  -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -text -noout | grep -A2 "Validity"

Default Ingress Controller Certificate

To check the expiration date:

oc get secret -n openshift-ingress router-certs-default \
  -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -enddate -noout

Automated Expiration Checks

When dealing with many certificates, automation is necessary. Here are several approaches for scripting certificate checks.

Option 1: Multi-Namespace Checking

This script iterates over key namespaces and extracts the end date for all TLS secrets:

#!/bin/bash

NAMESPACES=("openshift-kube-apiserver" "openshift-ingress" "openshift-config-managed")

for ns in "${NAMESPACES[@]}"; do
    echo "======== $ns ========"
    oc get secret -n "$ns" -o json 2>/dev/null | \
      jq -r '.items[] | select(.type == "kubernetes.io/tls") | .metadata.name' | \
      while read secret; do
        echo -n "$secret: "
        oc get secret -n "$ns" "$secret" -o jsonpath='{.data.tls\.crt}' | \
          base64 -d | openssl x509 -enddate -noout | cut -d= -f2
    done
    echo ""
done

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=30

oc 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 soon
    if [ $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:

oc get secret -n openshift-kube-apiserver -o json | \
  jq -r '.items[] | select(.type == "kubernetes.io/tls") | .metadata.name' | \
  while read secret; do
    echo "=== $secret ==="
    oc get secret -n openshift-kube-apiserver "$secret" \
      -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -enddate -noout
  done

Built-In Monitoring

While scripts are helpful for point-in-time checks, OpenShift provides built-in mechanisms for monitoring:

  1. Prometheus Alerts: OpenShift’s default monitoring stack includes Prometheus alerts for certificate expiration.
  2. Web Console: Check Observe → Alerting in the OpenShift web console for active certificate expiration warnings.
  3. 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>.
Aug 1, 2026

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:

  1. Workflow A — Detects branches and auto-creates PRs against main
  2. 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

OptionHow It WorksProsCons
Personal Access TokenUse a PAT instead of GITHUB_TOKENSimpleTied to user account, expires, requires secret management
GitHub AppCreate app, use its tokenNot tied to user, fine-grained permissionsRequires app creation/management, may need org admin access
workflow_run triggerWorkflow B triggers on Workflow A completionNo extra tokens, works within default permissionsSlightly 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)
    └────────────┘

Implementation

Workflow A: Auto-Create PR

# .github/workflows/auto-pr.yml
name: Auto Create PR

on:
  push:
    branches:
      - 'feature/**'
      - 'fix/**'

jobs:
  create-pr:
    runs-on: ubuntu-latest
    outputs:
      pr-number: ${{ steps.create-pr.outputs.pull-request-number }}
      pr-url: ${{ steps.create-pr.outputs.pull-request-url }}
    steps:
      - uses: actions/checkout@v4

      - name: Create Pull Request
        id: create-pr
        uses: peter-evans/create-pull-request@v5
        with:
          title: "Auto PR: ${{ github.ref_name }}"
          body: |
            Automated PR for branch `${{ github.ref_name }}`
            
            Created by workflow run: ${{ github.run_id }}
          base: main
          branch: ${{ github.ref_name }}

      - name: Output PR info
        run: |
          echo "Created PR #${{ steps.create-pr.outputs.pull-request-number }}"
          echo "URL: ${{ steps.create-pr.outputs.pull-request-url }}"

Workflow B: Run Tests on Workflow A Completion

# .github/workflows/pr-tests.yml
name: PR Tests

on:
  # Still trigger on manual PRs
  pull_request:
    branches: [main]

  # Also trigger when Auto Create PR workflow completes
  workflow_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-info
        if: github.event_name == 'workflow_run'
        uses: actions/github-script@v7
        with:
          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 code
        uses: actions/checkout@v4
        with:
          # Use PR ref for workflow_run, default for pull_request
          ref: ${{ github.event_name == 'workflow_run' && steps.pr-info.outputs.pr-head-ref || github.head_ref }}

      - name: Run tests
        run: |
          echo "Running tests..."
          # Your test commands here
          npm test  # or pytest, go test, etc.

      - name: Report status to PR
        if: github.event_name == 'workflow_run'
        uses: actions/github-script@v7
        with:
          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

  1. Dual triggers: Workflow B responds to both pull_request (manual PRs) and workflow_run (auto PRs)

  2. Conditional logic: The if condition ensures we only run on successful Workflow A completions

  3. PR discovery: Since workflow_run doesn’t have PR context, we query the API to find the PR by branch name

  4. 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 indexed
  run: 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:

  1. Push a branch matching your pattern (e.g., feature/test-chain)
  2. Confirm Workflow A runs and creates the PR
  3. Confirm Workflow B triggers on Workflow A completion
  4. 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 libraryRequired bySymptom when running krunkit --version
libepoxylibkrun-efiLibrary not loaded: .../libepoxy.0.dylib
molten-vkvirglrendererLibrary 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.

3. Check Homebrew dependency tree

brew deps krunkit
brew deps --installed krunkit libkrun-efi

Compare installed deps against what krunkit --version reports as missing.

4. Inspect linked libraries (optional)

otool -L "$(which krunkit)"

Follow the chain into /opt/homebrew/Cellar/ if multiple libraries are missing.


Resolution

Install the missing libraries reported by krunkit --version, then start the machine.

For the missing libepoxy and molten-vk libraries:

brew install libepoxy molten-vk

Verify krunkit runs:

krunkit --version
# krunkit 1.1.1

Start the Podman machine:

podman machine start

If dependencies are still broken or versions are mismatched, reinstall the krunkit stack so Homebrew relinks everything:

brew reinstall krunkit libkrun-efi
podman machine start

Verification

Confirm client–server connectivity:

podman version

Run a test container:

podman run --rm hello-world

Expected: image pulls successfully and prints the Podman hello-world banner.


Prevention

  • Install krunkit from the maintained tap so Homebrew tracks dependencies:

    brew tap slp/krun
    brew install krunkit
  • Avoid orphaning dependencies — if you run brew cleanup or manually remove packages, check that krunkit --version still works before starting Podman.

  • After major macOS or Homebrew upgrades, verify the stack:

    krunkit --version && podman machine start
  • Rootless vs rootful — the default machine runs rootless. If you need ports below 1024 or hit Docker-client compatibility issues:

    podman machine set --rootful

Environment (reference)

This issue was diagnosed on:

ItemValue
OSmacOS 26.5.1 (arm64)
Podman5.8.2 (Homebrew)
krunkit1.1.1
Machine providerlibkrun

References

HCL BigFix Agent Architecture Guide

Overview

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 CountRoleCPU CoresRAMStorage TypeRecommended Disk IOPS
< 1,000All-in-One Server4 Cores16 GBSSD (SAS/SATA)500+
1,000 - 10,000Root Server + DB8 Cores32 GBHigh-Speed NVMe/SSD2,000+
10,000 - 50,000Split Root & DB Server16 Cores64 GB+Dedicated Enterprise SAN5,000+

Prerequisites

1. Network & Firewall Port Requirements

The BigFix platform uses TCP/UDP port 52311 as its default communication channel.

SourceDestinationPort / ProtocolDirectionPurpose
Windows ConsoleRoot ServerTCP 52311InboundConsole-to-Server connection
Root ServerInternet (HCL)TCP 80 / 443OutboundGathering sync feeds and downloads
RelaysRoot ServerTCP 52311InboundReporting parent-upstream status
Clients (Agents)Local RelayTCP 52311InboundClient report uploads & payloads
Relays / ServerClients (Agents)UDP 52311Outbound“Ping” notification to trigger fast checks
Web Reports ServerRoot Server / DBTCP 1433 or 50000InboundSQL 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:

Phase 1: License Authorization & Masthead Creation

  1. Run the BigFix Installation Generator on a Windows staging machine.
  2. Submit your HCL license authorization file (.BesLicenseRequest) to the HCL License server.
  3. HCL returns a Signed License file.
  4. 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

  1. Run the console installer on a Windows administrator workstation.
  2. Connect to the Root Server using the operator account created during server installation.

Phase 4: Deploying Relays

  1. Install the standard BigFix Agent on dedicated relay VMs or branch office hosts.
  2. In the BigFix Console, right-click the designated computers and select “Install BigFix Relay”.
  3. 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:

  1. How many total endpoints (workstations, servers, VMs, containers) are in scope?
    • Why: Determines whether SQL Server Express is acceptable (< 500) and sets hardware specifications.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

References

Installing Podman on MacOS

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

RequirementNotes
macOS 14+Required for libkrun/krunkit on Apple Silicon
Apple Silicon or IntelUniversal .dmg works on both
Admin passwordNeeded during first-run Podman engine install
~4 GB RAM minimumDefault 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.

MethodCommand / sourceOfficial stance
DMG installer (recommended)podman-desktop.io/downloads/macosMost stable; bundles Podman, krunkit, and CLIs
Homebrewbrew install --cask podman-desktopSupported but not recommended by upstream

Do not mix install methods

If Podman is already installed via Homebrew, either:

  • Uninstall Homebrew Podman before using the .dmg installer, or
  • Use Homebrew only — do not also install the .dmg

Mixing paths causes duplicate binaries, conflicting machine configs, and hard-to-debug startup failures.

# Check what you already have
which podman
podman --version
brew list --cask podman-desktop 2>/dev/null
ls /Applications/Podman\ Desktop.app 2>/dev/null

Installation

  1. Download the Universal .dmg from Podman Desktop macOS downloads.
  2. Open the .dmg and drag Podman Desktop to Applications.
  3. Launch from Applications or Spotlight (Cmd+Space → “Podman Desktop”).
  4. Complete onboarding (see First-run setup below).

The installer bundles krunkit and avoids the Homebrew dependency-chain issues described in Podman Machine krunkit Abort Trap.

Option B: Homebrew

brew install --cask podman-desktop

Homebrew installs Podman Desktop and the Podman engine if not already present. Open the app from /Applications.

If you use libkrun (krunkit is on your PATH), install krunkit from the maintained tap so dependencies are tracked:

brew tap slp/krun
brew install krunkit

After any Homebrew install, verify krunkit before creating a machine:

krunkit --version

If this crashes, see the krunkit abort trap guide before proceeding.


First-run setup

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

  1. Install Podman engine — enter your macOS password when prompted.
  2. Create a Podman machine — default name is podman-machine-default.
  3. Install optional CLIs — kubectl and compose (Podman Compose).
  4. 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

Customize resources before or after init:

podman machine set --cpus 6 --memory 4096 --disk-size 100
podman machine start

Machine providers

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 mappingRuns as your user inside the VMRuns as root inside the VM
SecuritySmaller blast radiusBroader privileges in the VM
Privileged ports (< 1024)Cannot bind (e.g. port 80, 443)Can bind
Some Docker toolsMay need extra socket/configBetter compatibility
Editing VM system filesRequires podman machine ssh as userEasier 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

  1. Ensure the Podman machine is running.
  2. Enable Docker compatibility in Podman Desktop (on by default on macOS).
  3. Install the Docker CLI if you do not have it (onboarding or brew install docker).
  4. 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:

export DOCKER_HOST="unix://${HOME}/.local/share/containers/podman/machine/podman.sock"

Or create a Docker context:

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

Fix: See Podman Machine krunkit Abort Trap.

Quick check:

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 4
podman 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 running
podman machine list

# Client ↔ VM connectivity
podman version

# Basic container
podman run --rm hello-world

# Docker API socket (when compatibility enabled)
ls -la /var/run/docker.sock

# Optional: Docker CLI against Podman
docker info --format '{{.ServerVersion}}'

In Podman Desktop: Settings → Resources shows the machine as Running; Settings → Docker Compatibility shows the socket as reachable.


Quick reference

TaskCommand / location
Start machinepodman machine start or Podman Desktop Dashboard
Stop machinepodman machine stop
SSH into VMpodman machine ssh
Enable rootfulpodman machine set --rootful
Docker compatibilitySettings → Docker Compatibility
Fix krunkit crashkrunkit abort trap guide
Increase RAM/CPUspodman machine set --memory 8192 --cpus 4

References

NVIDIA Container Toolkit & CDI Setup on Proxmox GPU Passthrough VMs

Overview

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
  • Docker installed

Step-by-Step

1. Install NVIDIA Container Toolkit

curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
  sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
  sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit

2. Generate CDI Specs

sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml

Verify the spec was created:

ls -la /etc/cdi/nvidia.yaml

3. (If Needed) Create AMD CDI Stub

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:

sudo tee /etc/cdi/amd.yaml > /dev/null <<'EOF'
---
cdiVersion: "0.5.0"
kind: "amd.com/gpu"
devices:
  - name: "stub"
    containerEdits:
      env:
        - ROCR_VISIBLE_DEVICES = ""
EOF

4. Configure Docker for CDI

sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

5. Verify GPU Access

docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi

You should see the GPU listed with driver version and CUDA capabilities.

Common Pitfalls

PitfallResolution
“AMD CDI spec not found”Create stub /etc/cdi/amd.yaml as described above
CDI specs in /run/cdi/ conflict with /etc/cdi/Clean both directories and regenerate
GPU works on host but not in containersVerify CDI specs exist and Docker was restarted
Container starts but nvidia-smi fails insideCheck container runtime is docker (not containerd directly)

References

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.

Primary NVIDIA reference: NVIDIA GPU Operator with OpenShift Virtualization — Building the vGPU Manager image and Creating a ClusterPolicy.

Related workspace guide: Using the integrated OpenShift container image registry.


End-to-end workflow (integrated registry)

1. Download the vGPU Manager .run

  • 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 release
cp /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:

default-route-openshift-image-registry.apps.<cluster-base>/<project>

…where <project> is often nvidia-gpu-operator or a dedicated images project. See using-integrated-openshift-registry.md for login and push mechanics.

Example (illustrative only — adjust host, project, and versions):

export VERSION=510.73.06
export OS_TAG=rhcos4.15
export PRIVATE_REGISTRY=default-route-openshift-image-registry.apps.cluster.example.com/nvidia-gpu-operator

Build and push using podman or docker as in NVIDIA’s doc:

podman build --build-arg DRIVER_VERSION="${VERSION}" \
  -t "${PRIVATE_REGISTRY}/vgpu-manager:${VERSION}-${OS_TAG}" .
podman push "${PRIVATE_REGISTRY}/vgpu-manager:${VERSION}-${OS_TAG}"

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 ClusterPolicy vgpuManager.repository / image / version (or equivalent fields) must match how your installed GPU Operator version assembles the pull spec. Always confirm with:

oc explain clusterpolicy.spec.vgpuManager --recursive

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

5. Update ClusterPolicy (operator already installed)

Generate or export the example ClusterPolicy, then merge at least:

  • spec.sandboxWorkloads.enabled: true
  • spec.sandboxDevicePlugin.enabled: true
  • spec.vgpuManager.enabled: true with repository, image, version, and imagePullSecrets if your registry requires them
  • spec.vgpuDeviceManager.enabled: true

NVIDIA’s JSON-oriented example is in Creating a ClusterPolicy for the GPU Operator using the CLI. If a ClusterPolicy already exists, prefer oc get clusterpolicy -o yaml, edit, and oc apply.

6. Verify

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.

References

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):

    image-registry.openshift-image-registry.svc:5000

  • Full image reference:

    image-registry.openshift-image-registry.svc:5000/<project>/<image>:<tag>

    Example: image-registry.openshift-image-registry.svc:5000/nvidia-gpu-operator/vgpu-manager:9.0.0.0

  • Private by default: images are not public on the internet; access is via Kubernetes RBAC and image pull credentials where needed.

Prerequisites

  1. Registry is running — ManagementState should be Managed:

    oc get configs.imageregistry/cluster -o jsonpath='{.spec.managementState}{"\n"}'
  2. 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.

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

  1. Create or pick a project (namespace) to hold the image, for example nvidia-gpu-operator or a dedicated gpu-images project.

  2. Log in to the registry from your workstation (using the external route host Red Hat documents for your version), with an OpenShift user token:

    REGISTRY=$(oc get route default-route -n openshift-image-registry -o jsonpath='{.spec.host}')
    podman login -u "$(oc whoami)" -p "$(oc whoami -t)" "$REGISTRY"

    Use docker instead of podman if that is what you use.

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

Wiring operators (example: NVIDIA ClusterPolicy / vgpuManager)

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 explain clusterpolicy.spec.vgpuManager --recursive

Conceptually you want the operands to pull something equivalent to:

image-registry.openshift-image-registry.svc:5000/<project>/<image>:<tag>

Verification

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

See also: NVIDIA vGPU Manager: build, integrated registry, and ClusterPolicy — end-to-end flow when hosting the vGPU Manager image on the integrated registry.

VSCode Setup for Enterprise Development

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)

  1. Log in to your GitHub account.
  2. Go to Settings > Developer settings > Personal access tokens > Fine-grained tokens (or Tokens classic).
  3. Click Generate new token.
  4. Give it a descriptive name (e.g., “VSCode Enterprise Desktop”).
  5. Select the required scopes (typically repo for full control of private repositories).
  6. 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.


To get the best experience, install the following extensions from the VSCode Marketplace.

Core Languages & Tools

TechnologyExtension NamePublisherDescription
PythonPythonMicrosoftRich support for Python (IntelliSense, linting, debugging).
YAMLYAMLRed HatYAML Language Support with built-in Kubernetes/OpenShift syntax.
AnsibleAnsibleRed HatLanguage support, linting, and integration for Ansible playbooks.
BashBash IDEmads-hartmannBash language server for better autocomplete and linting.
OpenShiftOpenShift ConnectorRed HatInteract with OpenShift clusters directly from VSCode.

Version Control & Collaboration

Extension NamePublisherDescription
GitLensGitKrakenSupercharge Git capabilities, view line history, and repository heatmaps.
GitHub PRs & IssuesGitHubCreate, review, and manage GitHub Pull Requests and Issues within VSCode.
Git GraphmhutchieView 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:

  1. Click the Extensions icon in the Activity Bar on the side of VSCode (or press Ctrl + Shift + X).
  2. Type the name of the extension in the search bar.
  3. Click Install.

Add these to your user settings.json (Ctrl + Shift + P > “Open User Settings (JSON)”) for a smoother experience:

{
  "editor.formatOnSave": true,
  "editor.renderWhitespace": "all",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true,
  "git.autofetch": true,
  "git.confirmSync": false,
  "workbench.editor.restoreViewState": true,
  "yaml.format.enable": true,
  "ansible.validation.lint.enabled": true
}

Setting Up Your Workspace

1. Pulling Repositories

Use the integrated terminal (Ctrl + \``) or the Command Palette (Ctrl + Shift + P`) to clone your repositories.

git clone https://github.com/your-org/your-repo.git

2. Creating a Workspace

If you work across multiple repositories (e.g., one for Ansible playbooks, one for Python tools), use Multi-root Workspaces:

  1. Open your first repository folder.
  2. Go to File > Add Folder to Workspace…
  3. Select your second repository folder.
  4. Save the workspace: File > Save Workspace As…

3. Version Control Workflow

Basic Git Usage

  • Stage Changes: Click the + icon next to files in the Source Control tab.
  • Commit: Enter a message in the text box and click Commit.
  • Push/Pull: Use the sync icon in the status bar or the ... menu in Source Control.

Creating Pull Requests (PRs)

With the GitHub Pull Requests and Issues extension:

  1. Push your branch to the remote repository.
  2. Click the GitHub icon in the Activity Bar.
  3. Click the + (Create Pull Request) icon.
  4. Follow the prompts to select base/head branches and enter a title/description.

Plugin Configuration Tips

Ansible & YAML

To ensure the YAML extension recognizes OpenShift/Kubernetes schemas:

  1. Open Settings (Ctrl + ,).
  2. Search for yaml.schemas.
  3. Add entries for your Kubernetes/OpenShift manifest patterns.

Python

Ensure you select the correct Python interpreter:

  1. Press Ctrl + Shift + P.
  2. Type Python: Select Interpreter.
  3. Choose the version or virtual environment (.venv) relevant to your project.

  • Dev Containers: Use the Remote - Containers extension to define your development environment in a Dockerfile or docker-compose.yaml within the repo.
  • Snippets: Create custom snippets for common Ansible tasks or OpenShift manifest headers.
  • Tasks: Define VSCode Tasks (tasks.json) to run your playbooks or tests with a single shortcut.

Architecture & System Designs

This section compiles primary system architecture blueprints, integration designs, and infrastructure roadmaps.

Architecture Blueprints

Security & Integrations

  • Tailscale Integration for OpenBao - Security architecture for generating secure, on-demand Tailscale authorization keys for Dockerized containers using HashiCorp Vault / OpenBao.

Deployment Orchestration

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.
Jul 31, 2026

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:

  1. Baselining all clusters onto a single trunk (main) with no active tags or branches.
  2. Cleaning up all unused/stale git branches and tags.
  3. Separating testing environments onto a dedicated long-lived Testing branch.
  4. 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;

Phased Consolidation Plan

Phase 1: Trunk Consolidation (Stabilization Baseline)

Goal: Consolidate all GitOps controllers/ResourceSync configurations to point strictly to the main branch with zero tag/revision overrides.

  1. 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.
  2. Reconcile and Validate:
    • Allow GitOps controllers to reconcile. Confirm that all clusters are running stable configurations served purely from main.
  3. 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.

  1. Branch Creation:
    • Branch Testing directly from the cleaned and verified main branch.
  2. Target Re-pointing:
    • Modify the GitOps sync configurations of your Test Clusters to track the Testing branch instead of main.
  3. 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.

  1. Tag Generation:
    • Tag the stable main branch with the new version tag: v2.0.
  2. Target Re-pointing:
    • Update the GitOps sync configurations for all remaining clusters (e.g., Production, Shared Core Services) to target refs/tags/v2.0.
  3. 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:

  1. 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.
  2. 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.
  3. 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.
Jul 31, 2026

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.

+-----------------------------------------------------------+
| OpenShift Node (RHCOS / RHEL)                             |
|                                                           |
|   +----------------------------------------------------+  |
|   | bigfix Namespace                                   |  |
|   |                                                    |  |
|   |  +--------------------+    +--------------------+  |  |
|   |  | ServiceAccount     |    | Masthead Secret    |  |  |
|   |  | bigfix-agent-sa    |    | (actionsite.afxm)  |  |  |
|   |  +---------+----------+    +---------+----------+  |  |
|   |            |                         |             |  |
|   |            v                         |             |  |
|   |  +---------+----------+              |             |  |
|   |  | SCC RoleBinding    |              |             |  |
|   |  | (privileged)       |              |             |  |
|   |  +---------+----------+              |             |  |
|   |            |                         |             |  |
|   |            v                         v             |  |
|   |  +---------+-------------------------+----------+  |  |
|   |  | hcl-bigfix-agent DaemonSet Pod                |  |  |
|   |  |   - hostPID: true                             |  |  |
|   |  |   - hostNetwork: true                         |  |  |
|   |  |   - securityContext.privileged: true          |  |  |
|   |  +----+------------------------------------+----+  |  |
|   |       |                                    |       |  |
|   +-------|------------------------------------|-------+  |
|           | (Host Volume Mounts)               |          |
|           v                                    v          |
|   +-------+--------------+             +-------+------+   |
|   | Host Root Filesystem |             | State Folder |   |
|   | / (mounted to /host) |             | /var/opt/B...|   |
|   +----------------------+             +--------------+   |
+-----------------------------------------------------------+

To achieve direct host-level inspection, the workload is packaged as a cluster-wide DaemonSet configured with:

  • privileged: true: Grants absolute container permissions.
  • 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:privileged ClusterRole:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: bigfix-agent-privileged-scc
  namespace: hcl-bigfix
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:openshift:scc:privileged
subjects:
  - kind: ServiceAccount
    name: bigfix-agent-sa
    namespace: hcl-bigfix

Configuration Steps

The deployment is self-contained under artifacts/openshift/bigfix-agent-deployment/.

  1. Namespace & ServiceAccount: The namespace hcl-bigfix is isolated with monitoring active.
  2. 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.
  3. 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:
    tolerations:
      - key: node-role.kubernetes.io/master
        operator: Exists
        effect: NoSchedule
      - key: node-role.kubernetes.io/control-plane
        operator: Exists
        effect: NoSchedule

Common Pitfalls

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

References

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:

                                           +---------------------------------+
                                           |     External BFI Server         |
                                           +----------------+----------------+
                                                            | (REST API Query)
                                                            v
+-----------------------------------------------------------+----------------+
| OpenShift Cluster                                                          |
|                                                                            |
|  +------------------------------+        +------------------------------+  |
|  | Namespace: bigfix            |        | Namespace: openshift-nfd     |  |
|  |                              |        |                              |  |
|  |  +-------------------------+ |        |  +-------------------------+ |  |
|  |  | ServiceAccount          | |        |  | NFD Operator            | |  |
|  |  | big-fix-serviceaccount  | |        |  | (Subscription)          | |  |
|  |  +------------+------------+ |        |  +------------+------------+ |  |
|  |               |              |        |               |                  |
|  |               v              |        |               v (CRD Wave 3)     |
|  |  +-------------------------+ |        |  +-------------------------+ |  |
|  |  | API Token Secret        | |        |  | NodeFeatureDiscovery    | |  |
|  |  | bfi-service-account-tok | |        |  | (Instance CR)           | |  |
|  |  +------------+------------+ |        |  +-------------------------+ |  |
|  |               |              |        +------------------------------+  |
|  |               v              |                                          |
|  |  +-------------------------+ |                                          |
|  |  | NodeFeatureRule         | |                                          |
|  |  | ibm.ilmt.capacity-sock  | |                                          |
|  |  +-------------------------+ |                                          |
|  +------------------------------+                                          |
+----------------------------------------------------------------------------+
  1. 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).
  2. 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:

WaveComponentAction
2components/openshift-nfd/operatorInstalls the NFD namespace, OLM OperatorGroup, and Subscription to register the CRDs.
3components/openshift-nfd/instanceInstalls the NodeFeatureDiscovery controller instance custom resource.
4components/hcl-bigfix-discoveryDeploys 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:

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: bfi-cluster-nodes-list
rules:
  - verbs: [ "list" ]
    apiGroups: [ "" ]
    resources: [ "nodes" ]
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: bfi-cluster-virtualmachineinstance-list
rules:
  - verbs: [ "list", "get" ]
    apiGroups: [ "kubevirt.io" ]
    resources: [ "virtualmachineinstances" ]
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: bfi-cluster-namespaces-list
rules:
  - verbs: [ "list", "get" ]
    apiGroups: [ "" ]
    resources: [ "namespaces" ]

Escaping Go Templates in Helm for NFD

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:

      labelsTemplate: |
        {{ "{{" }} range .cpu.topology }}ibm.ilmt.{{ "{{" }} .Name }}={{ "{{" }} .Value }}
        {{ "{{" }} end }}

This forces Helm to output the braces literally, allowing the NFD operator to interpret them at runtime.


Common Pitfalls

  1. 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:
    apiVersion: v1
    kind: Secret
    metadata:
      name: bfi-service-account-token
      namespace: bigfix
      annotations:
        kubernetes.io/service-account.name: big-fix-serviceaccount
    type: kubernetes.io/service-account-token
  2. 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.

References

Tailscale Integration for OpenBao

Plugin repository: https://github.com/SamPlaysKeys/openbao-plugin-secrets-tailscale

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:

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

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

  1. Navigate to the plugin source folder:

    cd wip/openbao-tailscale-integration
  2. Build the Docker builder image:

    docker build -t openbao-tailscale-builder .
  3. 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:

  • openbao-plugin-secrets-tailscale-darwin-arm64 (Apple Silicon Mac)
  • openbao-plugin-secrets-tailscale-darwin-amd64 (Intel Mac)
  • openbao-plugin-secrets-tailscale-linux-arm64 (Linux ARM64/Raspberry Pi)
  • openbao-plugin-secrets-tailscale-linux-amd64 (Linux AMD64)

Step 2: Configure OpenBao for Plugins

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 directory
mkdir -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 loaded
bao 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.

  1. Set your environment variables:

    export BAO_ADDR="http://127.0.0.1:8200"
    export BAO_TOKEN="dev-only-token"
  2. Calculate the SHA256 checksum of your plugin binary:

    # On macOS:
    export PLUGIN_SHA=$(shasum -a 256 /Users/sam/Workspace/openbao-plugins/openbao-plugin-secrets-tailscale | cut -d' ' -f1)
  3. Register the plugin into the OpenBao Catalog:

    bao plugin register \
      -sha256="${PLUGIN_SHA}" \
      secret \
      openbao-plugin-secrets-tailscale

Step 4: Enable the Secrets Engine at docker/tailscale

Mount the newly registered secrets engine plugin:

bao secrets enable \
  -path=docker/tailscale \
  -plugin-name=openbao-plugin-secrets-tailscale \
  plugin

Step 5: Configure the Tailscale Secrets Engine

Write your Tailscale credentials to the plugin’s configuration endpoint:

bao write docker/tailscale/config \
  api_key="example-api-key-here" \
  tailnet="your-tailnet-name"

Verification

To verify that the integration is working and generating dynamic tokens, request a read operation for a specific app name (e.g. nginx):

bao read docker/tailscale/auth-token/nginx

Expected Output:

Key             Value
---             -----
auth_token      tskey-auth-kXXXXXX-XXXXXX
description     OpenBao dynamic token for nginx
expires         2026-06-07T16:30:00Z
key_id          key_XXXXXXXX

Passing Custom Key Parameters

You can customize the characteristics of the generated Tailscale key at request time:

bao read docker/tailscale/auth-token/nginx \
  reusable=false \
  ephemeral=true \
  preauthorized=true \
  tags="tag:docker,tag:prod" \
  expiry_seconds=1800

Requesting Secrets inside Docker Containers

When requesting secrets from within a Docker container, the container needs to resolve the host address 127.0.0.1:8200 to contact OpenBao.

Common Pitfall: Localhost Resolution

Inside a Docker container, 127.0.0.1 refers to the container itself, not the host machine.

  • On macOS, Docker provides the host alias host.docker.internal.
  • On Linux, you should use the default bridge gateway IP (typically 172.17.0.1) or run Docker Compose with extra_hosts to inject the host IP.

Example: Container Bootstrap Script (entrypoint.sh)

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 name
SERVICE_NAME="nginx"

echo "Fetching Tailscale auth token from OpenBao for ${SERVICE_NAME}..."

# Fetch the dynamic auth token using curl
BAO_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 response
TAILSCALE_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 1
fi

echo "Successfully retrieved auth token. Starting Tailscale..."

# Authenticate and bring up Tailscale node in the container
tailscaled --state=mem: &
sleep 2
tailscale 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:latest
    container_name: tailscale_nginx
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    volumes:
      - ./entrypoint.sh:/entrypoint.sh
    entrypoint: ["/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

  1. 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.
  2. 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.
  3. 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
  • Stand up a lab you can reach by console + CLI
  • Complete access onboarding (SSO, repo access, cluster access)
  • 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

  1. You have a working lab environment you can ssh/CLI into.
  2. You can state, without notes, the difference between learning a command and learning a model, and why this path leads with the model.
  3. 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.


Checklist — work through in order

  • Stage 0 (do first): watch Git For Ages 4 And Up and draw the object model
  • Stage 1: Microsoft Learn — GitHub Fundamentals path + GitHub Skills (in-browser); GitLab users add GitLab for Beginners
  • Stage 2: finish learngitbranching.js.org “Introduction Sequence” + “Ramping Up”; read Pro Git Ch.1–3
  • 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).
GitHubGitLabMeaning
Pull Request (PR)Merge Request (MR)Proposed, reviewed change
GitHub ActionsGitLab CICI/CD (GitLab built-in, GitHub opt-in)
OrganizationsGroupsTeam management
gh CLIglab CLITerminal 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 processGit / hosting-platform equivalent
Change ticketPR / MR — the diff is the change; description is the body
Peer review / approvalPR/MR review — named, required approvers via branch protection
Approval recordMerge commit — named, timestamped, traceable
“Who changed this?”git log, git blame, PR history
Emergency / break-glassEmergency PR with post-hoc review; audit trail preserved
Rollbackgit 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.


Stage 1 — Hands-on basics (free, 2–4h)

ResourceWhat it coversLink
Microsoft Learn — GitHub FundamentalsStructured, exam-aligned path: Git basics, GitHub flow, collaboration, PRs, issues, project boardsGitHub Fundamentals (Microsoft Learn)
GitHub Skills (in-browser)Introduction to Git; Introduction to GitHub — automated feedback via Actionsskills.github.com
GitLab for Beginners (in-browser)Same fundamentals in the GitLab UIabout.gitlab.com/learn/gitlab-for-beginners
Microsoft Learn — Introduction to GitModules: Intro to Git (~1h26m), Collaborate with Git (44m), Introduction to GitHublearn.microsoft.com/…/intro-to-git

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.


Stage 2 — Internals & interactive practice (free)

ResourceCoversNotes
learngitbranching.js.orgBranching, merging, rebasing, cherry-pick — visuallyStart with “Introduction Sequence”
Pro Git book (free)Everything; Ch.1–3 fundamentals, Ch.10 internalsDeeper 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)

TopicWhy it matters
Install Git; set user.name / user.emailEvery commit attributed; matches policy/audit
Clone, remote, fetch vs pullGitOps tooling reads from remotes; needs reproducible local copy
Branch / commit / pushFeature branch → PR → merge to main/env branch
PR/MR lifecycleWhere 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 stashShelve WIP when switching branches
git revert vs git resetrevert safe on shared branches; reset rewrites history (local only)
Org basics: permissions, CODEOWNERS, branch protectionYou may not push to main — that is correct and intentional
Optional: gh / glab CLIOpen 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

CertificationAligns withNotes
GitHub FoundationsStage 0–2 + enterprise notesValidates Git + GitHub fundamentals; pair with the Microsoft GitHub Fundamentals learn path as study material. Exam booked via

Verification (scenario-based)

  1. Clone → branch → edit → push → PR: as above, with ticket-style description.
  2. 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).
  3. Read history: from git log, answer who changed deployment.yaml two weeks ago, what, and was it reviewed?
  4. 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.

Topics

  • Images & layers — Dockerfile / Containerfile instructions; layer caching; minimal base images (UBI, distroless).
  • Building & running — build, run, exec, logs, port/publish, volumes, environment.
  • Registries — pull/push, tags vs digests, auth, mirror registries, private/internal registries.
  • Image hygiene — sizing, non-root users, multistage builds, scanning for CVEs.
  • Podman vs Docker — rootless containers; podman is OCI-compliant and often the platform default (no daemon).

Resources

Verification (scenario-based)

  1. Write a Containerfile for a small HTTP app from a minimal base image; build it; run it; curl the exposed port.
  2. Inspect layers (podman history / docker history); explain which instruction added which layer and why caching matters.
  3. Pull an image by tag, then by digest; explain why the digest is reproducible and the tag is not.
  4. 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.1 App layer — Deployments, Services, Routes/Ingress, ConfigMaps/Secrets, RBAC, Operators
  • 3.2 Cluster ops — cluster operators, MachineConfig, nodes, OAuth, SCCs, OLM, observability, backup/DR
  • 3.3 Networking — OVN-Kubernetes, NetworkPolicy, Multus/NAD, MetalLB, NMState
  • 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.
VMware conceptK8s/OCP analogueDiscard when…
vCenter UIOpenShift Console (observe only)Breaks in Module 5 (GitOps)
ESXi hostWorker nodeBreaks in Module 3.1 (nodes are cattle)
Resource pool / reservationRequests/limits, PriorityClasses, schedulingBreaks in Module 3.1
VM / templateDeployment+Pods (containers); VirtualMachine (Module 4)Breaks in 3.1–3.2
Port group / dvSwitchOVN-Kubernetes, NAD/MultusBreaks in networking deep dive
vSAN / datastoreStorageClass, PV/PVC, CSIBreaks in storage deep dive
DRS / HAScheduling, affinity, PodDisruptionBudgetsBreaks in 3.1
Change ticket / CABPR + merge (Module 1)Slowest reframe

3.1 — Kubernetes internals & application layer (1–2 weeks)

Topics

  • Images, registries, Deployments, Services, Routes/Ingress, ConfigMaps, Secrets
  • Namespaces/Projects, RBAC — who can get what
  • Operators — platform extends itself via CRDs (the same mechanism that adds VM support in Module 4)

Official

Verification

  • Given unseen Deployment YAML: identify image, replicas, port, env vars, volume mounts — unaided.
  • Run a pod that fails (bad image tag / missing env). Diagnose with oc get pods, describe, logs, get events — no console.
  • Expose an app with a Route; curl it; explain what the Route did and which operator manages it.

3.2 — Cluster operations (admin lens) (2–4 weeks, parallel with 3.1)

Topics

  • Cluster operators — oc get co is the platform-health dashboard
  • MachineConfig / MachineConfigPool — declarative node config (don’t SSH to fix nodes)
  • Nodes — cordon, drain, conditions (maintenance-mode equivalent)
  • Authentication — OAuth, identity providers; diagnose “can’t log in”
  • Security Context Constraints (SCCs) — pod-level security model, no VMware equivalent; restricted-v2 default is stricter than upstream K8s. Audit with oc adm policy who-can use scc.
  • Operator Lifecycle Manager (OLM) — CatalogSource, Subscription, InstallPlan, CSV. A CSV stuck Installing is the most common failure.
  • Multi-tenancy — namespace isolation (ResourceQuota, LimitRange) vs separate clusters; RBAC for self-service.
  • Observability — Prometheus + Alertmanager (default); PrometheusRule, AlertmanagerConfig; LokiStack logging; oc adm must-gather / oc adm inspect.
  • Backup & DR — etcd backup (control-plane recovery); OADP/Velero for workload PVs. Two models: restore (etcd + node rebuild) vs rebuild (GitOps re-apply) — neither fully replaces the other.

Official

Verification

  • oc get co; find a degraded operator; explain (via describe/events/logs) what’s wrong.
  • (multi-node) Cordon → confirm no eviction; drain → confirm pods moved; uncordon. On SNO: trace the procedure and name constraints.
  • SCCs: deploy a pod requesting hostNetwork: true; find the SCC failure; oc adm policy who-can use scc hostnetwork; explain restricted-v2 vs privileged.
  • OLM: install via Subscription; break the channel; diagnose stuck InstallPlan/CSV; recover without deleting the operator.
  • Observability: write a PrometheusRule firing when >10 pods Pending >5min; confirm in Alertmanager. Explain must-gather vs inspect.
  • Backup/DR: run/trace an etcd backup; compare to a vSphere snapshot — what etcd preserves that GitOps rebuild can’t, and vice versa.

3.3 — Networking deep dive

  • OVN-Kubernetes — default CNI; logical routers/switches managed via APIs, not a topology editor.
  • NetworkPolicy — allow rules, not a firewall. Without any policy, all pod traffic is allowed.
  • Multus CNI — multiple interfaces; primary via OVN, secondary via Multus (bridge/MACVLAN/IPVLAN).
  • NetworkAttachmentDefinition (NAD) — CR defining a secondary network; VMs reference it in Module 4.
  • MetalLB — LoadBalancer services on bare metal.
  • NMState — declarative node networking (NodeNetworkConfigurationPolicy).
  • 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).
  • VolumeSnapshot / VolumeSnapshotClass — CSI point-in-time snapshots (data only, not memory).
  • RWX vs RWO — live migration needs RWX.

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 VirtualMachineInstance is 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)
  • Read the mental model + skim About OpenShift Virtualization
  • Browse the Red Hat OpenShift Virtualization learning hub and kubevirt.io for the upstream project
  • Create/start/stop a VM; find its backing VMI Pod
  • Practice live migration (needs multi-node + RWX) and walk one MTV migration plan
  • Formal course: DO317 — Managing VMs with OpenShift Virtualization (includes EX316 exam)
  • 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
  • Migration Toolkit for Virtualization (MTV) — bulk vSphere import: source inventory, network maps, storage maps, cutover
  • Performance tuning (read-ahead) — CPU pinning/NUMA (dedicatedCpuPlacement), HugePages, SR-IOV, real-time kernel, Node Tuning Operator

Official

Formal courses: DO317 — Managing VMs with OpenShift Virtualization (includes EX316 exam); DO156 Administration I as an alternative entry point.

Verification (scenario-based)

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


Checklist — work through in order

  • Confirm Module 1 (Git) + Module 3 (cluster ops) are done
  • 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 revert replaces 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.

Topics

  • OpenShift GitOps (Argo CD) — Application, AppProject, sync policies, health checks, self-healing
  • ApplicationSet — generate many Applications from one template (by cluster label, directory, branch)
  • App-of-apps — a root Application managing child Applications; full inventory lives in Git
  • Configuration drift — what Argo CD detects; selfHeal vs manual sync
  • Sync options — Validate, CreateNamespace, RespectIgnoreDifferences, retries
  • 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.

Official

Verification (scenario-based)

  1. Create an Argo CD Application from Git. Change replica count/image tag in Git; watch convergence. Roll back via git revert; watch follow.
  2. 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.
  3. Create an ApplicationSet generating one Application per directory; add a directory; confirm auto-creation.
  4. 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.
  5. 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.
  6. Catalog: find NMState/MetalLB/ESO bases in gitops-catalog; create components/nmstate/kustomization.yaml referencing the catalog; point an Application at it; confirm install.
  7. 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 instinctFleet-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 documentConsole 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
  • Placements / PlacementBindings — label-based targeting
  • inform vs enforce — detect/report vs auto-remediate
  • PolicyAutomation — link a compliance event to an AAP job (ServiceNow, PagerDuty, Day-2 tasks)
  • Ansible + ACM bridge — AAP generates CRs via Jinja (Redfish/Vault/DNS); ACM deploys; policies trigger AAP post-provisioning. Idempotent.
  • Secret management — ESO + Vault back end (prereq for Module 8)

Key decision: Argo CD Application vs ACM Policy

SignalArgo CD / operators-installerACM Policy
Who decides it exists?A team owns itPlatform mandate for all of type X
If missing?Workload breaks; team fixesCompliance violation; platform must know
Enforcement required?Reconciliation sufficientDrift detected/auto-corrected
Environment variation?Yes (dev alpha, prod stable)No (same baseline)
Audit needed?Git log + PRACM dashboard + policy report + Git log
Lifecycle stageDay 2 workloadDay 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.

Official

Verification (scenario-based)

  1. 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.
  2. Explain fleet compliance without notes: what “non-compliant” means, who’s notified, monitoring vs remediation policy.
  3. 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)
  • Metrics — Prometheus docs + OCP Monitoring; enable user-workload monitoring, write a PrometheusRule
  • Dashboards & alerting — Grafana docs; wire Alertmanager routes
  • 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).
  • Fleet observability — ACM Observability aggregates hub-level metrics across managed clusters.
  • Backup & DR — etcd backup = control-plane recovery (certs, cluster ID, custom CRs). OADP/Velero backs up workload namespaces, PVs, metadata to object storage; Virt VMs need the KubeVirt Velero plugin.
  • Two recovery models: restore (etcd backup + node rebuild — recovers cluster state) vs rebuild (GitOps re-apply — recovers workloads but not cluster state). Neither replaces the other.

Official

Verification (scenario-based)

  1. Write a PrometheusRule firing when any namespace has >10 pods Pending >5min; confirm in Alertmanager. (Prereq: enable user-workload monitoring.)
  2. Explain what oc adm must-gather collects and when to use it vs oc adm inspect.
  3. 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.
  4. 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
  • RHACM edge computing / ZTP — ZTP at scale (OCP edge computing)
  • Day 1 install — author a SiteConfig (+ AgentClusterInstall, infraenv, NMStateConfig); understand Assisted Installer + ApplicationSet-per-cluster
  • Day 2 config — author a PolicyGenTemplate; orchestrate with TALM via ClusterGroupUpgrade (canary waves)
  • Updating GitOps ZTP — updating the ZTP pipeline
  • Secrets — wire ESO/vault for pull-secrets, BMH creds, certs (never in Git)
  • Complete the scenario-based verification at the bottom

Day 0 / 1 / 2 framework

DayCoversZTP component
Day 0Pre-install: network, bare-metal prep, health checksClusterCurator + AAP hooks
Day 1Install & initial provisioningSiteConfig, AgentClusterInstall, infraenv, Assisted Installer
Day 2Config, upgrades, compliance, scalingPolicyGenTemplate, TALM, ClusterGroupUpgrade

The ZTP pipeline

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.

Official

Verification (scenario-based)

  1. Commit a new SiteConfig directory; trace: ApplicationSet → Application → ACM ManagedCluster → Assisted Installer.
  2. Write a PolicyGenTemplate installing the Virt operator on virt=enabled clusters; apply via TALM ClusterGroupUpgrade; confirm on managed cluster.
  3. Trace a version-bump commit end-to-end: git push → Argo CD → ACM → PolicyGenTemplate → TALM → CGU → ClusterVersion operator.
  4. Walk the ESO flow: where the secret lives, how ESO auths to vault, when the K8s Secret appears.

Module 9 — Certifications & Next Steps

Audience: Engineers planning formal credentials alongside the learning path.

Outcomes: Map each module to relevant public certifications, choose a sensible sequence, and identify deeper specializations.


Checklist — choose your track

  • Pick a vendor-neutral track (portable across employers) and/or a Red Hat track (matches our OCP stack)
  • GitHub Foundations (after Module 1) — GitHub/PSI, $99, 75Q, no prereq
  • Linux base (Red Hat): RH124 → RH134 → RHCSA (EX200)
  • Kubernetes (CNCF, vendor-neutral): KCNA (entry) → CKAD or CKA (66%, $445) → CKS (requires CKA)
  • OpenShift (Red Hat): DO180 → DO280 → EX280; then DO317 (Virt + EX316 included), DO432 → EX432 (fleet)
  • Book one exam with a target date; the rest follow the module sequence below

Certification map

CertificationTrackAfter which module(s)Notes
GitHub FoundationsVendor-neutralModule 1Git + GitHub fundamentals; GitHub/PSI, $99, 75Q, no prereq; study via Microsoft Learn GitHub Fundamentals path
RHCSA (EX200)Red Hat / LinuxModule 0–2 (Linux base)RH124 → RH134 → EX200; the Linux foundation OCP assumes
KCNA — Kubernetes & Cloud Native AssociateCNCF (vendor-neutral)Module 3 (entry)Multiple-choice entry credential; good onboarding milestone
CKAD — Certified Kubernetes Application DeveloperCNCF (vendor-neutral)Module 3 (app layer)Performance-based, 66%, $445; for those deploying apps to K8s
CKA — Certified Kubernetes AdministratorCNCF (vendor-neutral)Module 3 depthPerformance-based, 66%, $445; the benchmark admin cert
CKS — Certified Kubernetes Security SpecialistCNCF (vendor-neutral)after CKARequires a valid CKA; advanced security
EX280 — Red Hat Certified OpenShift AdministratorRed HatModule 3 depthCore cluster admin; 3hr perf-based, 210/300; prep with DO180 → DO280
EX280 — Red Hat Certified OpenShift AdministratorRed HatModule 3 depthCore cluster admin; 3hr perf-based, 210/300; prep with DO180 → DO280
EX316 — Red Hat Certified Specialist in OpenShift VirtualizationRed HatModule 4 depthIncluded in DO317 bundle; covers VM management with KubeVirt
EX432 — Red Hat Certified Specialist in OpenShift Advanced Cluster ManagementRed HatModule 6 depthRHACM, 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 / DO432Red Hat (courses)Modules 2 / 3 / 4 / 6Formal Red Hat courses aligned to each module; DO317 bundles EX316 exam

Suggested sequencing

  1. GitHub Foundations (Module 1) — early, low-cost, universally useful.
  2. RHCSA (EX200) — if Linux is new to you; the base OCP assumes (RH124 → RH134 → EX200).
  3. KCNA → CKA/CKAD after Module 3 — vendor-neutral, portable Kubernetes proof.
  4. EX280 after Module 3 — validates core OCP admin on our stack.
  5. 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.
  6. 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.

#ModuleRequired / Optional
0Prerequisites & How to UseRequired
1Git, GitHub & GitLab Best PracticesRequired
2Containers & RegistriesRequired
3Kubernetes & OpenShift CoreRequired
4OpenShift VirtualizationRequired
5CI/CD & GitOpsRequired
6Fleet Management with ACMRequired
7Observability & Day-2 OperationsRequired
8Zero Touch ProvisioningOptional — specialist track (50+ cluster / edge fleets)
9Certifications & Next StepsRequired

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.

#ModuleRequired / Optional
0Prerequisites & How to UseRequired
1Git, GitHub & GitLab Best PracticesRequired
2Containers & RegistriesRequired
3Kubernetes & OpenShift CoreRequired
4OpenShift VirtualizationRequired
7Observability & Day-2 OperationsRequired
9Certifications & Next StepsRequired

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.

#ModuleShort description
0Prerequisites & How to UseLab setup, verification philosophy, beginner’s mind
1Git, GitHub & GitLab Best PracticesGit object model, daily workflow, GitHub Foundations cert
2Containers & RegistriesImages, layers, tags vs digests, registry auth
3Kubernetes & OpenShift CoreApp layer, cluster ops, networking, storage
4OpenShift VirtualizationKubeVirt VMs, live migration, MTV from vSphere
5CI/CD & GitOpsPipelines, Argo CD, GitOps repo structure, secrets
6Fleet Management with ACMRHACM policies, compliance, ACM + GitOps hybrid
7Observability & Day-2 OperationsPrometheus, Loki, OADP, etcd backup/DR
8Zero Touch ProvisioningSiteConfig, PolicyGenTemplate, TALM — specialist track
9Certifications & Next StepsRHCSA, KCNA, CKA, CKAD, EX280, EX480 map