GitOps Secrets: SOPS, External Secrets, and the Plain-Text Trap
Your Git repository is world-readable the day it is created, and the encrypted file that sits inside it is only as good as the key that unlocks it on every node in the cluster.
A team encrypted every secret with SOPS, committed the files, and considered the problem closed. The decryption key sat in the repository's CI variable, base64-encoded and masked. The encryption had produced a file any machine with the key could read, and the key was on every machine that pulled the repository.
The ciphertext was never the security boundary. The key was.
Why plain text keeps showing up
The pressure is real: GitOps says the repository is the desired state, so a secret missing from it is one the reconciler cannot apply. Three options resolve that tension.
Storing the value unencrypted fails because a private repository becomes public through a permissions change, a fork, a mirror, or a vendor breach, and history retains the value forever. Base64 in a manifest survives longest, since it looks like encoding to anyone who has spent an afternoon in Docker, but it is reversible in one line.
The third option ships: encrypt properly, then place the key where the pipeline can reach it without a human in the loop. That is where the design question lives.
Encrypted files in the repository
Tools like SOPS split the problem cleanly: the file is encrypted with a data key, and that data key is itself encrypted by a KMS or PGP key. The repository contains only ciphertext.
# encrypted with an age recipient, committed as-is
sops:
age:
- recipient: age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB...
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-08T06:12:00Z"
DB_PASSWORD: ENC[AES256_GCM,data:9q3x...,type:str]
API_TOKEN: ENC[AES256_GCM,data:Kp2v...,type:str]
The property to check is not that the file is encrypted (it obviously is) but where the decryption happens. Two shapes exist.
Client-side decryption means the pipeline holds the key, decrypts in memory, and applies a plain manifest. The cluster never sees ciphertext, but the pipeline holds every secret in the fleet: compromise the CI runner and you have everything.
Server-side decryption means the cluster holds the key. In sealed-secrets the controller running inside is the only thing that can unseal, so the repository can be fully public and still safe: an attacker who reads it gets ciphertext they cannot open.
The second is meaningfully stronger, and costs nothing but installing a controller, see running Argo CD in production.
Fetching at apply time
The other model keeps secrets out of Git entirely, pulling them from a secret manager at reconciliation. External Secrets Operator is the common implementation: declare what you want in Git, and a controller populates the Kubernetes Secret from Vault, AWS Secrets Manager, or GCP Secret Manager.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: payments-db
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: payments-db
data:
- secretKey: password
remoteRef:
key: payments/db
property: password
Git stores the intent (which secret, from where, refreshed how often), never the value. Rotation simplifies too: change it in the vault and the next refresh propagates without a commit.
The trade-off is a hard dependency: if the secret manager is unreachable at reconciliation, the secret keeps its previous value or disappears, depending on configuration. A cluster that cannot start workloads because a SaaS endpoint timed out is a different outage than one with stale credentials.
Where rotation actually dies
Most teams encrypt correctly and rotate never, and the blocker is rarely technical.
Nobody knows which files depend on which key. Rotating the KMS key means re-encrypting every SOPS file, and across twenty repositories in four teams that is a project rather than a click; until a single list of repositories and key IDs exists, rotation gets deferred every time it is scheduled.
The other failure is the workstation. Someone needs the key to test locally, receives it in a chat message, and it lands in a shell history. Issue short-lived credentials from the same manager the pipeline uses instead, scoped to one repository and expiring in hours.
The check worth running
One command tells you where you stand:
git log --all -p -S "password" --oneline -- '*.yaml' | head -40
It searches the reachable history for a literal, catching a file committed in plaintext months before encryption was added. Git does not forget: if it returns anything, today's ciphertext does not cover what already leaked.
Summary
Encryption in Git protects the file, not the key, so decide deliberately whether the pipeline or the cluster holds the decryption capability, and prefer a controller inside the cluster so the repository can be untrusted. External Secrets sidesteps the problem by keeping values out of Git, at the cost of a runtime dependency. Either way, maintain the key-to-repository map: rotation that requires archaeology will not happen.
SDP Clouds Team
DevOps and cloud engineers writing practical, battle-tested guides on CI/CD, Kubernetes, infrastructure as code, and production operations: every article is based on real incidents and real pipelines, not docs-page rewrites.
More about us →