Private infrastructure

A self-hosted Kubernetes platform, reproducible from code

Four nodes running K3s and a TrueNAS SCALE storage host that is part of the same layout, fronted by a UniFi edge. The whole platform is rebuilt from a bootstrap script that seeds secrets and installs Argo CD, which then installs everything else from git; adding a file to one directory is how a component is deployed. Two manual steps remain, both inherent: the one-time initialisation of the secrets store and seeding the secret values. The numbers below are a snapshot from September 2026.

5 nodes 20 cores, 80 GB RAM, incl. storage host
92 pods 144 containers, 25 namespaces
32 Argo CD applications all auto-synced from main
60 Deployments plus 39 CronJobs, 8 DaemonSets
36 NetworkPolicies default deny where it matters
96 container images every one pinned by digest
Edge Compute: K3s, 4 nodes Storage: TrueNAS SCALE, 6 threads, 32 GB Internet LAN clients Cloudflare proxy WAF and rate limiting, opt-in per host UniFi edge router firewall, NAT, WireGuard DNS-only hosts :443 Traefik ingress single entry point, MetalLB VIP CrowdSec + Authelia WAF, bans, forwardAuth Application workloads one Helm chart, 16 services Platform components 19 Helm releases ZFS pools 1.5 TB NVMe for configs and volumes, 32 TB HDD for bulk data also hosts one K3s node as an LXC

The recursive edge is the thing to hold on to: the storage host also runs one of the compute nodes, as a privileged LXC sharing the host kernel. That single fact explains the storage placement rules, the loopback NFS deadlock described below, and why a NAS reboot is a cluster event.

Delivery

A merge to main is the deploy. Renovate proposes, CI gates, Argo CD applies, and no single credential can both author a change and ship it.

How it works
Change GitLab CI tests, audits, Trivy scan git: main the desired state Argo CD reconcile loop Cluster Container registry images pinned by digest Renovate version bump MRs gates applies proposes merge digest referenced by

GitLab CI runs tests, dependency audits and a Trivy scan for secrets, dependencies and the image before anything reaches the registry. Renovate opens version bump merge requests and automerges routine ones; roughly a dozen high blast radius packages and every major version wait for a human click.

All 32 Argo CD applications auto-sync, so there is exactly one gate per change. Images are pinned by digest, each written in exactly one place. A gate that always passes is not a gate: where a second manual step was removed, it was replaced by a CI check that fails the merge, not by trust.

Three things sit deliberately outside GitOps: node upgrades (they cordon nodes and swap binaries, not containers), the vendored Wazuh manifests, and secret values, which no reconciler can supply. Argo CD replaced a hand written redeploy poller that classified commits by changed path and misidentified more than half of the chart bumps.

Security

Five tools, five boundaries, one pane. Each covers something the others cannot see, all of them forward into Wazuh, and every layer fails in a direction chosen on purpose.

How it works
Request Edge router forwards web traffic only Node firewall bans on all ports, fails open Proxy router one entry point for both kinds Public routes bans and application WAF, fails closed LAN routes address allowlist, enforced by CI Login portal on some routes of each kind Inside the cluster default deny ingress, Pod Security Admission, user namespaces

Prevention is a chain, and every link has a deliberate failure direction. The edge router forwards web traffic and nothing else, and intrusion prevention runs there. Ingress then arrives on a MetalLB address that floats with the Traefik pod and preserves the real client address; it replaced the K3s default load balancer, which masqueraded every request to one internal address, so every client looked the same and there was nothing to ban. A firewall bouncer on every node drops banned sources on all ports ahead of the proxy, and its chains fail open, so a crash costs protection, never access. Inside the proxy the two kinds of route run parallel rather than stacked: a LAN route carries an address allowlist, a public route carries the ban middleware and an application layer WAF that fails closed, and neither carries the other's. The login portal sits in front of some routes of each kind.

Name resolution is not the exposure boundary; the middleware is. A route added without the allowlist would be reachable from outside the moment it exists, whatever DNS says, so a CI check fails any merge that adds one without it. Inside the cluster, the namespaces holding identity, secrets and the SIEM are default deny for ingress, the application namespace has an egress baseline, Pod Security Admission applies per namespace, and workloads that tolerate it run in user namespaces.

CrowdSec ingress logs, application WAF Edge router packets, firewall, IPS Falco syscalls via eBPF ClamAV scheduled signature scans Lynis weekly host audit Storage host syslog through a relay Wazuh HIDS, SIEM, FIM, correlation Firewall bouncer every node, all ports, fails open Telegram high severity only bans

