Skip to content

Secret management

Secrets never live in Git. They flow:

1Password → ExternalSecret → Kubernetes Secret → consumed by the workload

The pieces

  • A ClusterSecretStore named onepassword-connect reads the Talos 1Password vault.
  • Per-app externalsecret.yaml files reference specific 1Password items by title. Apps that use one should dependsOn onepassword-connect in external-secrets.
  • cluster-secrets: a single 1Password item extracted into a Secret and injected into every app's postBuild.substituteFrom. Holds cluster-wide sensitive values, including ${SECRET_DOMAIN}, ${SECRET_INTERNAL_DOMAIN}, and internal device DNS names.
  • cluster-settings: a git-tracked ConfigMap (components/global-vars/) holding cluster-wide non-sensitive ${...} values (non-secret feature flags and the like; currently empty).

Rules for a public repository

  • Use the ${SECRET_DOMAIN} / ${SECRET_INTERNAL_DOMAIN} placeholders in Git; Flux substitutes the real values at apply time.
  • Any literal ${VAR} you want to survive substitution (Grafana dashboards, envsubst templates, shell snippets) must be escaped as $${VAR}.
  • Device address / lookup tables (e.g. router backup inventories, SNMP/NUT targets) must be templated inside an ExternalSecret's target.template.data block and mounted from the rendered Secret, never rendered into a ConfigMap in Git. Internal device DNS names live in the cluster-secrets 1Password item, not in cluster-settings.
  • A CI guard (.github/scripts/check_internal_identifiers.py, run by the security-scans workflow) fails any pull request that introduces a LAN IP, node name, site-prefixed device hostname (cr-* / sw-*), MAC address, or internal hostname (.lan / .internal) that its allowlist of accepted functional configs doesn't cover. Each allowlist entry is an Allow(path glob, kinds, reason, contains=… | regex=…): it accepts only the named kinds, only on lines containing that literal or matching that regex, which describes the line's shape or YAML key and never the value, and only one identifier per line unless it sets per_line. Any other identifier in the same file still fails. An entry that matches nothing fails the scan with a message to delete it, and a pull request that edits the script is scanned against the whole tree. Device models are intentionally not enumerated in the script to avoid self-disclosure in this public repository.

Talos machine secrets (talsecret)

The talhelper secrets bundle (cluster CA/PKI, etcd certs, bootstrap tokens) follows the same "1Password owns it" rule as everything else. There is no SOPS anywhere in this repository: the historical talos/talsecret.sops.yaml was removed in PR #3463 (2026-07) after the age key for it was lost; the encrypted blob left in Git history is dead ciphertext.

  • The bundle is stored as the talsecret document in the Talos 1Password vault.
  • just talos gen-config fetches it (op document get talsecret --vault Talos) to a temp file, runs talhelper genconfig, and cleans up. Node configs land in the gitignored talos/clusterconfig/.
  • just talos gen-secret creates the 1Password document (from talhelper gensecret) only if it does not already exist.
  • Recovery without 1Password: any previously generated node config contains the full secret material, so the bundle can be reconstructed offline from the gitignored output of a prior run:
talosctl gen secrets \
  --from-controlplane-config talos/clusterconfig/kubernetes-<node>.yaml \
  -o /tmp/talsecret.yaml --force

This was how the bundle was recovered when the age key disappeared: regenerated configs were verified byte-identical before the SOPS file was deleted.