SOPS and Age: Encrypting Secrets in Git Without a Secrets Server

SOPS and Age: Encrypting Secrets in Git Without a Secrets Server

The Problem

GitOps works by treating git as the single source of truth for cluster state. Everything the cluster runs — Deployments, ConfigMaps, Ingresses, RBAC policies — lives in git, and a controller (Flux, Argo CD) continuously reconciles the cluster toward whatever is committed. This model gives you auditable change history, branch-based environment promotion, and rollback by reverting a commit.

Secrets break this model immediately. A Kubernetes Secret containing a database password, an API key, or a TLS private key is just a base64-encoded YAML file. Committing it to git — even a private repository — exposes that secret to everyone with read access, every CI runner that clones the repo, every tool that scrapes repository metadata, and git history forever. Base64 is not encryption.

Three approaches exist for resolving the GitOps secrets dilemma:

Don’t put secrets in git at all. Use an external secrets store — HashiCorp Vault, AWS Secrets Manager, Azure Key Vault — and reference secrets from Kubernetes manifests using an operator like External Secrets Operator. The operator pulls the actual values at reconciliation time and creates Kubernetes Secrets in-cluster. This works well at scale but adds a hard dependency: if the secrets store is unreachable during bootstrap, the cluster cannot start. You now have two systems to operate, monitor, and secure instead of one.

Encrypt secrets and commit the ciphertext. SOPS, Bitnami Sealed Secrets. The ciphertext lives in git. Decryption happens at apply time, either by the controller or by the CI runner. Git remains the single source of truth including secrets, because the plaintext can always be recovered by whoever holds the decryption key. This is the approach this article covers.

Reference secrets from git but store values elsewhere using an operator. External Secrets Operator creates Kubernetes Secrets from external stores using CRDs committed to git — the values themselves never touch git. Covered separately.

SOPS (Secrets OPerationS, originally from Mozilla, now maintained at github.com/getsops/sops) with age is the canonical implementation of approach two for most GitOps workflows.

How SOPS Works

SOPS operates on YAML, JSON, ENV, INI, and binary files. It generates a data encryption key (DEK) per file, encrypts each secret value with AES-256-GCM using that DEK, and then encrypts the DEK itself for each configured recipient using their public key. The file structure — keys, nesting, comments — remains in plaintext. Only the values are encrypted.

The encryption metadata travels with the file in a sops: stanza. This stanza records which recipients can decrypt the file (by public key), the encrypted DEK for each recipient, the file MAC (a message authentication code over all plaintext keys and their encrypted values), and the SOPS version.

An unencrypted Kubernetes Secret:

apiVersion: v1
kind: Secret
metadata:
  name: database-credentials
  namespace: production
stringData:
  password: supersecret123
  username: dbadmin
  host: prod-db.internal.example.com

After sops --encrypt --in-place:

apiVersion: v1
kind: Secret
metadata:
    name: database-credentials
    namespace: production
stringData:
    password: ENC[AES256_GCM,data:8K9mNpQ2rVx=,iv:aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5=,tag:ABCDEF1234567890ABCDEF12=,type:str]
    username: ENC[AES256_GCM,data:5vW7xY8z=,iv:zY4xW3vU2tS1rQ0pO9nM8lK7jI6hG5fE4dC3bA2=,tag:FEDCBA9876543210FEDCBA98=,type:str]
    host: ENC[AES256_GCM,data:3hG5iJ7kL8mN9oP=,iv:1aB2cD3eF4gH5iJ6kL7mN8oP9qR0sT1uV2wX3yZ4=,tag:1234567890ABCDEF12345678=,type:str]
sops:
    kms: []
    gcp_kms: []
    azure_kv: []
    age:
    -   recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
        enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNRU5hRWtXNGlJSmZC
            dkJqUjFIbEY0QXpZQ3BnVGdBbE1rQ1ZCeHhKRlhNCi0tLSBLaHFkZ0JkUzZC
            TzFhbFNjK0Z2aG55cjUwMm5BRHJNYWRSQ1hpYmpTVXkwCqBHJdK...
            -----END AGE ENCRYPTED FILE-----
    lastmodified: '2026-05-09T10:14:22Z'
    mac: ENC[AES256_GCM,data:IntegrityCheckOverAllValues==,iv:...,tag:...,type:str]
    version: 3.9.0

The keys (password, username, host) are visible. The values are not. A git diff between two versions of this file shows which keys changed, even when neither plaintext value is visible — exactly the audit trail GitOps needs.

