Public Repository Security
The public repository contains desired configuration, public examples, non-secret version/checksum pins, and tests. Your controller and Ubuntu KVM host remain the trust boundary; GitHub Actions never deploys infrastructure.
Credentials and State
| Material | Storage | Rule |
|---|---|---|
| SSH private key | SSH agent or private key store outside Git | Never generated by Terraform |
| SSH public key | Ignored local tfvars; passed to cloud-init | Public, but keep personal key metadata out of examples |
| K3s, Grafana, and Authelia credentials | Encrypted .local/vault.yml |
Preserve originals for recovery |
| Vault password | Private .local/vault-password, mode 0600 |
Move recovery copy to a password manager; never commit |
| TLS CA and server private keys | Private .local/pki, files mode 0600 |
No automatic Mac trust-store installation |
| Kubeconfig | .local/kubeconfig, mode 0600 |
Cluster-administrator credential |
| Terraform state and saved plans | Ignored local files or a private locked backend | Treat all state and plans as sensitive |
| Idempotence reports | .local/idempotence.json, mode 0600 |
May include operational metadata; never CI artifacts |
| etcd snapshots and VM backups | Encrypted off-host storage | Contain credentials and application data |
sensitive = true is output redaction, not encryption. This repository avoids
Terraform password/private-key resources and Kubernetes/Helm providers so those
credentials never enter its state. Libvirt uses the existing SSH identity rather
than a new API credential. State still reveals network topology and infrastructure metadata and
must be protected.
For one operator, an encrypted workstation disk and encrypted off-host state
backup are the default. For collaboration, use a private remote backend with
encryption, versioning, and locking. Configure backend authentication outside Git
and migrate existing state deliberately with terraform init -migrate-state.
Never run concurrent operators against independent copies of local state.
Ansible secret tasks use no_log, suppress diffs, avoid cached token facts, and
keep token/config files root-only. No plaintext vault file is created by the
generation helper. Decrypting or editing a vault is a separate trusted operator
action; your editor and workstation must also be protected.
Network Policy at the VM Boundary
The guests use a dedicated nftables input table with default drop. It does not flush Kubernetes/CNI tables or add a competing forwarding policy:
| Source | Destination | Allowed |
|---|---|---|
| PN51 NAT gateway | Guests | TCP 22, 80, 443, 6443 for SSH/tunnels and ingress proxy |
| Private node subnet | Nodes | UDP 8472; TCP 80, 443, 6443, 10250 |
| Pod subnet | API and kubelet | TCP 6443, 10250 |
| Guest outbound | Package/image registries, DNS, time, other destinations | Allowed; replies are statefully tracked |
Flannel VXLAN is not encrypted. Keep this LAN/VLAN trusted and do not expose UDP 8472 to the internet. VM firewalls do not isolate pods from one another: application namespaces still need appropriate Kubernetes NetworkPolicies and RBAC. This baseline intentionally does not claim a zero-trust pod network.
No broad NodePort range, host metrics port, RDP, or VNC network service is opened.
K3s includes Traefik and ServiceLB inside the private VMs. The API is accessed
through a loopback-only SSH tunnel; services remain ClusterIP behind ingress.
After guarded cutover, the host proxy accepts only 192.168.1.0/24 on 80/443.
Both proxy ACLs and UFW define this boundary.
Guest UFW is not configured by this workflow. Do not apply an old generic UFW or hardening script over the cluster: forwarding rules and strict reverse-path filtering can break the CNI. Platform namespace policies restrict ingress and egress; monitoring admits only its own workloads, Traefik, and authorized API health probes. Authelia defaults to deny and requires MFA on every dashboard.
Host and Cluster Privileges
The cloud-init ubuntu account has passwordless sudo, because Ansible must
configure the guests without embedding sudo credentials. Treat its SSH key as a
guest-root credential. Root SSH and password SSH are disabled. The PN51 host
uses terminal-authenticated sudo, not passwordless host sudo. Membership in
system libvirt is privileged and is granted only to the existing administrator.
K3s enables Secret encryption at rest and protects kubeconfig/token permissions. Workers join with the running server's CA-pinned secure token, not only a short bootstrap password. Existing token mismatch and stale worker identity checks fail closed. Token rotation and etcd restores are deliberate operations, not automatic convergence.
The monitoring namespace permits privileged pod security because node-exporter needs host inspection. Do not allow untrusted workloads or namespace-admin users there. Keep application namespaces restricted separately. Grafana Secret values are outside Helm release values, but remain Kubernetes Secrets: base64 is not encryption and cluster administrators can read them. Platform pods are restricted, non-root, and use read-only root filesystems. Headlamp has no cluster-admin binding or persistent bearer-token Secret; operators mint short-lived read-only tokens. TLS private keys and vault secrets are reconciled without logging them.
Homepage has no Kubernetes token or Docker socket and accepts only its declared
ingress hostname. Argo CD is isolated in its own restricted namespace and is a
trusted, privileged controller. Its external UI requires Authelia MFA plus Argo
CD login; anonymous access is disabled. The locked default project and the
repository/namespace-scoped homelab project prevent accidental infrastructure
adoption. Do not grant untrusted users project administration or access to
repository credentials. No Application or auto-sync is created by bootstrap.
How to Check Before Publishing
git status --short
git check-ignore pn51/terraform/terraform.tfvars .local/vault.yml .local/kubeconfig
make secrets-scan
Review staged files and diffs before committing. The working-tree secret scan
uses Git's tracked and nonignored file list, so it includes new public source
without scanning private runtime data. A direct gitleaks dir can also flag
legitimate credentials in ignored private directories on an operator's machine.
Never upload raw output or solve that by committing those directories or
globally disabling rules. No path-based scanner exception is added to hide
tracked secrets.
Git ignore rules do not protect a file that was already committed. Remove the file from tracking, rotate the exposed credential immediately, then coordinate history cleanup as needed. Deleting the latest file does not remove it from Git history or forks. The fitness workload declares no Secret data in Git; Ansible owns its private runtime and TLS Secrets, which the application project cannot take over.
Dependency Automation
Keep Dependabot alongside Renovate. The two tools do not overlap in the current setup: homelab Renovate promotes only the two published fitness images, not application packages, GitHub Actions, or container base images.
The application's Dependabot configuration
declares weekly GitHub Actions, frontend npm, and backend/frontend Docker
updates, plus monthly root uv updates. Preserve that coverage; reviewed
application dependency changes pass publishing qualification and scans before
Renovate promotes their resulting images. Homelab has no Dependabot version-update
configuration, so scheduled controller, documentation, Terraform, and action
updates are not provided by this image-only Renovate configuration.
Version-update configuration, advisory alerts, and automatic security-fix PRs are separate settings. Live checks on 2026-11-05 found homelab advisory alerts enabled, fitness-tracker advisory alerts disabled, and Dependabot security-update PRs disabled in both repositories. No setting was removed or silently changed. Enable application advisory alerts and security-fix PRs deliberately as a separate security improvement; a declared version-update file is not proof those security features are active.
Removing Dependabot would only be appropriate after another reviewed tool owns all those ecosystems and their lockfile/base-image checks, without duplicate PRs. This release deliberately does not expand Renovate beyond application image delivery or auto-merge unrelated dependency changes.
Controller Dependency Advisories
The release review separately found existing Dependabot advisories in the
homelab controller/docs uv.lock: critical certifi and high filelock,
pymdown-extensions, and ansible-core findings. These are not a clean security
bill for the controller; review the current
advisory inventory
and validate a scoped dependency upgrade. Do not silently substitute a release
candidate for a pinned controller version or bypass TLS/scanner gates.
The deployed fitness backend/frontend are separate images and passed their required CI vulnerability scans. Controller dependency remediation remains a separate follow-up; this rollout does not claim those alerts are fixed.
How to Rotate Secrets
Rotate SSH keys by adding and testing a new public key before removing the old one; cloud-init is first-boot configuration, so existing guests may need an explicit authorized-keys update or rebuild.
K3s server-token rotation changes datastore bootstrap protection: follow the K3s token procedure, update the encrypted vault and root token files deliberately, and take a new snapshot. Preserve old tokens with snapshots that require them. The role deliberately refuses an unexplained token change on an existing node.
Grafana reads its initial admin password into its persistent database. Rotating only the Kubernetes Secret does not change that database password. Use Grafana's documented account-administration flow, update the vault, and verify login. Never put a new password in a CLI argument that will be logged.
Troubleshooting
Trust failures require correct CAs or verified host fingerprints, not insecure flags. Unexpected secret-scan findings require a local review with redacted output. Restore failures involving tokens require the snapshot's original token, not an attempt to brute-force a fresh credential into the datastore.
See provisioning, operations, and testing.