GitOps-Native Secret Management with SOPS and ArgoCD CMP Sidecar
The challenge: How do you manage database passwords, API tokens, and OIDC client secrets in a GitOps workflow where everything β including the deployment configuration β lives in a public Git repository?
The answer: Encrypt secrets with SOPS using age keys, commit them as
.enc.yamlfiles alongside your plaintext values, and decrypt them at deploy time with an ArgoCD Config Management Plugin sidecar. No Vault cluster. No External Secrets Operator. No proprietary infrastructure β justsops,age, and a 10-line shell script.
The Problem: Secrets in GitOps
At openDesk Edu, we deploy 25+ integrated services across multiple Kubernetes clusters using a GitOps workflow with ArgoCD and Helmfile. Every piece of configuration β Helm values, environment overrides, service definitions β lives in a Git repository and is automatically synced to the cluster.
This is great for everything except secrets.
A standard openDesk Edu deployment requires ~126 distinct secret values:
- P0 (Critical): Keycloak admin credentials, database root passwords, mail relay authentication
- P1 (High): OIDC client secrets, LDAP bind credentials, SMTP passwords
- P2 (Medium): Redis authentication tokens, MariaDB replica passwords
- P3 (Low): Ceph storage keys, service API tokens
With GitOps, you can't just kubectl create secret β that violates the principle of Git as single source of truth. But you also can't commit plaintext secrets to a Git repository.
The open-source ecosystem offers several solutions. Here's why we chose our approach.
Why SOPS (+ age) Over the Alternatives
Option Rejected: Sealed Secrets
Bitnami Sealed Secrets encrypts secrets with a cluster-local key. The problem: the SealedSecret CRD is cluster-scoped. If your cluster dies, you lose your sealing key unless you've backed it up separately. For a multi-cluster deployment (dev, staging, prod), you'd need separate sealed secrets per cluster, which duplicates effort and creates drift risk.
Option Rejected: External Secrets Operator
External Secrets Operator pulls secrets from an external vault (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault). This is a great pattern for cloud-native deployments, but it introduces an external dependency and a second source of truth. For an on-premise deployment at German universities where you want to minimize infrastructure dependencies, adding a HashiCorp Vault cluster just to manage secrets felt like replacing one problem with another.
Option Rejected: Helm Secrets / Helm S3
These require the decryption tooling on every developer's machine and don't integrate cleanly with ArgoCD's declarative sync model.
Chosen: SOPS with age and ArgoCD CMP Sidecar
SOPS (Secrets OPerationS) is a mature, battle-tested tool for encrypting individual values or entire files. We chose it because:
- Stateless encryption β decryption only requires the age private key, not a running service
- Git-native β encrypted files sit next to their plaintext counterparts in the repo
- ArgoCD-native β the Config Management Plugin sidecar pattern integrates seamlessly
- No cluster dependency β the decryption key is a single file, backed up independently of any cluster
- Audit trail β every secret change is a Git commit with author, timestamp, and diff
The Architecture
Our secret management architecture consists of four components:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Git Repository β
β β
β .sops.yaml helmfile/secrets/ β
β ββββββββββ βββββββββββββββββββ β
β creation_rules: secrets.enc.yaml (P0 critical) β
β age: <public_key> secrets.prod.enc.yaml (P1-P3) β
β β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β git push
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ArgoCD β
β β
β βββββββββββββββββββ ββββββββββββββββββββββββββββββ β
β β repo-server β β CMP sidecar (sops) β β
β β (main) ββββββ β β
β β β β sops --decrypt *.enc.yaml β β
β β Receives β β age key: /sops-age-key/ β β
β β decrypted YAML β ββββββββββββββββββββββββββββββ β
β βββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββ
β Kubernetes Cluster β
β Secrets + ConfigMapsβ
βββββββββββββββββββββββ
1. SOPS Configuration (.sops.yaml)
The repository root contains a .sops.yaml file that defines the encryption rules:
creation_rules:
- path_regex: \.enc\.yaml$
age: <age-public-key>
This tells SOPS: any file ending in .enc.yaml should be encrypted with this age public key. The magic is that SOPS generates a unique data encryption key per file, which is then wrapped with the age public key. This means you can rotate the age key without re-encrypting every file β just re-wrap the data encryption keys.
2. Encrypted Secrets Files
We organize secrets by environment and criticality:
| File | Contents | Priority |
|---|---|---|
helmfile/environments/default/secrets.enc.yaml |
P0 critical secrets (97 values) | π΄ Critical |
helmfile/environments/default/secrets.prod.enc.yaml |
P1-P3 secrets per environment (29 values) | π High |
The .enc.yaml extension is our signal to both SOPS and ArgoCD that the file needs decryption. When you open one of these files, you see:
keycloak_admin_password: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
mariadb_root_password: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
sops:
kms: null
gcp_kms: null
azure_kv: null
hc_vault: null
age:
- recipient: age1...
enc: |
---
...
lastmodified: '2026-07-09T12:00:00Z'
Each value is individually encrypted with AES-256-GCM, and the file carries metadata about the encryption keys, version, and last modification time.
3. ArgoCD CMP Sidecar
The key integration point is the ArgoCD Config Management Plugin (CMP) sidecar. This sidecar runs alongside the main ArgoCD repo-server container and intercepts .enc.yaml files before they reach resource detection.
The sidecar configuration is a single ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmp-sops-plugin
namespace: argocd
data:
plugin.yaml: |
apiVersion: argoproj.io/v1alpha1
kind: ConfigManagementPlugin
metadata:
name: sops
spec:
sidecar: true
generate:
command:
- /bin/sh
- -c
- |
for f in $(find . -name '*.enc.yaml' -type f); do
/custom-tools/sops --decrypt \
--input-type yaml --output-type yaml "$f" 2>/dev/null
done
discover:
find:
glob: "**/*.enc.yaml"
When ArgoCD detects a file matching *.enc.yaml, it invokes the sidecar's generate command, which runs sops --decrypt on each encrypted file. The decrypted YAML is then handed to ArgoCD's standard resource detection.
The sidecar needs two things to function:
- The
sopsbinary in the sidecar container - The age private key mounted as a Kubernetes Secret
Both are injected via a strategic merge patch to the ArgoCD repo-server Deployment:
# Strategic merge patch (abbreviated)
spec:
template:
spec:
initContainers:
- name: install-sops
image: alpine:3.20
command: ["/bin/sh", "-c"]
args:
- |
wget -q https://github.com/getsops/sops/releases/...
mv sops /custom-tools/sops
volumeMounts:
- name: extra-tools
mountPath: /custom-tools
containers:
- name: sops
image: alpine:3.20
command: [/var/run/argocd/argocd-cmp-server]
volumeMounts:
- name: extra-tools
mountPath: /custom-tools
- name: argocd-sops-age-key
mountPath: /sops-age-key
readOnly: true
volumes:
- name: extra-tools
emptyDir: {}
- name: argocd-sops-age-key
secret:
secretName: argocd-sops-age-key
4. Annotating ArgoCD Applications
Each ArgoCD Application that uses SOPS-encrypted files must declare the plugin:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: opendesk-edu
spec:
source:
repoURL: https://github.com/opendesk-edu/opendesk-edu.git
path: .
plugin:
name: sops
That's it. The Application now transparently decrypts secrets during sync.
Verified End-to-End
The CMP sidecar was deployed and tested on the production HRZ cluster (K3s v1.32.3, ArgoCD v3.0.12) in July 2026. We verified:
- Encrypted secret committed β A
test-secret.enc.yamlwas committed to the repository - ArgoCD detected the change β The Application's sync status showed a diff
- Sidecar decrypted the secret β
kubectl -n argocd logs deployment/argocd-repo-server sopsconfirmed decryption - Resource was applied β The plaintext Secret was created in the target namespace
- No plaintext leaked β Verified the encrypted file remained encrypted in Git
The entire setup β from repository configuration to working deployment β took approximately 4 hours, including debugging the sidecar volume mounts.
What This Means for Operators
Day-to-Day Operations
For most operators, the SOPS integration is invisible. They:
- Edit Helm values in the usual
.yamland.gotmplfiles - Commit and push to Git
- ArgoCD syncs automatically
The only difference is that secrets are stored as encrypted files. When an operator needs to add or update a secret:
# Decrypt the secrets file
sops helmfile/environments/default/secrets.enc.yaml
# Edit the file (opens in $EDITOR)
# When saved, SOPS automatically re-encrypts
# Commit
git add helmfile/environments/default/secrets.enc.yaml
git commit -m "feat(secrets): update Keycloak admin password"
git push
The sops CLI handles decryption and re-encryption transparently. Operators never see or handle plaintext secrets unless they have the age private key.
Secret Rotation
Rotating a secret is as simple as:
sops helmfile/environments/default/secrets.enc.yaml
# Edit the value in-place
# Save β auto-encrypted
# Or rotate all data encryption keys:
sops --rotate helmfile/environments/default/secrets.enc.yaml
Disaster Recovery
The most important operational concern: back up the age private key. This single file (~/.age/opendesk-edu.txt) controls access to all encrypted secrets. Without it, you cannot decrypt any .enc.yaml file.
Our disaster recovery guide now includes SOPS key recovery as the first step in the recovery order (P0 priority, before storage and databases).
The 126 Encrypted Secrets: By the Numbers
| Priority | Count | Categories |
|---|---|---|
| P0 (Critical) | 97 | Keycloak admin, DB roots, mail relay auth, LDAP admin |
| P1 (High) | 16 | OIDC client secrets, SMTP credentials, API tokens |
| P2 (Medium) | 8 | Redis auth, replica DB passwords, cache keys |
| P3 (Low) | 5 | Ceph keys, service tokens, monitoring credentials |
| Total | 126 |
Each value is individually encrypted with AES-256-GCM. No two values share a nonce. The file-level data encryption key is unique per file, wrapped with the age public key.
Why This Matters for GitOps
This approach preserves the core GitOps principles:
- Git is the single source of truth β every secret value has a Git commit
- Declarative deployment β ArgoCD handles everything autonomously
- No infrastructure dependencies β no Vault, no cloud provider, no external service
- Audit trail β
git logshows exactly who changed which secret and when - Reproducible β a fresh cluster can be bootstrapped from Git alone (with the age key)
Lessons Learned
-
Volume mounts are the trickiest part β The CMP sidecar needs access to the same repo checkout as the main repo-server. The shared
tmpvolume mount is essential. -
Proxy configuration matters β On the HRZ cluster, the
install-sopsinitContainer neededHTTP_PROXYandHTTPS_PROXYenvironment variables to download the sops binary. -
Plugin caching can cause stale secrets β If a previous sync is cached in Redis, ArgoCD may not re-run the plugin after a secret update. Forcing a cache flush (
redis-cli FLUSHALL) or bumping the commit hash resolves this. -
Test with a dummy secret first β We committed a harmless
test-secret.enc.yamland verified end-to-end before encrypting real production credentials. -
Back up the age key in multiple locations β This single file is the root of trust for all secrets. Losing it means losing access to every encrypted value.
What's Next
The SOPS integration is foundational for our v1.1 release. With secrets management solved, we're now able to:
- Define secret requirements for every service β each Helm chart declares exactly what secrets it needs
- Audit secret coverage β automated checks ensure no service references an undefined secret
- Rotate secrets on schedule β using SOPS's metadata (encryption timestamp) to track rotation windows
- Extend to CI/CD pipelines β GitLab CI jobs can decrypt secrets for integration tests
The complete integration guide is available in our repository at docs/developer/sops-argocd-integration.md, and the encrypted secrets live in helmfile/environments/*/secrets*.enc.yaml.
SOPS-based secret management is part of the openDesk Edu v1.1 release. For the full v1.1 roadmap, see our release plan.
openDesk Edu: Sovereign, integrated, production-ready open-source education technology.