The age Key Model

age (lowercase, by Filippo Valsorda) is a modern encryption tool designed as a replacement for GPG for file encryption use cases. An age key pair consists of:

  • A public key prefixed with age1 — safe to share, safe to commit to .sops.yaml, safe to paste in documentation
  • A private key prefixed with AGE-SECRET-KEY-1 — must never be committed, never logged, never stored unencrypted outside the systems that need it

SOPS encrypts the DEK for each configured recipient using their age public key. Any holder of a corresponding age private key can decrypt the DEK, and thus the file values. Multiple recipients can be specified — SOPS encrypts the DEK separately for each. This means any single holder can decrypt without the others — a union of authorized decryptors, not a quorum.

For a team GitOps setup: each engineer has a personal age key pair, and a dedicated CI age key pair exists for automation. The .sops.yaml lists all public keys as recipients. A developer can decrypt locally with their private key. CI decrypts with its private key stored as a pipeline secret. If a developer leaves, their public key is removed from .sops.yaml and secrets are re-encrypted — but their private key, which they hold, can no longer decrypt any newly re-encrypted file.

Threat Model

Plaintext secret committed before encryption. SOPS provides no protection until it is applied. If a developer creates a secret manifest and runs git commit before sops --encrypt, the plaintext lives in git history. git filter-repo can rewrite history, but any fork, clone, or CI run that pulled before the rewrite retains the exposed value. The secret must be rotated regardless of how thoroughly history is cleaned.

Age private key committed to the repository. The entire model collapses. Every file encrypted for that recipient is permanently readable by anyone with access to the repository or its history. Rotating the key means re-encrypting every secret file — a full repository secret rotation. Secret scanning (GitHub, GitGuardian, truffleHog) should detect AGE-SECRET-KEY-1 patterns, but detection after commit means the key is already exposed in history.

Age private key exposed in CI logs. If the SOPS_AGE_KEY environment variable is printed — by a set -x shell trace, a debug step, or a misconfigured logging integration — the private key appears in whatever log system stores CI output. GitHub Actions masks secrets.* values in logs, but only if the exact string appears. A key split across multiple log lines, or printed with line breaks, may not be masked.

Missing recipient in .sops.yaml causes secret to be inaccessible to CI. If .sops.yaml is updated to add a new environment path and the CI public key is not in the recipient list for that path, CI will fail to decrypt the secret at apply time with no warning at commit time. The failure surface is wide: new environment overlays, new path patterns, repository restructuring.

Age key not rotated after team member departure. The former employee’s private key decrypts every file encrypted under their public key — including historical versions in git. Re-encrypting all current secrets removes their access going forward, but git history retains all previously encrypted versions, each decryptable with the old key. For secrets that rotate (API keys, database passwords), the previous values in history are less critical. For secrets that do not rotate automatically (TLS private keys, SSH keys), historical exposure is meaningful.

.sops.yaml encrypted_regex is too permissive or too narrow. A regex that matches too broadly encrypts fields like name or namespace, making the manifest unreadable. A regex that is too narrow misses a key called connectionString or PGPASSWORD, leaving it plaintext while the developer believes the file is encrypted. Verifying encryption coverage after each new secret field type is non-optional.

Hardening Configuration

1. Install SOPS and age

# Install age
# macOS:
brew install age

# Linux (from GitHub releases):
curl -Lo age.tar.gz \
  https://github.com/FiloSottile/age/releases/latest/download/age-v1.2.0-linux-amd64.tar.gz
tar -xzf age.tar.gz
sudo mv age/age age/age-keygen /usr/local/bin/

# Install SOPS
# macOS:
brew install sops

# Linux:
curl -Lo sops \
  https://github.com/getsops/sops/releases/latest/download/sops-v3.9.0.linux.amd64
chmod +x sops
sudo mv sops /usr/local/bin/

# Verify both are available:
sops --version   # sops 3.9.0 (latest)
age --version    # v1.2.0

2. Generate age Key Pairs

Generate a key for a team member:

mkdir -p ~/.age
age-keygen -o ~/.age/key.txt

# Output to stdout:
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

# File contents of ~/.age/key.txt:
# # created: 2026-05-09T10:00:00Z
# # public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# AGE-SECRET-KEY-1QJEPF0ZY4W3...

chmod 600 ~/.age/key.txt

Generate a dedicated CI key pair (do this once, store the private key as a CI secret):