Behind all of that, Wazuh (HIDS and SIEM) correlates. CrowdSec reads the ingress logs, Falco watches syscalls through eBPF, ClamAV scans on a schedule, Lynis audits host posture weekly, and one agent per node does curated realtime file integrity monitoring. The two hosts that cannot run an agent, the NAS and the router, report over syslog; the router can send to exactly one destination, so a relay takes the stream and fans it out to both Loki and the SIEM, holding routine traffic lines below the alerting threshold so that full logging cannot flood it. Trivy is deliberately not wired in, being a build time gate rather than runtime detection. High severity alerts page via Telegram.

One hostile request, end to end: the WAF matches the payload and refuses it, the agent reading the access log crosses a scenario threshold and records a ban, and within seconds every node has pulled that decision and drops the source on all ports, so the next request never reaches the proxy at all. Repeat offences escalate the duration, and the ban itself travels back into the SIEM as an event like any other.

Argo CD is the most privileged workload and is fenced accordingly: a default deny NetworkPolicy pair limits it to DNS, the API server and outbound HTTPS. The chart's own policies had to be disabled to get there, because its shipped server policy allowed ingress from every pod in the cluster, and NetworkPolicies are additive. Found by reading the live object, not the chart.

Observability

Grafana Alloy collects logs, metrics and traces into Loki, Mimir and Tempo on MinIO. Alerting is split between the SIEM and Gatus, and the pager has a check that watches the pager.

How it works
Pod logs Metrics nodes, kube-state, cAdvisor OTLP traces Host syslog storage host and router Grafana Alloy DaemonSet on every node Loki Tempo Mimir MinIO NVMe, hot tier HDD cold archive nightly mirror, disks stay asleep Grafana dashboards and alerts Gatus queries Mimir, pages

All three signals persist to MinIO on NVMe. A nightly job mirrors the day's objects to a compressed dataset on the HDD pool, so the spinning disks stay asleep instead of absorbing continuous writes.

Gatus does not probe ports. Its interesting checks query Mimir: unavailable replicas anywhere in the app namespace, exporters that have stopped producing metrics, and Argo CD applications that can no longer be rendered. That last case is invisible from every other angle, because the pods keep running while the cluster silently stops tracking git.

Readiness probe coverage went from 7 to 19 of 31 containers, which is what gave the replica metric meaning: a container without a probe is Ready the moment it starts. Exporter sidecars deliberately have none, because a pod is only Ready when every container is, and a probe on a sidecar would pull the application out of its Service. Monitoring failure would cause an outage.

SIEM correlated events, high severity Grafana restarts, OOM kills, degradation Gatus checks, queries and heartbeats its delivery log, read from outside One chat every page lands here Deliberately pages nobody deploy notifications, build time scanning, routine traffic lines

Three senders reach one chat and nothing else does: the SIEM for high severity correlations, Grafana for a handful of metric rules (containers that restart repeatedly, containers the OOM killer takes, a security tool that stops reporting), and Gatus for everything better expressed as a question than as a threshold. A check must not depend on the thing it watches, so the data sources have liveness checks of their own, and the one that matters most reads the SIEM's own delivery log through Loki, from a different system, because the only thing that would notice a dead pager is the pager. A second, independent check counts high severity alerts in the SIEM's index, so a silent delivery path shows up twice. Jobs whose only output is an exit status push a heartbeat instead of being polled, which makes a job that stopped running as visible as one that failed.

What deliberately pages nobody is as considered as what does. Argo CD's own notifications are off, because a pager is not a chat channel; the image scanner stays a build time gate and never pages; routine firewall and traffic lines are held below the alerting threshold. Each of those is a decision that can be revisited, not an omission.

Identity

One account per person in LLDAP, one group as the single access gate. Removing someone there revokes everything at once.

How it works
Person Landing page registration, approval, resets LLDAP one group is the gate Authelia portal and OIDC provider provisions read-only bind forwardAuth OIDC direct LDAP bind Browser-only apps Endpaper among them OIDC clients Argo CD, forwardAuth in front Apps with native clients cannot follow a redirect

Authelia provides the login portal and acts as OIDC provider, binding read-only to LDAP. Browser-only apps sit behind forwardAuth. Apps with native clients that cannot follow a login redirect bind to LDAP directly. Argo CD uses both: forwardAuth keeps anonymous traffic off it entirely, and OIDC tells its own RBAC who you are.

LDAP rather than an OIDC-first design because it is the common denominator across the services that matter here. Apps behind forwardAuth are ClusterIP only: a NodePort would SNAT traffic into the pod CIDR that the identity header trusts, making it forgeable from any host on the LAN.

Secrets

