SOPS + Age: Encrypting Secrets in Git
Every secret in my GitOps repo is encrypted and committed to Git. The private key lives only in the cluster. Here's how SOPS + age makes it work.
Every Kubernetes secret in my GitOps repository is encrypted and committed to Git. The private key lives only in the cluster. Here's how SOPS + age makes this work.
The Problem
GitOps means everything is in Git, including secrets. Database passwords, API keys, OAuth client secrets. You can't commit them in plaintext (obviously), but you also can't manage them out-of-band without breaking the "Git is the source of truth" principle.
You need a way to encrypt secrets so they're safe in Git but automatically decrypted when applied to the cluster.
Why SOPS + age
SOPS (Secrets OPerationS) encrypts structured data files: YAML, JSON, ENV. It's format-aware, so it can encrypt specific fields while leaving the rest readable. age is the encryption backend: simple, modern, no key servers or GPG complexity.
The combination gives you:
- Encrypted
dataandstringDatafields in Kubernetes Secrets - Readable metadata (namespace, name, labels) for GitOps tooling
- Simple key management: one age keypair, no PKI
- Native Flux integration: Flux decrypts at apply time
Setup
Generate an age keypair:
age-keygen -o keys.txt
# Public key: age1xxxxx (goes in .sops.yaml)
# Private key file: keep this safeCreate .sops.yaml in your repo root:
creation_rules:
- path_regex: .*\.sops\.yaml$
age: age1xxxxx # your public keyGive Flux the private key:
kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=keys.txtEncrypting a Secret
Create the secret in plaintext first:
# db-credentials.sops.yaml (before encryption)
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: my-app
type: Opaque
stringData:
username: myuser
password: supersecret123Encrypt it:
sops --encrypt --in-place db-credentials.sops.yamlNow stringData values are encrypted but metadata stays readable:
stringData:
username: ENC[AES256_GCM,data:abc123...,type:str]
password: ENC[AES256_GCM,data:xyz789...,type:str]Decrypting for Editing
# View decrypted
sops --decrypt db-credentials.sops.yaml
# Edit in-place (opens in $EDITOR, re-encrypts on save)
sops db-credentials.sops.yamlHow Flux Uses It
In your Flux Kustomization, enable decryption:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
spec:
decryption:
provider: sops
secretRef:
name: sops-ageFlux reads the encrypted files from Git, decrypts them using the age key, and applies the plaintext Secrets to the cluster. The plaintext never touches Git.
Conventions
A few patterns that keep things clean:
- Name encrypted files
*-credentials.sops.yamlor*-secrets.sops.yaml: makes it obvious at a glance - Keep the
.sops.yamlregex tight: only encrypt files that match the pattern - Back up your age private key somewhere safe. If you lose it, you lose access to every secret in the repo
- Never commit a
.sops.yamlfile before encrypting it. Use pre-commit hooks or CI checks
Recovery
If the cluster dies, recovery is straightforward:
- Bootstrap Flux on the new cluster
- Create the
sops-agesecret with your private key - Flux reconciles everything, including decrypting and applying all secrets
Your secrets are version-controlled, encrypted, and recoverable. That's the whole point of GitOps.