age-keygen -o /tmp/ci-age-key.txt
# Public key: age1y8zntu0z4tzgs7jkk5fvh4p4s4hx8fxzpxm5kw9zjnzf3z2ktrs8q9zl0

# Immediately register the private key in your CI system (GitHub Actions, GitLab CI, etc.)
# then delete the local file:
cat /tmp/ci-age-key.txt   # copy AGE-SECRET-KEY-1... value to CI secret store
rm /tmp/ci-age-key.txt

Never store the CI private key on disk in any persistent location. It exists only in your CI system’s secret store, injected as an environment variable at pipeline runtime.

3. Configure .sops.yaml for the Repository

.sops.yaml at repository root tells SOPS which encryption keys to use for which file paths. Path matching uses Go’s regexp syntax against file paths relative to .sops.yaml.

# .sops.yaml
creation_rules:
  # Production secrets: both developers and CI must be able to decrypt
  - path_regex: kubernetes/overlays/production/.*secret.*\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1DEVELOPER2PUBLICKEY...,
      age1y8zntu0z4tzgs7jkk5fvh4p4s4hx8fxzpxm5kw9zjnzf3z2ktrs8q9zl0
    encrypted_regex: '^(data|stringData|password|secret|key|token|credential|connectionString)$'

  # Staging secrets: developers only (CI deploys staging from a different path)
  - path_regex: kubernetes/overlays/staging/.*secret.*\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1DEVELOPER2PUBLICKEY...
    encrypted_regex: '^(data|stringData|password|secret|key|token|credential|connectionString)$'

  # Catch-all for any secrets directory: apply encryption with all keys
  - path_regex: .*secrets/.*\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1DEVELOPER2PUBLICKEY...,
      age1y8zntu0z4tzgs7jkk5fvh4p4s4hx8fxzpxm5kw9zjnzf3z2ktrs8q9zl0
    encrypted_regex: '^(data|stringData|password|secret|key|token|credential|connectionString)$'

The encrypted_regex is a field name filter. Only fields whose keys match this regex have their values encrypted. Everything else — apiVersion, kind, metadata.name, metadata.namespace — stays plaintext, keeping the manifest diff-able and readable.

Verify the regex covers your actual field names before committing secrets. If your application uses connectionString or DATABASE_URL as field names, add them explicitly. A field that does not match stays plaintext silently.

4. Encrypt and Commit Secrets

# Create the plaintext secret manifest
cat > kubernetes/overlays/production/database-secret.yaml << 'EOF'
apiVersion: v1
kind: Secret
metadata:
  name: database-credentials
  namespace: production
stringData:
  password: supersecret123
  username: dbadmin
  host: prod-db.internal.example.com
EOF

# Tell SOPS where your age private key lives (for decryption; not needed for encryption)
export SOPS_AGE_KEY_FILE=~/.age/key.txt

# Encrypt in-place — SOPS reads .sops.yaml to determine recipients
sops --encrypt --in-place kubernetes/overlays/production/database-secret.yaml

# Verify: keys visible, values encrypted, sops stanza present
grep -E "(stringData|password|username|host|^sops)" \
  kubernetes/overlays/production/database-secret.yaml

# Edit the encrypted file (SOPS decrypts to a temp editor buffer, re-encrypts on save):
sops kubernetes/overlays/production/database-secret.yaml

# Commit the encrypted form:
git add kubernetes/overlays/production/database-secret.yaml
git commit -m "Add database credentials for production (SOPS/age encrypted)"

The sops command without --encrypt or --decrypt opens the file in $EDITOR in decrypted form. On save, it re-encrypts and writes the ciphertext back. The plaintext never touches disk as a file — it exists only in the editor buffer.

To decrypt to stdout for inspection:

sops --decrypt kubernetes/overlays/production/database-secret.yaml

To decrypt to a file (use with caution — plaintext on disk):

sops --decrypt kubernetes/overlays/production/database-secret.yaml \
  > /tmp/decrypted-secret.yaml
# Use it, then:
shred -u /tmp/decrypted-secret.yaml

5. Flux CD Integration with SOPS Decryption

Flux v2 has native SOPS support via the decryption field on Kustomization resources. Flux’s kustomize-controller decrypts SOPS-encrypted files before applying them to the cluster, using an age private key stored as a Kubernetes Secret in the flux-system namespace.

First, create the age private key secret in the cluster. This is the only time the CI private key touches a Kubernetes object:

# kubectl — run this once during cluster bootstrap
kubectl create secret generic sops-age \
  --namespace=flux-system \
  --from-literal=age.agekey="$(cat /path/to/ci-age-key.txt)"

