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.

SOPS + Age: Encrypting Secrets in Git

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 data and stringData fields 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 safe

Create .sops.yaml in your repo root:

creation_rules:
  - path_regex: .*\.sops\.yaml$
    age: age1xxxxx  # your public key

Give Flux the private key:

kubectl create secret generic sops-age \
  --namespace=flux-system \
  --from-file=age.agekey=keys.txt

Encrypting 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: supersecret123

Encrypt it:

sops --encrypt --in-place db-credentials.sops.yaml

Now 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.yaml

How 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-age

Flux 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.yaml or *-secrets.sops.yaml: makes it obvious at a glance
  • Keep the .sops.yaml regex 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.yaml file before encrypting it. Use pre-commit hooks or CI checks

Recovery

If the cluster dies, recovery is straightforward:

  1. Bootstrap Flux on the new cluster
  2. Create the sops-age secret with your private key
  3. Flux reconciles everything, including decrypting and applying all secrets

Your secrets are version-controlled, encrypted, and recoverable. That's the whole point of GitOps.