ArgoCD Secrets Management#
GitOps says everything should be in Git. Kubernetes Secrets are base64-encoded, not encrypted. Committing base64 secrets to Git is equivalent to committing plaintext – anyone with repo access can decode them. This is the fundamental tension of GitOps secrets management.
Three approaches solve this, each with different tradeoffs.
Approach 1: Sealed Secrets#
Sealed Secrets encrypts secrets client-side so the encrypted form can be safely committed to Git. Only the Sealed Secrets controller running in-cluster can decrypt them.
How It Works#
Developer encrypts Secret with kubeseal (uses controller's public key)
→ SealedSecret resource committed to Git
→ ArgoCD syncs SealedSecret to cluster
→ Sealed Secrets controller decrypts it
→ Regular Kubernetes Secret is created in the namespace
→ Pods consume the Secret normallyInstallation#
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-system \
--set-string fullnameOverride=sealed-secrets-controllerInstall the CLI:
# macOS
brew install kubeseal
# Linux
KUBESEAL_VERSION=$(curl -s https://api.github.com/repos/bitnami-labs/sealed-secrets/releases/latest | grep tag_name | cut -d '"' -f4 | cut -c2-)
curl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v${KUBESEAL_VERSION}/kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz"
tar -xvzf kubeseal-*.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubesealEncrypting a Secret#
Start with a regular Secret manifest:
# my-secret.yaml (DO NOT commit this file)
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: my-app
stringData:
username: admin
password: s3cret-p4ssword
connection-string: "postgresql://admin:s3cret-p4ssword@db:5432/mydb"Encrypt it:
kubeseal --format yaml < my-secret.yaml > sealed-secret.yamlThe output is a SealedSecret resource:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: db-credentials
namespace: my-app
spec:
encryptedData:
username: AgBy3i4OJSWK+PiTySYZZA9rO...
password: AgCtr8OJSWK+PiReSYZZA9rO...
connection-string: AgDf5OJSWK+PiTySYZZBt7q...
template:
metadata:
name: db-credentials
namespace: my-appCommit sealed-secret.yaml to Git. Delete the plaintext my-secret.yaml.
Scoping#
Sealed Secrets supports three scopes that control where the decrypted Secret can be created:
- strict (default): Sealed to a specific name and namespace. Cannot be moved or renamed.
- namespace-wide: Can be used with any name within the sealed namespace.
- cluster-wide: Can be decrypted in any namespace with any name. Use with caution.
# Namespace-wide scope
kubeseal --format yaml --scope namespace-wide < my-secret.yaml > sealed-secret.yaml
# Cluster-wide scope
kubeseal --format yaml --scope cluster-wide < my-secret.yaml > sealed-secret.yamlArgoCD Integration#
No special ArgoCD configuration is needed. ArgoCD applies the SealedSecret resource like any other manifest. The Sealed Secrets controller handles decryption asynchronously.
Add a custom health check so ArgoCD can track SealedSecret status:
# In argocd-cm ConfigMap
data:
resource.customizations.health.bitnami.com_SealedSecret: |
hs = {}
if obj.status ~= nil then
if obj.status.conditions ~= nil then
for i, condition in ipairs(obj.status.conditions) do
if condition.type == "Synced" and condition.status == "True" then
hs.status = "Healthy"
hs.message = "Secret has been unsealed"
return hs
end
if condition.type == "Synced" and condition.status == "False" then
hs.status = "Degraded"
hs.message = condition.message
return hs
end
end
end
end
hs.status = "Progressing"
hs.message = "Waiting for secret to be unsealed"
return hsKey Rotation and Backup#
The Sealed Secrets controller generates a sealing key pair on startup. If you lose this key, you cannot decrypt any existing SealedSecrets. Back it up:
kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > sealed-secrets-key-backup.yamlStore this backup outside the cluster in a secure location. This is the one secret you cannot seal.
The controller generates new keys every 30 days by default but keeps old keys for decryption. To re-encrypt all SealedSecrets with the latest key:
kubeseal --re-encrypt < sealed-secret.yaml > sealed-secret-new.yamlApproach 2: External Secrets Operator#
External Secrets Operator (ESO) pulls secrets from an external secret store (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault, GCP Secret Manager) and creates Kubernetes Secrets automatically. The actual secret values never appear in Git at all.
How It Works#
Secret stored in AWS Secrets Manager / Vault / etc.
→ ExternalSecret resource in Git (references the external secret by name)
→ ArgoCD syncs ExternalSecret to cluster
→ ESO reads the external store and creates a Kubernetes Secret
→ Pods consume the Secret normallyInstallation#
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets \
--create-namespaceConfiguring a SecretStore#
A SecretStore tells ESO how to connect to the external provider. A ClusterSecretStore works across all namespaces; a SecretStore is namespace-scoped.
AWS Secrets Manager example:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: aws-secrets-manager
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: external-secrets-sa
namespace: external-secretsHashiCorp Vault example:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault
spec:
provider:
vault:
server: "https://vault.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: external-secrets-sa
namespace: external-secretsCreating an ExternalSecret#
This is what goes in Git – a reference to the external secret, not the secret value:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
namespace: my-app
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: db-credentials
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: production/my-app/db
property: username
- secretKey: password
remoteRef:
key: production/my-app/db
property: passwordESO creates a regular Kubernetes Secret named db-credentials with username and password keys, pulling the values from production/my-app/db in AWS Secrets Manager.
Templating#
ESO can transform secret data before creating the Kubernetes Secret:
spec:
target:
name: db-connection
template:
type: Opaque
data:
connection-string: "postgresql://{{ .username }}:{{ .password }}@db.example.com:5432/mydb"
data:
- secretKey: username
remoteRef:
key: production/my-app/db
property: username
- secretKey: password
remoteRef:
key: production/my-app/db
property: passwordThis constructs a connection string from individual secret fields without storing the assembled string in the external provider.
ArgoCD Integration#
Like Sealed Secrets, no special ArgoCD configuration is required. ArgoCD syncs the ExternalSecret CRD, and ESO handles the rest.
ESO’s ExternalSecret resources include status conditions that ArgoCD can read out of the box. The application shows Healthy when the external secret is successfully synced.
Approach 3: SOPS (Secrets OPerationS)#
SOPS encrypts specific values within YAML or JSON files in-place, leaving keys and structure visible. It supports multiple encryption backends: age, PGP, AWS KMS, GCP KMS, Azure Key Vault.
How It Works#
Developer encrypts values in Secret manifest using SOPS
→ Encrypted YAML committed to Git (keys visible, values encrypted)
→ ArgoCD uses KSOPS or helm-secrets plugin to decrypt during sync
→ Decrypted Secret applied to clusterEncrypting with SOPS and age#
age is the simplest key management option for SOPS:
# Install
brew install sops age
# Generate a key pair
age-keygen -o key.txt
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Create a .sops.yaml in the repo root to configure encryption rules
cat > .sops.yaml << 'EOF'
creation_rules:
- path_regex: .*secrets.*\.yaml$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
EOFEncrypt a Secret manifest:
sops --encrypt --in-place secrets/db-credentials.yamlThe result keeps YAML structure intact but encrypts the values:
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: my-app
stringData:
username: ENC[AES256_GCM,data:dGVzdA==,iv:abc...,tag:def...,type:str]
password: ENC[AES256_GCM,data:c2VjcmV0,iv:ghi...,tag:jkl...,type:str]
sops:
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...ArgoCD Integration with KSOPS#
KSOPS is a Kustomize plugin that decrypts SOPS files during kustomize build. ArgoCD’s repo server needs the SOPS binary and the decryption key.
Patch the ArgoCD repo server to include SOPS:
# In argocd values.yaml
repoServer:
env:
- name: SOPS_AGE_KEY_FILE
value: /sops/age/keys.txt
volumes:
- name: sops-age
secret:
secretName: sops-age-key
volumeMounts:
- name: sops-age
mountPath: /sops/ageCreate the age key as a Kubernetes Secret in the ArgoCD namespace:
kubectl create secret generic sops-age-key \
--from-file=keys.txt=key.txt \
--namespace argocdIn your application manifests, use a Kustomize generator with KSOPS:
# kustomization.yaml
generators:
- secret-generator.yaml
# secret-generator.yaml
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
name: db-credentials
files:
- secrets/db-credentials.yamlArgoCD Integration with helm-secrets#
For Helm-based applications, the helm-secrets plugin decrypts SOPS-encrypted values files:
spec:
source:
path: charts/my-app
helm:
valueFiles:
- values.yaml
- secrets+age-import:///sops/age/keys.txt?secrets.yamlChoosing an Approach#
| Factor | Sealed Secrets | External Secrets Operator | SOPS |
|---|---|---|---|
| Secret values in Git | Encrypted | Never | Encrypted |
| External dependency | None (self-contained) | Secret store (Vault, AWS SM, etc.) | Encryption key only |
| Rotation | Manual re-seal | Automatic (refreshInterval) | Manual re-encrypt |
| Multi-environment | Sealed per-cluster key | One store, multiple ExternalSecrets | One key, multiple encrypted files |
| Team workflow | Encrypt locally, commit | Update external store, reference in Git | Encrypt locally, commit |
| Operational complexity | Low | Medium (need external store) | Low |
| Cloud-native fit | Generic | Strong (native cloud provider integration) | Medium |
Sealed Secrets is the simplest starting point. Everything is self-contained in the cluster. Good for small teams and single-cluster setups.
External Secrets Operator is the production choice for teams already using a secret store. Secrets are centrally managed, automatically rotated, and never touch Git even in encrypted form.
SOPS fits teams that want encrypted secrets in Git with fine-grained diff visibility (you can see which keys changed, just not the values). Works well with PR review workflows.
Common Mistakes#
- Committing plaintext Secrets “just temporarily.” Git history is permanent. Even after removing the file, the secret is in the commit history. If this happens, rotate the secret immediately.
- Not backing up Sealed Secrets controller keys. Lose the key, lose all your secrets. Back up the sealing key and store it outside the cluster.
- Setting ExternalSecret refreshInterval too low. Every refresh calls the external provider API. At 10s with 100 ExternalSecrets, that is 600 API calls per minute. Start at 1h and lower only where needed.
- Forgetting the decryption key when migrating clusters. SOPS and Sealed Secrets both need their keys present in the new cluster before ArgoCD can sync encrypted resources.
- Using the same secret values across all environments. Each environment should have its own secrets, even for non-sensitive config. This prevents accidental cross-environment connections.