A secrets store the cluster reads for itself. One path, one policy and one authentication role per namespace, so a namespace can only ever read its own.

How it works
Secrets store one policy per namespace Secrets operator logs in as the namespace Kubernetes Secret refreshed on a timer Workload restart only if it changed Kubernetes API issues the identity token short-lived token validates it

Values live in OpenBao, and the cluster reads them for itself through the Vault Secrets Operator. Each namespace has its own path prefix, its own read policy and its own authentication role, so a policy can only ever expose that one namespace's secrets. The operator holds no standing credential: it asks the API server for a short-lived token belonging to the namespace's own service account, the store validates that token back against the API server before trusting it, and what comes back carries nothing but that namespace's policy, for an hour.

From there the operator writes an ordinary Kubernetes Secret, owned by the object that asked for it, and re-reads on a timer. Consumers are restarted only when the value actually changed, compared by hash rather than by timestamp, so a rotation reaches every workload that needs it while a refresh that changed nothing disturbs no one.

Storage

ZFS on the NAS, three pools tuned by record size, three paths into pods. Databases stay off NFS by design.

How it works
ZFS pools Paths into pods What uses them NVMe pool, 500 GB small record sizes NVMe pool, 1 TB dynamic volumes HDD pool, 4 × 8 TB 1M record size hostPath bind-mounted into the LXC democratic-csi NFS StorageClass Static NFS volumes shared, retained Service configs and every database Provisioned volumes observability, Wazuh, Endpaper Bulk data Snapshots and cold archive

An NVMe pool with small record sizes holds service configs and databases, bind-mounted as hostPath into the LXC node. A second NVMe pool backs democratic-csi for dynamically provisioned volumes (observability, Wazuh, Endpaper). The HDD pool serves bulk data over static NFS volumes and holds the cold archive and snapshot replication.

SQLite and MariaDB locking over NFS is unsafe, so anything database-bearing uses hostPath. iSCSI is impossible here: the LXC worker's network namespace blocks the kernel initiator, which is also why Longhorn was abandoned. Backups are ZFS snapshots replicated to a second pool, on-box only.

  1. 01

    The loopback NFS deadlock

    The LXC's NFS client and the host's NFS server share one kernel, one page cache and one reclaim path. Under a burst of concurrent writers an nfsd thread entered direct reclaim, landed on dirty pages of an NFS client mount, and waited on an RPC to a server whose every thread was in the same wait. Every export was down for hours and needed a power cut. The media path now bypasses the NFS client entirely via hostPath, which is a reduction, not a cure, and documented as such.

  2. 02

    Ready for 39 hours through a storage outage

    A pod stayed healthy through a total storage outage because its probe ran a real query through an already open SQLite file descriptor served from page cache, while fresh opens on the same mount hung forever. A probe that reuses a long-lived handle measures the process, not its storage. Probes now perform a namespace operation with an internal timeout shorter than the probe timeout.

  3. 03

    The deploy console reachable from any pod

    Argo CD's chart ships a NetworkPolicy for its server that allows ingress from any pod in any namespace. Policies are additive, so a narrower one cannot subtract it, and the console bypassed both the LAN-only allowlist and the login for anything inside the cluster. Found by reading the live object after install; fixed by disabling the chart's policies and writing a default deny pair.

  4. 04

    CI starving etcd

    Build pods once saturated the control plane and restarted k3s 26 times in an afternoon. CI is now pinned hard to a dedicated node, not merely preferred, because a scheduling preference would let it recur under memory pressure, the exact condition that caused it.

  5. 05

    The alert that fired once

    Gatus never re-notifies while an alert stays triggered, so an eight day outage produced exactly one message, on day one. Wazuh rules now provide the escalation Gatus structurally cannot, and every scheduled job whose only output is an exit status has something that reads that status. A suppression rule is replayed against real events rather than verified by reading, because silence cannot distinguish a working filter from a source that went quiet.

  6. 06

    The nightly job nobody was watching

    An egress NetworkPolicy written for one application also selected a nightly job, because the two happened to share a label. The job failed every night for two weeks before anyone noticed: completed pods are garbage collected, no probe covers a job that is not currently running, and its only output was an exit status. What finally surfaced it was Argo CD's per resource health, which remains the only thing watching the outcome of scheduled jobs.

  • The LXC node runs privileged and unconfined, which the nested kubelet needs. A pod escape could reach host root and the bind-mounted datasets. An unprivileged migration with ID mapping is planned and clone-tested.
  • Backups are on-box only. Snapshots replicate to a second pool on the same host; there is no offsite copy yet.
  • A NAS reboot is a cluster event. It takes the LXC node down and, because the CI node hangs off the same bridge, CI with it.