Introduction

My homelab cluster repo contains a file called talosSecrets.yaml. Whoever holds it owns the cluster: every certificate, every encryption key, every bootstrap token. It sits in the same git repository as everything else - pushed to a remote, cloned onto multiple machines.

And that’s fine, because what’s actually committed looks like this:

secrets:
    bootstraptoken: ENC[AES256_GCM,data:mVXKm7Ug...,tag:PzkiEEw...,type:str]
    secretboxencryptionkey: ENC[AES256_GCM,data:m2h5Tv2i...,type:str]

This post is about the two tools that make that possible - SOPS and age - how they work under the hood (with diagrams), what the alternatives are, and the mistakes I made so you don’t have to.

The Problem: Secrets Want to Live in Git

The naive answer is “never put secrets in git” - add them to .gitignore and move on. I tried. It falls apart fast:

  • Disaster recovery. If my laptop dies, an un-versioned talosSecrets.yaml dies with it - and with it, control of the cluster. Secrets need backups, and git is my backup and history mechanism for everything else.
  • Multiple machines. Desktop and laptop both operate the cluster. Un-versioned secrets mean manually shuttling files around and never being sure which copy is current.
  • GitOps needs them. Flux deploys my workloads straight from the repo. Those workloads need Secrets - database passwords, API tokens. If secrets can’t live in git, the “everything comes from git” model breaks exactly where it matters most.

So the real requirement isn’t “keep secrets out of git” - it’s “make the secrets in git worthless to anyone who isn’t me”.

Meet the Tools

Two tools, one job each.

age: the encryption

age (pronounced like the Italian “ah-jeh”) is a modern file encryption tool - the “do one thing well” successor to GPG’s encryption use case. If you know SSH keys, you already understand it:

age-keygen -o key.txt
# Public key: age1qksdhxdflmsd65qtsadltz9qh3dhgs3557dqfcl62npzgzdwzpzse00w22

One command gives you a keypair. The public key (above - safe to share, it’s meant to be shared) lets anyone encrypt data for you. The private key (in key.txt - guard it) is the only thing that can decrypt it. No key servers, no web of trust, no expiry ceremonies, no 400-page manual. The whole private key is one line of text.

SOPS: the smart part

SOPS (“Secrets OPerationS”, originally by Mozilla) is what makes encrypted files livable. The naive approach - encrypt the whole file - produces an opaque blob: no diffs, no code review, no idea what changed between commits.

SOPS instead understands YAML and JSON structure, and encrypts only the values, not the keys:

# Before: flux/apps/immich/secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: immich-db
stringData:
  password: hunter2

# After sops -e:
apiVersion: v1
kind: Secret
metadata:
  name: immich-db          # structure stays readable
stringData:
  password: ENC[AES256_GCM,data:9k2f...,tag:Xm1...,type:str]
sops:
  age:
    - recipient: age1qksdhxdflmsd65qtsadltz9qh3dhgs3557dqfcl62npzgzdwzpzse00w22
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        ...
  lastmodified: "2026-07-03T09:14:11Z"
  mac: ENC[AES256_GCM,data:...]

A git diff still tells you which secret changed and where - just not what it changed to. Reviews work, blame works, history works.

How It Actually Works: Envelope Encryption

SOPS doesn’t encrypt your values directly with your age key. It uses a two-layer scheme called envelope encryption, and understanding it explains everything else about the tool:

  1. For each file, SOPS generates a random data key.
  2. Every value in the file is encrypted with that data key (AES-256-GCM).
  3. The data key itself is then encrypted once per recipient - with your age public key - and stored in the file’s sops: metadata block.
graph LR
    subgraph Your File
        V1[password: hunter2] --> E1[ENC data:9k2f...]
        V2[api-token: abc123] --> E2[ENC data:7hh1...]
    end

    DK[Random data key] -->|encrypts values| E1
    DK -->|encrypts values| E2

    subgraph sops metadata block
        W1[data key, wrapped for age recipient]
    end

    PUB[age public key] -->|wraps| W1
    DK --> W1

    style DK fill:#ae81ff,stroke:#272822,color:#fff
    style PUB fill:#ae81ff,stroke:#272822,color:#fff
  

