Imported from nghiant03/homelab (
AGENTS.md). Install upstream withnpx skills add nghiant03/homelab. Copyright stays with the author.
AGENTS.md
Repository purpose
Kubernetes GitOps homelab repo: plain YAML manifests and Flux HelmRelease/HelmRepository CRs grouped into Kustomize roots under apps/ and platforms/. No application source code, Makefile, CI workflow, or scripts.
Flux control flow
platforms/flux-system/gotk-sync.yaml(generated,DO NOT EDIT):GitRepositorypoints atssh://git@gitea-ssh.gitea.svc.cluster.local:22/nghiant03/homelab.gitbranchmain— in-cluster DNS so the GitOps loop has no dependency on external DNS or Tailscale. FluxKustomizationflux-systemapplies path./platformswithprune: true.platforms/kustomization.yamlis the aggregator for that path: it lists every platform directory plusapp-kustomization.yaml.platforms/app-kustomization.yamlis a second FluxKustomization(apps, path./apps,dependsOn: flux-system). Apps ARE deployed by Flux — adding a directory underapps/plus an entry inapps/kustomization.yamlis sufficient.- Both Flux Kustomizations set
decryption.provider: sopswithsecretRef: sops-age— SOPS Secrets are decrypted cluster-side by Flux using thesops-ageSecret influx-system. Commit onlyENC[...]ciphertext.
Layout
platforms/flux-system/— Flux bootstrap.gotk-components.yamlandgotk-sync.yamlare generated; regenerate with Flux tooling, don't hand-edit.platforms/tailscale-operator/— Tailscale operator, CRDs (incrd/sub-root), RBAC, and thetailscaleIngressClassused by other components.platforms/traefik/— shared tailnet entry point (HTTPS apps plus a TCP-22 passthrough for Gitea SSH). Thetailnet-traefikService inkube-systemselects the existing k3s Traefik pods and is exposed by the Tailscale operator astailnet-traefik. AHelmChartConfigcustomizes the existing k3s chart to publish this Service's load-balancer status to app Ingresses and declares thesshentrypoint. No separate Traefik installation or node-IP overrides. Verify rollout via the traefik deployment args (--entryPoints.ssh.address=:22/tcp),tailnet-traefikService ports (22/80/443), and thegitea-sshIngressRouteTCP; the tailnet ACL must allow clients TCP 22 to the proxy tag, not only 80/443.platforms/gitea/— FluxHelmRelease(chartgitea, pinned version) + a Traefik Ingress (gitea.home.arpa,ingressClassName: traefik, TLS via thehomelab-caClusterIssuer; chart ingress block inhelm-release.yamlenables it). Laptop git works over HTTPS (https://gitea.home.arpa) and SSH (git@gitea.home.arpa:22— routed through the shared entry point by the Traefiksshentrypoint and theIngressRouteTCPiningress-tcp.yaml); the chart's built-in SSH listener serves Flux over the in-cluster servicegitea-ssh.gitea.svc.cluster.local:22. Sensitive chart values come from the SOPS-encryptedgitea-valuesSecret viavaluesFrom.platforms/kuberay-operator/,platforms/kubescape-operator/— Helm-based components: each is justnamespace.yaml+helm-repository.yaml+helm-release.yamlwith a pinned chart version. kubescape-operator values raise kubevuln'sephemeral-storagelimit to 30Gi (grype DB + scan tarballs live in emptyDir and evicted the pod at the chart's 10Gi default).platforms/external-dns/— RFC2136 provider against Technitium at100.90.10.86, manages thehome.arpazone from Service/Ingress sources (--domain-filter=home.arpa,--policy=sync). TSIG keys come from the SOPS Secretexternal-dns-secret. The Technitium zone must allow AXFR zone transfers and dynamic updates for theexternal-dnsTSIG key (security policy domain*.home.arpa, record typesANY— external-dns also writes TXT registry records).platforms/cert-manager/— cert-manager v1.21.x (HelmRelease) with CRDs enabled. Uses the OCI HelmRepositoryoci://quay.io/jetstack/charts.bootstrap.yamlcontains a SelfSignedClusterIssuer, a 10-year RSA-4096 rootCertificate(isCA: true,rotationPolicy: Never) stored in Secrethomelab-root-cain thecert-managernamespace, and the productionClusterIssuerhomelab-ca(typeca, points at the root secret). trust-manager (platforms/trust-manager/) reads that same secret into aca-certificates.crtBundle in every namespace.platforms/trust-manager/— Jetstack trust-manager HelmRelease (deploys into thecert-managernamespace; charttrust-manager). One Bundle (homelab-trust, APItrust.cert-manager.io/v1alpha1) merges the root CA secret with the system default CAs and writes aca-certificates.crtConfigMap into every namespace. UsenamespaceSelectorto scope down later.platforms/coredns/— only acoredns-customConfigMap inkube-systemforwardinghome.arpato100.90.10.86; it relies on the cluster CoreDNS importingcoredns-custom, there is no CoreDNS deployment here. Add the new zone here when introducing a public domain later.apps/homepage/— Homepage dashboard. Traefik Ingress onhomepage.home.arpa(cert viahomelab-ca). Central catalog of homelab apps/services: web UIs (Homepage, Gitea, Headlamp, Syncthing) are discovered fromgethomepage.dev/*ingress annotations (seeapps/headlamp/ingress.yaml); keep them when adding ingresses. External/manual entries (Tailscale admin + widget, Technitium DNS, Cloudflare Tunnel), the group layout, and the visual theme (flat dark + amber accent incustom.css; background is set by overriding homepage's--bg-colorvariable —body/htmlrules never show because#__nextpaints on top) live inconfig-map.yaml(services.yaml/settings.yaml); discovered groups must match the layout group names (Apps,Platform,Kubernetes,Network). Groups are split into tabs (Overview: Apps/Network/Docs,Cluster: Platform/Kubernetes) via thetabfield in thelayoutblock. Every discovered ingress also carriesgethomepage.dev/showStats: "true"; platform components without a UI are listed manually in thePlatformgroup withnamespace/podSelector/showStatsfor live status and CPU/mem.apps/headlamp/— Headlamp runs inkube-system(deliberately nonamespace.yaml); Flux/kubescape plugins are installed via initContainers into anemptyDir.apps/syncthing/— Syncthing always-on node used as the sync hub for offline password databases (KeePassXC.kdbxfiles) on tailnet devices. GUI is served atsyncthing.home.arpa(Traefik +homelab-ca); the sync protocol (TCP/QUIC 22000) is exposed through a separatesyncthing-tailnetLoadBalancerService (loadBalancerClass: tailscale) published assyncthing-sync.home.arpa— the two hostnames must stay distinct because they resolve to different tailnet proxies. Devices must be configured with the hub's device ID and addresstcp://syncthing-sync.home.arpa:22000; set a GUI username/password on first login.
Each immediate app/platform directory is a standalone Kustomize root with its own kustomization.yaml. New manifest files must be added to the directory's resources list or Kustomize will not render them.
Commands
No build/test/lint tooling exists. Validate individual roots with:
kustomize build platforms # whole platform tree
kustomize build apps # all apps
kustomize build platforms/gitea # single component
kubectl kustomize <dir> works equivalently. Helm-based components render fine this way because HelmRelease/HelmRepository are plain CRs — this does NOT validate the chart values against the actual chart.
Secrets and SOPS
.sops.yaml: all *.yaml/*.yml match; only data/stringData fields are encrypted (encrypted_regex: "^(data|stringData)"); single age recipient.
Encrypted Secrets currently committed: platforms/tailscale-operator/secret-operator-key.yaml, platforms/external-dns/secret-key.yaml, platforms/gitea/secret-values.yaml (whole values.yaml chart values blob), apps/homepage/secret-key.yaml, apps/headlamp/secret-operator-token.yaml.
Never replace ENC[...] values with plaintext. Edit encrypted files through SOPS (sops <file>), not with a plain editor. Non-data/stringData fields stay readable — keep sensitive values out of them.
Gitea break-glass credentials
platforms/gitea/secret-values.yamlis the SOPS-encryptedgitea-valuesSecret. Its embeddedvalues.yamlcontainsgitea.admin.username,password, andpasswordMode: keepUpdated, loaded by the HelmRelease throughvaluesFrom. The configured password is reapplied when the admin configuration init container runs. It is not continuous password drift detection.- Keep
gitea_adminfor emergency access and use a separate personal account for daily work. Changing the Secret's username can create another administrator; it does not rename or remove the old account. Account rotation does not require deleting PostgreSQL or repository PVCs. - Store an emergency copy of the credentials in a password manager accessible without this cluster or Gitea. Back up the SOPS age private key and an encrypted copy of this repository outside the cluster as well; a cluster-only copy is not a break-glass recovery path.
- Rotate through SOPS, reconcile the Secret, and ensure the Gitea admin configuration init container runs again (a Secret-only update does not necessarily restart the pod). Verify login before updating the emergency copy. A successful Helm upgrade alone does not confirm that the new credentials are active.
Conventions
Gitea database credentials
-
The same SOPS-encrypted
platforms/gitea/secret-values.yamlstorespostgresql.global.postgresql.auth: database/usernamegitea, an applicationpassword, and an independentpostgresPasswordfor the maintenance-onlypostgressuperuser. Do not use the superuser as Gitea's application login or reuse the web-admin password. -
Gitea chart 12.5.3 derives its database
NAME,USER, andPASSWDfrom these values. The PostgreSQL subchart also uses them for itsgitea-postgresqlSecret; do not introduce a separategitea.config.database.PASSWDsource of truth. -
Updating Helm values or a Secret does not by itself rotate passwords in an initialized PostgreSQL database. Change the existing roles, verify TCP authentication, synchronize Secrets, and then resume reconciliation. Keep all PVCs and database ownership intact.
-
Resource names match the directory/component name; selectors use
app.kubernetes.io/name: <name>(Tailscale operator follows upstreamapp: operatorinstead). -
Components get their own namespace in the same root — except
headlamp,coredns, and the sharedtraefikconfiguration (all inkube-system). -
Images are pinned to explicit tags, except Tailscale operator (
stable) and Headlamp (latest). -
Indentation is inconsistent across files (2-space vs 4-space in operator/SOPS files). Preserve local style when editing; don't reformat wholesale.
Ingress and exposure
Tailnet is treated as LAN — every client uses Technitium (100.90.10.86, on the tailnet) for DNS and reaches the cluster by IP. So there is no separate "LAN vs tailnet" tier; everything that is reachable from tailnet devices is reachable as far as this repo is concerned. No *.ts.net URLs in user-facing paths.
- All app Ingresses use
ingressClassName: traefik, with hostnames likeapp.home.arpa. Tailnet clients reach the shared Tailscale-managedkube-system/tailnet-traefikService, which forwards TCP 80/443 to Traefik. The k3s chart'sproviders.kubernetesIngress.publishedService.pathOverridepoints to that Service, so its operator-assigned address propagates to every Ingress and external-dns (RFC2136 → Technitium). Do not add per-app node-IP target annotations or per-app Tailscale proxies for HTTP services. - external-dns v0.22.0 collects both IP and hostname fields from Ingress status; its record-type conflict resolver prefers A/AAAA over a competing CNAME and can delete an owned obsolete CNAME under
--policy=sync. Verify actual DNS records during cutover; the shared entry point must publish an IP, and an old Service must not keep claiming the app hostname. No MagicDNS lookup is needed for IP records. - The shared entry point is node-independent, but its standalone Tailscale proxy and the current Traefik deployment are not highly available. Multi-node HA requires supported ingress ProxyGroups plus spread Traefik replicas; app availability also depends on storage (Gitea currently uses node-local
local-pathPVCs). - TLS for
*.home.arpais signed by the internal root CA via thehomelab-caClusterIssuer: add the annotationcert-manager.io/cluster-issuer: homelab-caand aspec.tls[].secretNamematching the leaf (cert-manager populates it). Traefik hot-reloads the Secret on renewal so no pod restart is needed. - Raw-TCP workloads that can't be done via HTTP Ingress use
type: LoadBalancer+loadBalancerClass: tailscalewithtailscale.com/hostname+external-dns.kubernetes.io/hostnameannotations. The Tailscale operator's support for HTTP Ingress is only for*.ts.netnames — keep that out of this model. Gitea SSH (port 22) is the exception: it rides the sharedtailnet-traefikentry point via the Traefiksshentrypoint +IngressRouteTCP(platforms/gitea/ingress-tcp.yaml), sogitea.home.arpaserves both 443 and 22. - Note the prefix: external-dns v0.22+ uses
external-dns.kubernetes.io/; the legacyexternal-dns.alpha.kubernetes.io/annotations are ignored. - Cleanup (Part A → end): once nothing references ts.net names, disable MagicDNS in the Tailscale admin console (DNS page). Global nameservers (
100.90.10.86) and "Override DNS servers" stay on — they are independent of MagicDNS and keep Technitium resolution working. Then delete Technitium's conditional forwarder zonetail36f6a3.ts.net→100.100.100.100; it's only needed for resolving external-dns CNAMEs to Tailscale proxyts.netnames.*.home.arpais unaffected.
CA trust (internal root)
The internal root CA (homelab-root-ca) is generated by cert-manager in-cluster. Devices (Linux/macOS/Windows) must import it once to trust *.home.arpa:
kubectl -n cert-manager get secret homelab-root-ca \
-o jsonpath='{.data.tls\.crt}' | base64 -d > homelab-root-ca.crt
- Linux (Debian/Ubuntu):
sudo cp homelab-root-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates. Firefox on Linux ignores the OS store by default — setsecurity.enterprise_roots.enabled=trueinabout:configor import withcertutilinto NSS. - macOS:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain homelab-root-ca.crt(covers Safari/Chrome/Edge/curl). - Windows (admin PowerShell):
Import-Certificate -FilePath homelab-root-ca.crt -CertStoreLocation Cert:\LocalMachine\Root.
The root key lives only in the cluster Secret cert-manager/homelab-root-ca. Back it up (e.g. SOPS-encrypt an export and git it under platforms/cert-manager/); losing the cluster loses the trust anchor and forces re-import on every device.
Gotchas
apps/whoamiwas removed; don't resurrect references to it.gotk-sync.yamlholds the Git source of truth — manual edits can break reconciliation or be overwritten byflux bootstrap.platforms/flux-system/gotk-components.yamlis large generated YAML; avoid broad search/replace there.- Homepage needs its ServiceAccount + ClusterRole/ClusterRoleBinding (
apps/homepage/rbac.yaml) for Kubernetes widgets. - Removing a component means deleting its directory AND its entry in the parent
kustomization.yaml; Fluxprune: truewill then delete it from the cluster. - cert-manager CRDs install via
crds.enabled: trueon the HelmRelease. The first Flux apply ofplatforms/cert-manager/bootstrap.yamlraces CRD installation and will transiently error on theCertificate/ClusterIssuerobjects — Flux retries and self-heals. Cert readiness for Traefik/ingress shims: leafCertificatedefault is 90 days; the root is pinned to 10 years withrotationPolicy: Neverso device trust survives. - Flux→Gitea SSH uses the chart's in-cluster
gitea-ssh.gitea.svc.cluster.local:22. Theflux-systemsecret'sknown_hostsmust contain an entry for that host pointing at Gitea's generated SSH host key:kubectl get secret -n gitea gitea-ssh-host-keys -o jsonpath='{.data}(orssh-keyscan -p 22 gitea-ssh.gitea.svc.cluster.localfrom a debug pod), thenkubectl edit secret flux-system -n flux-systemto add the line.
Adding a new component
- Create a directory under
apps/orplatforms/with a localkustomization.yamllisting every file. - Add a
namespace.yamlif it runs in its own namespace. - Register the directory in
apps/kustomization.yamlorplatforms/kustomization.yaml— this is what makes Flux deploy it. - Encrypt any Secret with SOPS before committing.
- For Helm charts, follow the
kubescape-operatorpattern:helm-repository.yaml+helm-release.yamlwith pinned chart version; put sensitive values in a SOPS Secret referenced viavaluesFrom. - Validate with
kustomize build <dir>before committing.