# Verify the secret was created and contains the key:
kubectl get secret sops-age -n flux-system -o jsonpath='{.data.age\.agekey}' \
  | base64 -d | head -2
# # created: 2026-05-09T10:00:00Z
# # public key: age1y8zntu0z4tzgs7jkk5fvh4p4s4hx8fxzpxm5kw9zjnzf3z2ktrs8q9zl0

Then configure the Flux Kustomization to use SOPS decryption:

# flux-system/kustomizations/infrastructure.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: infrastructure
  namespace: flux-system
spec:
  interval: 10m
  sourceRef:
    kind: GitRepository
    name: flux-system
  path: ./kubernetes/overlays/production
  prune: true
  decryption:
    provider: sops
    secretRef:
      name: sops-age   # Kubernetes Secret containing the age private key

Flux’s kustomize-controller reads the age private key from the sops-age Secret, uses it to decrypt any SOPS-encrypted file in the reconciliation path, and applies the decrypted manifests to the cluster. The decrypted values exist only in the controller’s memory during the apply operation — they are never written to any persistent volume or log.

If a file in the Kustomization path is SOPS-encrypted but the controller’s age key is not listed as a recipient, reconciliation fails with an error like:

failed to decrypt Secret kubernetes/overlays/production/database-secret.yaml:
age: no identity matched any of the recipients

This is the failure you want: loud, at apply time, rather than silent application of an unencrypted value.

6. CI/CD Integration — GitHub Actions

For a CI pipeline that deploys directly (rather than via Flux), the age private key lives as a GitHub Actions secret and is injected into the workflow as an environment variable:

# .github/workflows/deploy.yaml
name: Deploy to Production
on:
  push:
    branches: [main]

permissions: {}

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # for OIDC cloud credential exchange

    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Install sops
        run: |
          curl -sSLo sops \
            https://github.com/getsops/sops/releases/download/v3.9.0/sops-v3.9.0.linux.amd64
          echo "EXPECTED_SHA256  sops" | sha256sum --check
          chmod +x sops
          sudo mv sops /usr/local/bin/

      - name: Configure kubeconfig
        # ... OIDC credential exchange, kubeconfig setup ...

      - name: Apply secrets
        env:
          # GitHub Actions masks this value in logs
          SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_PRIVATE_KEY }}
        run: |
          # Decrypt to stdout, pipe directly to kubectl — plaintext never touches disk
          sops --decrypt \
            kubernetes/overlays/production/database-secret.yaml \
            | kubectl apply -f -

      - name: Apply manifests
        run: |
          kubectl apply -k kubernetes/overlays/production/

The SOPS_AGE_KEY environment variable accepts the full age private key file content, including the comment lines. SOPS reads it directly from the environment without requiring a file on disk. The key is masked by GitHub Actions in log output, but a set -x trace in the shell script would still print it. Do not use set -x in any step that has access to SOPS_AGE_KEY.

An alternative that avoids even the environment variable:

      - name: Apply secrets using exec-file
        env:
          SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_PRIVATE_KEY }}
        run: |
          # sops exec-file decrypts to a temporary file, runs the command,
          # then securely deletes the temp file — regardless of whether the
          # command succeeds or fails.
          sops exec-file \
            kubernetes/overlays/production/database-secret.yaml \
            'kubectl apply -f {}'
          # {} is replaced with the path to the temporary decrypted file

sops exec-file guarantees cleanup even on command failure, making it preferable to decrypt-to-file patterns.

7. Pre-commit Hook: Block Plaintext Secret Commits

A pre-commit hook that checks staged YAML files for unencrypted secrets is the last line of defence before plaintext values reach git history:

#!/usr/bin/env bash
# .git/hooks/pre-commit
# Install: cp this file to .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
# Or use pre-commit framework: see .pre-commit-config.yaml below

set -euo pipefail

# Fields that should always be SOPS-encrypted if present
SECRET_FIELDS='password|secret|token|key|credential|connectionString|DATABASE_URL|API_KEY'

# Directories where secrets are expected to be encrypted
SECRET_PATHS='kubernetes/overlays|secrets/'

EXIT_CODE=0

for file in $(git diff --cached --name-only | grep -E "\.(yaml|yml)$"); do
  # Skip files outside secret directories
  if ! echo "$file" | grep -qE "$SECRET_PATHS"; then
    continue
  fi

  staged_content=$(git show ":$file" 2>/dev/null) || continue

  # Check if file contains secret-like fields at all
  if ! echo "$staged_content" | grep -qE "^($SECRET_FIELDS):"; then
    continue
  fi

  # If file has secret fields but no sops: stanza, it is unencrypted
  if ! echo "$staged_content" | grep -q "^sops:"; then
    echo "ERROR: $file contains secret fields but is not SOPS-encrypted"
    echo "       Run: sops --encrypt --in-place $file"
    EXIT_CODE=1
    continue
  fi

  # File has sops: stanza — check that secret values are not plaintext
  # A SOPS-encrypted value begins with ENC[
  while IFS= read -r line; do
    if echo "$line" | grep -qE "^($SECRET_FIELDS):\s+[^E]"; then
      echo "ERROR: $file — possible plaintext secret value on line: $line"
      echo "       Verify encryption: sops --decrypt $file | grep -E '$SECRET_FIELDS'"
      EXIT_CODE=1
    fi
  done < <(echo "$staged_content" | grep -E "^($SECRET_FIELDS):")
done

exit $EXIT_CODE

Or via the pre-commit framework, which handles hook installation, versioning, and bypass auditing:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/getsops/sops
    rev: v3.9.0
    hooks:
      - id: sops-encrypt
        # Runs sops --encrypt on matching files if they are not already encrypted
        files: kubernetes/overlays/.*secret.*\.yaml$

  - repo: local
    hooks:
      - id: check-plaintext-secrets
        name: Check for unencrypted secrets in git
        entry: .git/hooks/pre-commit
        language: script
        files: \.(yaml|yml)$
        pass_filenames: false

The hook cannot prevent git commit --no-verify, which is why the CI pipeline also validates encryption at deploy time — if a plaintext secret somehow reaches the repository, the Flux reconciliation or CI apply step will either apply it incorrectly or the deploy-time secret scanner should alert.

8. Key Rotation

When a team member leaves, remove their age public key from .sops.yaml and re-encrypt all secrets:

# 1. Update .sops.yaml — remove the departing engineer's public key from all creation_rules

# 2. Re-encrypt every SOPS-managed file with the updated recipient set:
find kubernetes/ -name "*.yaml" -exec sh -c '
  if grep -q "^sops:" "$1"; then
    echo "Re-encrypting: $1"
    sops updatekeys --yes "$1"
  fi
' _ {} \;

# sops updatekeys re-encrypts the DEK for the current creation_rules recipients.
# It does NOT change the plaintext values or generate new DEKs.
# The old encrypted DEK for the removed recipient is dropped.
# Historical git versions still contain the old DEK encrypted for that recipient.
# Rotate any sensitive secret values if the departing employee had access to plaintext.

# 3. Commit the re-encrypted files:
git add -p  # review each change
git commit -m "Rotate SOPS recipients: remove departed engineer"

For periodic key rotation (not triggered by personnel change), generate a new age key pair for CI, update .sops.yaml with the new public key, re-encrypt with sops updatekeys, update the sops-age Kubernetes Secret and the CI environment secret, then remove the old public key from .sops.yaml and re-encrypt again.

Expected Behaviour

When sops --decrypt kubernetes/overlays/production/database-secret.yaml runs successfully:

apiVersion: v1
kind: Secret
metadata:
    name: database-credentials
    namespace: production
stringData:
    host: prod-db.internal.example.com
    password: supersecret123
    username: dbadmin

The output is a valid Kubernetes Secret manifest that kubectl apply -f - will accept directly.

When Flux reconciles the Kustomization containing SOPS-encrypted files, kustomize-controller logs:

{"level":"info","ts":"2026-05-09T10:14:00Z","msg":"decrypting secret",
 "resource":"kubernetes/overlays/production/database-secret.yaml",
 "provider":"sops"}
{"level":"info","ts":"2026-05-09T10:14:01Z","msg":"Kustomization reconciled",
 "name":"infrastructure","namespace":"flux-system","revision":"main@sha1:abc123"}

If the age key is not a recipient for a file, the error is immediate and unambiguous:

{"level":"error","ts":"2026-05-09T10:14:00Z",
 "msg":"failed to decrypt Secret",
 "error":"age: no identity matched any of the recipients"}

When a developer edits an encrypted file with sops kubernetes/overlays/production/database-secret.yaml, the editor opens with fully decrypted plaintext. On write and quit, SOPS re-encrypts all values and writes the ciphertext back. The lastmodified timestamp in the sops: stanza updates, and the MACs regenerate — producing a clean git diff showing the stanza metadata changed without revealing old or new plaintext values.

Trade-offs

All encrypted history is permanently recoverable with the private key. The age private key that exists today decrypts every version of every file encrypted for that recipient in git history. If the private key is compromised six months after a secret was rotated, the historical value is still readable from git. For secrets that were meaningfully sensitive (database master passwords, private keys), this historical exposure matters. KMS-backed SOPS partially mitigates this: if the KMS key is disabled, historical decryption fails even with cloud credentials — but this only holds if the KMS policy is configured to prevent historical decryption, which is non-default behaviour.

age vs. cloud KMS. age keys are self-contained — no cloud account, no API calls, no network dependency during decryption. A cluster in a network partition can still reconcile SOPS secrets if it has the age private key. Cloud KMS (AWS KMS, GCP KMS, Azure Key Vault) shifts key management to the cloud provider, adds cloud dependency to every Flux reconciliation cycle, but provides centralized audit logging of every decryption event and supports automatic key rotation without re-encrypting files. For air-gapped or regulated environments, age is often the only viable option. For cloud-native teams with strong cloud security posture, KMS provides better operational visibility.

Multiple recipients add management surface. Every person and system listed as a recipient can decrypt every secret in the scope they are listed for. Adding a recipient is permanent in git history — even if you remove them from .sops.yaml and re-encrypt, historical git versions encrypted under the old recipient list are still readable by the removed recipient. The minimum viable recipient set is: one human per environment (for emergency access), plus the CI/CD system. More recipients mean more keys to track, more failure surfaces, and more historical exposure on departure.

encrypted_regex discipline is ongoing. New secret field names introduced by application teams (e.g., SMTP_PASSWORD, S3_ACCESS_KEY, JWT_SECRET) that do not match the current encrypted_regex will be committed as plaintext without any warning. The regex is a safelist, not a pattern matcher against known secret formats. Review .sops.yaml whenever new secret types are introduced to the repository.

Failure Modes

Age private key stored in CI environment and also committed to git. A developer generating the CI key locally, forgetting to delete the key file, and running git add . before the next commit sends the private key to the repository. This single mistake compromises every secret encrypted for the CI recipient. Mitigations: add *.agekey and age-key.txt to .gitignore, configure secret scanning to detect AGE-SECRET-KEY-1 patterns in pre-receive hooks, never use git add . in directories that might contain key material.

.sops.yaml path regex does not match new file paths. A developer creates kubernetes/overlays/production/new-service/auth-secret.yaml, which does not match the regex kubernetes/overlays/production/.*secret.*\.yaml$ (it would need .*secret.* in the filename, not the directory structure). SOPS does not encrypt the file. The pre-commit hook catches this if it checks all YAML files in secret-adjacent directories; if it only checks files with secret in the name, it misses the file too. Path regex design should match directory patterns, not just filename patterns:

# More robust pattern — matches any yaml under production overlay:
- path_regex: kubernetes/overlays/production/.*\.yaml$
  encrypted_regex: '^(data|stringData|password|secret|key|token|credential|connectionString)$'
  age: ...

With this pattern, every YAML in the overlay gets encrypted regex applied, not just files named with secret. Non-secret manifests (Deployments, Services) will have the regex applied but no fields match, so they are unaffected.

Pre-commit hook bypassed with --no-verify. Document that --no-verify triggers an automatic security review comment on the associated pull request. In GitHub Actions, check for unencrypted secrets in the CI pipeline as a mandatory status check that cannot be bypassed:

# .github/workflows/secret-check.yaml
- name: Check for unencrypted SOPS secrets
  run: |
    find kubernetes/ -name "*.yaml" | while read f; do
      if grep -qE "^(password|secret|token|key|credential):\s+[^ENC]" "$f"; then
        echo "FAIL: $f contains possible plaintext secret"
        exit 1
      fi
    done

Flux sops-age Secret deleted or rotated without updating .sops.yaml. If the sops-age Kubernetes Secret is recreated with a new age private key — for example, during a cluster rebuild — but the new public key was not added as a recipient in .sops.yaml before re-encryption, Flux will fail to decrypt all secrets in the repository. Recovery requires access to the old private key to decrypt secrets, updating .sops.yaml with the new public key, re-encrypting, and recreating the sops-age Secret with the correct key. Bootstrap order matters: the Kubernetes Secret must contain the age key that matches at least one recipient in the encrypted files.