Decryption runs in reverse: your age private key unwraps the data key from the metadata, and the data key decrypts the values.

Why bother with the indirection? Because it makes hard things trivial:

  • Multiple recipients - wrap the same data key for a second key (a teammate, a recovery key, a CI system). One file, several people can decrypt, no re-encrypting values.
  • Backend flexibility - the wrapping layer can be age, GPG, AWS KMS, GCP KMS, Azure Key Vault, or several at once. Homelab decrypts with age; a company can use the same file with KMS.
  • Integrity - the mac field is a checksum over the whole file; tampering with even the plaintext keys breaks decryption loudly.

Telling SOPS What to Encrypt: .sops.yaml

Drop a .sops.yaml at the repo root and sops -e needs no flags - rules match on file path:

creation_rules:
  # Kubernetes Secrets in Flux — encrypt only data/stringData values
  - path_regex: ^flux/.*secret.*\.ya?ml$
    encrypted_regex: '^(data|stringData)$'
    age: "age1qksdhxdflmsd65qtsadltz9qh3dhgs3557dqfcl62npzgzdwzpzse00w22"

  # Talos machine secrets — encrypt anything that looks sensitive
  - path_regex: ^talos/(talosSecrets|rendered)\.yaml$
    encrypted_regex: '^(.*crt|.*key|.*id|.*secret|.*token|secretboxencryptionsecret)$'
    age: "age1qksdhxdflmsd65qtsadltz9qh3dhgs3557dqfcl62npzgzdwzpzse00w22"

  # Terraform variables — one sensitive field
  - path_regex: ^terraform/terraform\.tfvars\.enc$
    encrypted_regex: '^(pm_api_token_secret)$'
    age: "age1qksdhxdflmsd65qtsadltz9qh3dhgs3557dqfcl62npzgzdwzpzse00w22"

encrypted_regex is the precision tool: it matches key names, and only matching keys get their values encrypted. That’s why a Flux Secret stays applyable - apiVersion, kind, metadata remain plaintext (Flux and kubectl need to read them), while everything under data/stringData is locked.

The gotcha that cost me an evening

Rules are matched top to bottom, first match wins - and encrypted_regex assumes the file has YAML keys to match against. My Talos client config (talosconfig) is treated by SOPS as a binary format: internally it becomes a single data field. My broad ^talos/rendered/ rule with its cert/key encrypted_regex matched the file, the regex matched nothing useful, and decryption broke in confusing ways.

The fix is a dedicated rule above the broad one, with no encrypted_regex at all:

  # talosconfig: binary format — no encrypted_regex, encrypt everything
  - path_regex: ^talos/rendered/talosconfig$
    age: "age1qksdhxdflmsd65qtsadltz9qh3dhgs3557dqfcl62npzgzdwzpzse00w22"
  # broader rendered/ rule comes AFTER

Lesson: encrypted_regex is for structured files you control. For anything binary or oddly shaped, encrypt the whole thing and put the specific rule first.

The GitOps Payoff: Flux Decrypts in the Cluster

Encrypting locally is half the story. The elegant part is that the cluster can decrypt on its own - my laptop is not in the deployment path.

One age private key is stored in the cluster as a Kubernetes Secret (sops-age in flux-system), and every Flux Kustomization gets three lines:

decryption:
  provider: sops
  secretRef:
    name: sops-age
graph LR
    DEV[Me: edit + sops -e] -->|git push, encrypted| REPO[Git repo]
    REPO -->|pull| SRC[Flux source-controller]
    SRC --> KC[kustomize-controller]
    KEY[sops-age Secret
age private key] --> KC KC -->|decrypt in memory| API[Kubernetes API] API --> APP[App reads its Secret] style KEY fill:#ae81ff,stroke:#272822,color:#fff style REPO fill:#ae81ff,stroke:#272822,color:#fff

The decrypted values exist only in the controller’s memory and in the resulting in-cluster Secret - never on disk in the repo, never in CI logs. Registering the key is a one-time step (just bootstrap::flux in my repo pipes the local key into a kubectl create secret).

Day to Day

In practice I never type sops commands. The just recipes wrap the workflow:

