Encrypt production secrets in public Git repos with SOPS and Age. Selective encryption, multi-recipient keys, zero-disk CI, and Flux-native decryption.
A public repository that contains Kubernetes manifests and environment files will eventually be scanned by automated secret detectors. When those scanners surface plaintext credentials, the incident response cost is immediate: key rotation, audit, and often a temporary freeze on deployments. The engineering requirement is therefore precise—keep the full desired state in Git while ensuring that secret values never appear in plaintext inside the repository, CI logs, or shared runner disks.
SOPS combined with Age satisfies that requirement when configured with selective encryption, multiple recipients, and strictly in-memory decryption. The following layout is the version that survived production review.
Selective Encryption and Multi-Recipient Configuration
Encrypting an entire Kubernetes manifest breaks GitOps controllers. Argo CD and Flux need to read apiVersion, kind, and metadata in order to build the resource graph and compute diffs. Only the sensitive payloads must be encrypted.
creation_rules:
# Kubernetes Secrets: encrypt only data / stringData
- path_regex: infrastructure/prod/.*secret.*\.yaml$
encrypted_regex: "^(data|stringData)$"
age: >-
age1prodrunner000000000000000000000000000000000000000000000000,
age1srebreakglass000000000000000000000000000000000000000000000
# Environment files: encrypt values
- path_regex: \.env\.prod$
encrypted_regex: ".*"
age: >-
age1prodrunner000000000000000000000000000000000000000000000000,
age1srebreakglass000000000000000000000000000000000000000000000Two Age recipients are declared deliberately. The first is the CI / controller identity; the second is an offline SRE break-glass key. Loss or departure of a single engineer no longer renders the secrets unrecoverable.
Zero-Disk Decryption in CI
Writing decrypted material to /tmp on a shared runner leaves residual data in disk buffers and crash dumps. The safe pattern is pure streaming:
# .github/workflows/deploy.yml
name: Hardened GitOps Deployment
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install SOPS
run: |
curl -LO https://github.com/getsops/sops/releases/download/v3.8.1/sops-v3.8.1.linux.amd64
sudo mv sops-v3.8.1.linux.amd64 /usr/local/bin/sops
sudo chmod +x /usr/local/bin/sops
- name: In-Memory Decrypt and Apply
env:
SOPS_AGE_KEY: ${{ secrets.PROD_AGE_PRIVATE_KEY }}
run: |
# Decrypt → pipe directly into kubectl (no intermediate file)
sops --decrypt infrastructure/prod/secrets.enc.yaml | kubectl apply -f -
# Load environment variables without materializing a file
eval "$(sops exec-env infrastructure/prod/.env.prod 'true')"The private key exists only as an environment variable inside the job. No plaintext secret file is ever created on the runner filesystem.
Native Decryption Inside the Cluster (Flux v2)
When the GitOps controller itself can decrypt, the CI job no longer needs the private key at all. Flux supports this natively:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: production-secrets-pipeline
namespace: flux-system
spec:
interval: 10m
path: ./infrastructure/prod
prune: true
sourceRef:
kind: GitRepository
name: flux-system
decryption:
provider: sops
secretRef:
name: sops-age-master-keyThe Age private key is stored once as a Kubernetes Secret inside the cluster. Flux decrypts during reconciliation; the CI system only pushes encrypted manifests.
Automated Key Rotation
Manual re-encryption is error-prone. After a new recipient is added to .sops.yaml, the following script re-encrypts every matching file in place:
#!/usr/bin/env bash
set -euo pipefail
echo "[+] Rotating SOPS recipients across repository..."
find infrastructure/ -type f \( -name "*.enc.yaml" -o -name ".env.prod" \) | while read -r secret_file; do
echo " re-encrypting: ${secret_file}"
sops updatekeys -y "${secret_file}"
done
echo "[+] Key rotation completed."sops updatekeys reads the existing data keys, re-wraps them for the current set of recipients, and writes the file back without ever exposing the plaintext values in the process.
Cryptographic Integrity & Tamper Protection
SOPS does more than encrypt values. It also protects the structural integrity of the document. After encrypting the sensitive fields with ChaCha20-Poly1305, SOPS computes an HMAC-SHA256 over the canonical tree (including the unencrypted keys) and the encrypted payloads:
HMAC-SHA256(K data TamperedTree neq StoredMAC)
The pipeline aborts before any mutated manifest reaches the Kubernetes API server. This property is essential when encrypted files live in public repositories that accept external pull requests.
Operational Trade-offs
- Key custody becomes a first-class operational concern. The break-glass Age key must be stored offline and tested periodically.
- Diff readability is reduced; reviewers see ciphertext for values. The trade-off is accepted because the alternative is plaintext in Git history.
- Controller support varies. Flux and the SOPS Kubernetes controller handle decryption natively; some older operators still require a CI decryption step.
- Performance impact is negligible for typical secret volumes. Decryption of a few dozen Kubernetes Secret objects adds low double-digit milliseconds in CI.
References
- SOPS project and documentation: https://github.com/getsops/sops
- Age encryption tool: https://github.com/FiloSottile/age
- Flux decryption documentation: https://fluxcd.io/flux/guides/mozilla-sops/

