GitOps Without Leaks: Encrypting Production Secrets Directly in Public Repositories Using SOPS and Age

GitOps Without Leaks: Encrypting Production Secrets Directly in Public Repositories Using SOPS and Age



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,
      age1srebreakglass000000000000000000000000000000000000000000000


Two 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.



GitOps Without Leaks: Encrypting Production Secrets Directly in Public Repositories Using SOPS and Age





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-key



The 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:

                 MAC=HMAC-SHA256(Kdata,  CanonicalTreeStructureEncryptedValues)

If an attacker modifies an unencrypted field in a pull request—for example changingnamespace: default to namespace: kube-system—the MAC verification fails at decryption time:

                    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.
Teams that need dynamic, short-lived credentials or automatic rotation of database passwords will still prefer a dedicated secrets manager. For static configuration secrets that must travel with the GitOps state, the SOPS + Age pattern removes the classic “secrets in Git” contradiction without introducing a parallel source of truth.


References


Post a Comment

Previous Post Next Post