- Secrets never touch git (
/config/,compose.private.yaml,targets/*/compose/.envare gitignored);.secret-style files are 0600 host files. Authelia SSO (forward-auth + basic-auth, 2FA); CrowdSec (srv0: Helm chart with agent/LAPI/AppSec + per-service postoverflow whitelists; vps0: single container, bouncer key auto-registered fromBOUNCER_KEY_TRAEFIK); geoblock allowlist (CA/CN/CU) using the publicget.geojs.ioAPI (fails closed); per-component NetworkPolicies plus baselines in thenamespacescomponent (its kube-system policy explicitly allows 80/443/8000/8443 to Traefik). - Docker socket via
wollomatic/socket-proxy:dockerproxy(read-only, Traefik/monitoring) anddockerproxy_priv(read-write, watchtower; only on pc/bigpc —cap_drop: ALL,read_only: true,mem_limit: 512M, user65534:$DOCKER_GID). - Runtime security — Falco (srv0 + bigpc) consumes kernel events with the modern eBPF driver and evaluates the default syscall rules; events are JSON in Loki and metrics drive Grafana alerts. It runs with capabilities (BPF, SYS_RESOURCE, PERFMON, SYS_PTRACE) plus pod-level
spc_t, reads/proc,/etcand/sys/kernelread-only, and uses the K3s containerd socket; the k8s-metacollector is disabled, so the sensor has no cluster-wide RBAC. Detection only — no Talon/auto-remediation, and no k8saudit plugin yet. - Known tradeoff — K3s
HelmChartvaluesContent(DB passwords, JWKS, OIDC secrets) is readable by anyone withgetonhelmcharts.helm.cattle.io; fine for single-user, but audit before granting namespace access. Where a chart exposes a secret ref it is used: authelia (path:), crowdsec (externalSecret), grafana (admin.existingSecret), headlamp (oidc.externalSecret), immich/nextcloud/plik (secretKeyRefin plain YAML). Where a chart has no secret-ref support but takes plain YAML, the component usesspec.valuesSecrets: values live in a namespaced Secret (e.g.targets/srv0/k3s-apps/templates/frigate.yaml, keyvalues.yaml) listed in the CR, projected as a later-fvalues file so the merge is plain Helm deep-merge and renders identically. Done for frigate'senvpasswords (itsenvkey only accepts plain strings — chart limitation). What can't be avoided: authelia's JWKS PEM has to be embedded asvalue:(the chart otherwise generates a fresh RANDOM key whenCryptographicKeyisn't inline). freshrss stays a root container (the official image hardcodes apache on :80 and its entrypoint runs as root — de-rooting needs a custom apache config); nextcloud stays root too (the official entrypoint writes/etc/apache2as root even withAPACHE_PORTset — uid 33 crashes onremoteip.confremoval; the PVC is already www-data-owned, so this is purely an entrypoint limitation). - SELinux (Fedora nodes) — Kubernetes assigns per-pod MCS categories, and files keep their creator's categories forever. Any pods that share a hostPath tree (file-pipelines/dscpln), plus the backup/restore pods, need pod-level
seLinuxOptions.level: s0— the container-level setting is ignored; anything that writes there (dscpln's git-tracked Excel ledger) also runs asMY_UIDso new files stay imranr-owned and the ledger repo stays committable; dscpln commits every workbook write asD$CPLN <dscpln@localhost>with signing disabled; syncthing adds pod-leveltype: spc_tbecause its trees can carry foreign-labeled files (e.g. moved in from/tmp).privileged: truesidesteps enforcement, but newly written files still get labelled. For its filesystem collector to read/host/proc/1/mountinfo, node-exporter needs pod-levelseLinuxOptions.type: spc_t, root, andCAP_SYS_PTRACE(the ptrace LSM hook rejects container_t; because the denial is dontaudited it surfaces only as EACCES). It runs on the pod network (not hostNetwork) so clustered Alloy can scrape it from either node; theallow-node-exporter-metrics-ingressNetworkPolicy admits alloy-metrics on :9100. Python/Nodeio_uringdenials are audit spam with an epoll fallback — fix withPYTHON_IO_URING=0/UV_USE_IO_URING=0, not SELinux changes.setroubleshootdpegging CPU means a denial backlog; fix the denials, don't mask. - NFS app data — the
nfs-appsexport usesno_root_squash(required for CSI subdir creation and Postgres chowns) and is reachable only from the LAN CIDR; the backup pod and restore pod run as root (PVC_POD_UID=0). The Immich Postgres image is forced onto NFS withIGNORE_DATABASE_FSTYPE=true. These are deliberate full-NFS tradeoffs; seedocs/storage.md. - Static node labels —
hostpath-main/external-exposedare set byk3s-server.sh/k3s-join.sh --hostpath-main; NFD/node-facts are gone.validate.shwarns if srv0 is missing them.
Resolved audit findings and deliberate choices — don't re-flag any of these without reading the referenced reasoning.
- qBittorrent's
adminadminpassword is fine (targets/srv0/k3s-apps/templates/qbittorrent.yaml) — the UI is already gated by forwardauth-authelia + geoblock + crowdsec, so the password is convenience, not a boundary. Never propose changing it. - NetworkPolicies are enforced — K3s embeds the kube-router network-policy controller and enables it by default; nothing here passes
--disable-network-policy(the scripts only setflannel-backend: wireguard-native). Flannel carrying the data path does not disable policy enforcement. Checkkubectl -n kube-system get pods | grep -i routerbefore disputing this. - SSH hardening lives outside this repo — sshd/fail2ban are configured manually per machine; don't suggest
harden-ssh-style tooling. srv0's tunneled SSH is not publicly reachable: it is only reachable at10.99.0.2:22from vps0, so remote logins must pass vps0's pubkey-only sshd as a jump host (its effective config is hardened out-of-band via asshd_config.ddrop-in). - Unpinned supply-chain fetches are deliberate — the dracut clone in
preboot.sh, the "latest" etcdctl download inupdate-node-ip, thereleases/latestsystem-upgrade manifests, the TOFUget.k3s.ioinstaller fetch (TLS only, no local hash), andget.docker.cominprereqs. Pinning each would force manual version bumps; don't propose it. - The
s01-whitelistsubnet regex on vps0 stays (targets/vps0/compose/files/crowdsec/postoverflows/s01-whitelist/internal.yaml) — uptime-kuma needs the exemption, its container IP is not fixed, and nothing better exists. - Authelia intentionally sits outside geoblock — the operator travels beyond the CA/CN/CU allowlist. Crowdsec guards the srv0
authroute; geoblock would lock the operator out. - Public by design: seerr (geoblock only — meant to be shareable, no SSO) and owncast's RTMP ingest on :1935 (protected only by a strong stream key set in the Owncast UI; Traefik can't gate it).
- Hosts that intentionally skip SSO and/or geoblock — on srv0,
gpt(Open WebUI),fmd, andrss(FreshRSS) are public behind geoblock + crowdsec only (they have their own auth, no Authelia);tv(Jellyfin) uses crowdsec +jellyfin-ratelimit+ the app's own auth, no Authelia forward-auth and no geoblock. On vps0,plausible(crowdsec only) andytdl(metube; crowdsec + Autheliaone_factor) skip geoblock. Don't propose adding SSO or geoblock to any of them. - Home Assistant reaches the ConBee II through the generic-device-plugin, not
privileged(targets/srv0/k3s-base/templates/generic-device-plugin.yaml+k3s-apps/templates/homeassistant.yaml). The device cgroup forbids unprivileged opens of/dev/ttyACM0, so HA asks for theinfra.local/conbeeresource. The plugin DaemonSet (squat/generic-device-plugin) scans each node's/dev— grants track the hardware rather than a node label — and publishes stable IDs derived from the device path; a node without the device advertises zero. HA also requires pod-levelseLinuxOptions.type: spc_t(container_t is denied the mounted/run/dbus/system_bus_socket— Bluetooth integration via host bluez — and the device) andcapabilities.add: [NET_ADMIN, NET_RAW](habluetooth talks to the host adapter over direct HCI sockets; with hostNetwork off and auto-discovery unused, these are scoped to the pod netns). The device cgroup grant covers only the ConBee II. Don't propose bringing back privileged. - Jellyfin gets /dev/dri through the generic-device-plugin, not
privileged(targets/srv0/k3s-apps/templates/jellyfin.yaml) — it requestsinfra.local/dri, runs as$MY_UIDundercontainer_t, and needs no spc_t (renderD128 is 0666 and container_t can open it). The device cgroup grant is scoped to the render node. frigate and immich use the same grant (container_t + s0, pinned to srv0 — frigate retains CAP_PERFMON but its dashboard GPU-stats graph fails because SELinux denies container_tperf_event_open). The device plugin itself is unprivileged (spc_t, no capabilities); the Task Runner server runs as root with spc_t and DAC_OVERRIDE/FOWNER/CHOWN plus SETUID/SETGID (it needs the app-data UIDs and the MY_UID-owned hostPath backup repo; user-data tasks drop to MY_UID viarun_as); the removed legacy pvc-backup/restore pods were non-privileged (spc_t + DAC_OVERRIDE/FOWNER); ollama uses theinfra.local/amdgrant (kfd + dri on bigpc) with container_t + s0, and its NetworkPolicy admits only the open-webui and opencode pods on 11434; mosquitto runs as uid 1883. On TCP 2049, nfs-server ingress is allowlisted to the node CIDR (every share is nfsvers=4.2 and CSI mounts originate on nodes); the CIDR is hardcoded as192.168.8.0/24innfs-server.yaml, so change it for a different LAN. - One public edge key covers all public hostnames —
edge-public-tlslists the 24 public names exactly (not a wildcard), but they share one key, so a key compromise has wildcard-like blast radius. The private cert (files/traefik) is separate and never attached to the public-exposure listener. Don't propose per-host certificates without accepting the operational cost this replaced. - nfs-server must stay
privileged(targets/srv0/k3s-base/templates/nfs-server.yaml) — it hosts a kernel NFS server (nfsd/rpc.mountd) inside the container, which privileged is genuinely required for. It's an internal base component; don't suggest de-privileging. - The AmneziaWG pod runs root + NET_ADMIN under spc_t (
targets/srv0/k3s-base/templates/vpn.yaml) — creating/removing the host-netnsra-amneziawg0interface and the iptables NAT/FORWARD rules needs it, and the officialamneziavpn/amneziawg-goimage runs the userspace implementation (no host kernel module,/dev/net/tunmounted) by choice. The DNS pod is unprivileged (UID 65532 + NET_BIND_SERVICE) and binds 10.100.0.1:53 only, so it is reachable solely through the tunnel. Onlyfiles/ra-amneziawg-start.shtouches host state (the keygen init container writes only the PVC, and theconn-notifysidecar only reads the UAPI socket and posts to ntfy). - Falco is a host-level sensor with elevated access (
targets/srv0/k3s-base/templates/falco.yaml) — modern eBPF needs CAP_BPF/PERFMON/SYS_PTRACE/SYS_RESOURCE and SELinuxspc_t(container_t is deniedperf_event_open/BPF, as with node-exporter); it also mounts/proc,/etc,/sys/kernelread-only and/run/k3s/containerdfor container metadata. That access is the point of the sensor; don't propose removing it without replacing the runtime-detection capability. The default rules are noisy on this stack (Task Runner, backup pods, opencode,kubectl exec -it, Home Assistant integration installs); tune with rule-level disables/exceptions rather than disabling whole tags. Event-rate alerts use a 30-minute warning recurrence gate, and the daemonset readiness alert is gated on Ready nodes, so bigpc being powered off does not page. TheContact K8S API Server From Containeroutput is overridden to dropcommand=%proc.cmdline: command lines can carry bearer tokens (logtfy's API polling exposed a service-account token), and Loki retains events for 90 days. - vps0's
one_factorAuthelia rules are intentional for ytdl/ikom/sale-site (family and guests); the admin catch-all is the onlytwo_factorrule (TOTP is Authelia's default second factor, so nodefault_second_factor_policyis needed). - srv0's PROXY-protocol trustedIPs are exactly the hub address (
targets/srv0/k3s-base/templates/traefik.yaml):10.99.0.1/32. vps0's Traefik connects to10.99.0.2:8443over the public-exposure tunnel; the hub container SNATs the proxy network's traffic to10.99.0.1, so the flow carrying the PROXY v2 header has a single stable trusted source and no pod can forge it. - vps0's Cloudflare proxy setup is deliberate —
plausible.imranr.devis proxied with Bot Fight Mode on theimranr.devzone; the zone SSL mode isfull(not strict) because Traefik's TLS-ALPN renewal cannot complete behind the proxy, and Bot Fight Mode cannot be skipped on the Free plan — hence theobtainium_appsstats fetch bypasses Cloudflare (extra_hosts→ host gateway) and the apps source accepts plain HTTP/unverified HTTPS. Third-level hostnames (apps.obtainium.imranr.dev) are not covered by Universal SSL and would need the paid Advanced Certificate Manager to be proxied, so they stay DNS-only. - CrowdSec's
clientTrustedIPsis a bypass-whitelist for clients, not an XFF/proxy knob (the plugin README: "List of client IPs to trust, they will bypass any check from the bouncer or cache"). Proxy trust isforwardedHeadersTrustedIPs:127.0.0.1/32on srv0, and127.0.0.1/32plus the Cloudflare edge ranges on vps0 (required for CF-proxied hostnames); docker-network callers are exempted at the crowdsec layer by thes01-whitelistpostoverflow, notclientTrustedIPs. Don't reintroduce it to "fix" internal traffic. - Agents must never read
config/or anything in it — those secrets are private by design; an audit verifies gitignore coverage and git history, never file contents.
See docs/decisions.md for the accepted duplication list (do not consolidate).