just secrets::decrypt   # start of a working session
# ...edit talos patches, regenerate configs...
just secrets::encrypt   # before committing
just secrets::status    # paranoia check: what's currently encrypted?

And the long-running recipes (like the rolling upgrade) decrypt at the start and re-encrypt in a trap - so even a crashed upgrade never leaves plaintext secrets lying around to be accidentally committed.

For a quick single edit there’s a nicer primitive: sops talos/talosSecrets.yaml opens the decrypted file in $EDITOR and re-encrypts on save. No decrypted state ever touches the working tree.

The Alternatives

ToolModelWhere it shinesWhy I passed
Sealed SecretsEncrypt for one cluster; controller decryptsKubernetes-only shopsSecrets can’t be read back or reused; key lives in the cluster, so cluster loss = secrets loss; useless for my Talos/Terraform files
External Secrets Operator + VaultSecrets live in an external manager; operator syncs themTeams, rotation, audit trailsVault is a stateful, HA-needing service to babysit - heavier than my entire secrets problem
git-cryptTransparent whole-file GPG encryptionDrop-in for existing reposWhole-file only (no diffs), GPG keyring pain, project is barely maintained
Ansible VaultEncrypted vars filesAlready-Ansible shopsEcosystem-bound; my repo isn’t Ansible
age aloneWhole-file encryptionBlobs, backups, one-off filesLoses structure: no partial encryption, no useful diffs, no GitOps integration

The pattern: most alternatives couple your secrets to one consumer (a cluster, a config-management tool). SOPS files are just files - the same mechanism covers Kubernetes Secrets, Talos machine configs, and a Terraform token, which is exactly the mixed bag a homelab repo is.

Suggestions From the Trenches

Things I’d tell past-me:

  1. Back up the private key like it’s the cluster - because it is. Mine lives in the default sops location on each machine and in a password manager. A printed copy in a drawer is not paranoid. If this one line of text is lost, every encrypted file in git history becomes noise.
  2. Add a second recipient early. A recovery key (generated offline, stored cold) costs one line in .sops.yaml. Adding recipients later means running sops updatekeys on every file - fine at 5 files, tedious at 50.
  3. Guard against committing decrypted files. My in-place decrypt/encrypt workflow means plaintext does exist in the working tree during a session. Three layers catch mistakes: just secrets::status before committing, a TruffleHog scan in the deploy pipeline, and - the one I’d add next - a pre-commit hook that greps staged YAML for a missing sops: block.
  4. Don’t over-encrypt Kubernetes manifests. If encrypted_regex swallows kind or metadata, Flux can’t even tell what the resource is. ^(data|stringData)$ is almost always what you want for Secrets.
  5. Rotate calmly. sops rotate -i file.yaml generates a fresh data key; sops updatekeys re-wraps for a changed recipient list. Neither touches your values. Knowing these two commands exist removes most key-management anxiety.
  6. Keep the public key in the repo README. It’s public by design - and it means any machine (or teammate) can encrypt new secrets without access to anything sensitive.

Key Takeaways

ConceptWhat I Learned
ageModern keypair encryption: one command, one-line keys, no GPG ceremony
SOPSEncrypts values, not keys - files stay diffable and reviewable
Envelope encryptionRandom data key per file, wrapped per recipient - multi-key and multi-backend for free
encrypted_regexKey-name precision; keep kind/metadata readable for Flux
Rule orderFirst match wins - specific rules (binary files!) above broad ones
Flux decryptionCluster holds one age key; laptop is not in the deploy path
The real riskLosing the private key, or committing a decrypted file - guard both

Conclusion

SOPS + age turned “never commit secrets” - a rule that fights git - into “never commit plaintext secrets” - a rule that git can actually help enforce. The repo is a complete, self-contained description of my homelab, secrets included, and cloning it onto a new machine plus one age key is a full disaster recovery.

It’s also a rare piece of infrastructure that scales down gracefully: no server, no operator, no subscription - two binaries and a text file of rules. The same setup handling my three-file homelab would handle a team; I just get to skip the meetings.

This wraps the tooling trilogy around my cluster repo: Talos runs the nodes, just names the workflows, and SOPS keeps the whole thing safe to push.


Useful Resources: