DevOps GitOps Secret Management: 6 Production Patterns from Sealed Secrets to External Secrets Operator
Committing Secrets to Git? You're Sitting on a Time Bomb
GitOps revolves around "Git as the Single Source of Truth," but Kubernetes Secrets are merely Base64-encoded — not encrypted. Pushing Base64-encoded Secrets to a Git repository is equivalent to storing database passwords, API keys, and TLS certificates in plaintext. Once the repository is leaked (internal misconfiguration, third-party supply chain breach, forgotten cleanup in a forked repo), all secrets are instantly exposed.
In 2026, DevOps GitOps secret management is no longer optional — it's a hard requirement for production deployments. This article covers 6 production-grade secret management patterns, from Sealed Secrets encrypted storage to External Secrets Operator dynamic injection, helping you completely solve secret security in GitOps workflows.
Key Takeaways
- Understand 3 architectural patterns for GitOps secret management: encrypted storage, external injection, and hybrid
- Master complete deployment and configuration of Sealed Secrets, External Secrets Operator, and SOPS
- Implement automatic secret rotation and multi-cluster synchronization
- Avoid 5 most common production pitfalls and 10 frequent errors
- Get a comparison decision matrix for tool selection
Table of Contents
- GitOps Secret Management Core Concepts
- Pattern 1: Sealed Secrets Encrypted Storage
- Pattern 2: External Secrets Operator + Vault
- Pattern 3: SOPS + Age/GPG Encryption
- Pattern 4: Automatic Secret Rotation
- Pattern 5: Multi-Cluster Secret Synchronization
- Pattern 6: Audit and Compliance
- 5 Common Pitfalls and Solutions
- 10 Common Error Troubleshooting
- Advanced Optimization Tips
- Comparison Analysis
- Recommended Online Tools
GitOps Secret Management Core Concepts
Three Architectural Patterns
┌─────────────────────────────────────────────────────────────┐
│ GitOps Secret Management: 3 Patterns │
├─────────────────┬──────────────────┬────────────────────────┤
│ Encrypted │ External │ Hybrid │
│ Storage │ Injection │ (SOPS + ESO) │
│ (Sealed Secrets)│ (ESO + Vault) │ │
├─────────────────┼──────────────────┼────────────────────────┤
│ Git stores │ Git stores │ Git stores encrypted │
│ ciphertext │ references only │ values + references │
│ In-cluster │ In-cluster │ In-cluster decrypt │
│ decryption │ pulls plaintext │ or pull │
│ Offline audit │ External deps │ Flexible combo │
│ Re-encrypt to │ Native rotation │ Partial rotation │
│ rotate │ support │ support │
└─────────────────┴──────────────────┴────────────────────────┘
The Fundamental Problem with K8s Secrets
# Create a standard Secret
kubectl create secret generic db-credentials \
--from-literal=username=admin \
--from-literal=password='SuperSecret123!'
# "Encryption" is just Base64 encoding — anyone can decode it
kubectl get secret db-credentials -o yaml
# Decoding takes one command
kubectl get secret db-credentials \
-o jsonpath='{.data.password}' | base64 -d
# Output: SuperSecret123!
# Verify with ToolsKu Base64 encoder/decoder
# https://toolsku.com/en/encode/base64
Core Principles of DevOps GitOps Secret Management
| Principle | Description | Consequence of Violation |
|---|---|---|
| Zero Plaintext | Never store plaintext secrets in Git | Repository leak = total compromise |
| Least Privilege | Each app accesses only its own secrets | Lateral secret leakage |
| Auto Rotation | Secrets are automatically updated periodically | Long-lived keys brute-forced |
| Auditable | Every secret access is recorded | Cannot trace leak source |
| Recoverable | Secrets can be quickly restored after loss | Business disruption |
Pattern 1: Sealed Secrets Encrypted Storage
Sealed Secrets is an open-source GitOps secret management solution by Bitnami. The core idea: encrypt Secrets locally with kubeseal, generate SealedSecret resources committed to Git, and the in-cluster Sealed Secrets Controller automatically decrypts them back into K8s Secrets.
Architecture
┌──────────────────────────────────────────────────────┐
│ Developer Workstation │
│ ┌────────────┐ ┌────────────┐ │
│ │ secret.yaml │───▶│ kubeseal │ │
│ │ (plaintext, │ │ (encryption │ │
│ │ not commit)│ │ tool) │ │
│ └────────────┘ └─────┬──────┘ │
│ │ encrypt │
│ ┌─────▼──────┐ │
│ │sealed-secret│ │
│ │.yaml(cipher)│ │
│ └─────┬──────┘ │
└──────────────────────────┼───────────────────────────┘
│ git push
┌──────────────────────────▼───────────────────────────┐
│ Git Repository │
│ (stores only encrypted SealedSecrets) │
└──────────────────────────┬───────────────────────────┘
│ git pull (ArgoCD/Flux)
┌──────────────────────────▼───────────────────────────┐
│ K8s Cluster │
│ ┌──────────────────────────────┐ │
│ │ Sealed Secrets Controller │ │
│ │ (decrypts with private key) │ │
│ └──────────────┬───────────────┘ │
│ │ decrypt │
│ ┌─────▼──────┐ │
│ │ K8s Secret │ │
│ │ (plaintext, │ │
│ │ in-cluster)│ │
│ └────────────┘ │
└───────────────────────────────────────────────────────┘
Installing Sealed Secrets
# Install Controller to cluster
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.27.0/controller.yaml
# Wait for Controller to be ready
kubectl wait --for=condition=available --timeout=120s \
deployment/sealed-secrets-controller -n kube-system
# Install kubeseal CLI
KUBESEAL_VERSION=0.27.0
curl -sLO "https://github.com/bitnami-labs/sealed-secrets/releases/download/v${KUBESEAL_VERSION}/kubeseal-linux-amd64"
chmod +x kubeseal-linux-amd64
sudo mv kubeseal-linux-amd64 /usr/local/bin/kubeseal
# Verify installation
kubeseal --version
Encrypting Secrets
# Encrypt from plaintext Secret file
kubectl create secret generic db-credentials \
--from-literal=username=admin \
--from-literal=password='SuperSecret123!' \
--dry-run=client -o yaml | \
kubeseal --format yaml > sealed-secret.yaml
# Create from files
kubectl create secret generic tls-cert \
--from-file=tls.crt=server.crt \
--from-file=tls.key=server.key \
--dry-run=client -o yaml | \
kubeseal --format yaml > sealed-tls.yaml
# Specify namespace and name (strict mode)
kubectl create secret generic api-key \
--from-literal=key=abc123xyz \
--namespace production \
--dry-run=client -o yaml | \
kubeseal --format yaml \
--scope namespace-wide > sealed-api-key.yaml
SealedSecret Resource Example
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: db-credentials
namespace: production
spec:
encryptedData:
username: AgBfj8k2mN3pQ7sT9vWxYz...
password: AgCdH5lM6nO8qR0tU2wXyZa...
template:
metadata:
name: db-credentials
namespace: production
type: Opaque
Encryption Scope Control
# strict: can only decrypt with same namespace and name (default)
kubeseal --scope strict
# namespace-wide: can rename within same namespace
kubeseal --scope namespace-wide
# cluster-wide: usable in any namespace in the cluster
kubeseal --scope cluster-wide
| Scope | Security Level | Flexibility | Use Case |
|---|---|---|---|
| strict | Highest | Lowest | Production secrets |
| namespace-wide | Medium | Medium | Multi-app same namespace |
| cluster-wide | Lowest | Highest | Shared resources like certs |
Backup and Restore Master Key
# Backup master key (all SealedSecrets become undecryptable if lost!)
kubectl get secret -n kube-system \
sealed-secrets-key -o yaml > sealed-secrets-key-backup.yaml
# Restore master key to new cluster
kubectl apply -f sealed-secrets-key-backup.yaml
# Rotate master key
kubectl delete secret -n kube-system sealed-secrets-key
# Controller auto-generates new key, old SealedSecrets still work
Pattern 2: External Secrets Operator + Vault
External Secrets Operator (ESO) integrates external secret management systems (Vault, AWS Secrets Manager, GCP Secret Manager, etc.) with Kubernetes. Git repositories only store SecretStore references, and the in-cluster ESO Controller pulls secrets from external systems and creates K8s Secrets.
Architecture
┌──────────────────────────────────────────────────────┐
│ Git Repository │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SecretStore │ │ ExternalSecret│ │
│ │ (Vault conn │ │ (secret ref │ │
│ │ config) │ │ mapping) │ │
│ └──────────────┘ └──────────────┘ │
└──────────────────────┬───────────────────────────────┘
│ git pull
┌──────────────────────▼───────────────────────────────┐
│ K8s Cluster │
│ ┌──────────────────────────────┐ │
│ │ External Secrets Operator │ │
│ │ (watches ExternalSecrets) │ │
│ └──────────────┬───────────────┘ │
│ │ fetch secrets │
│ ┌────────────▼────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ K8s │ │ HashiCorp│ │
│ │ Secret │ │ Vault │ │
│ └──────────┘ └──────────┘ │
└───────────────────────────────────────────────────────┘
Installing ESO
# Install with Helm
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets \
external-secrets/external-secrets \
--namespace external-secrets \
--create-namespace \
--set installCRDs=true \
--set replicaCount=2 \
--set leaderElect=true
# Verify
kubectl get pods -n external-secrets
Configuring HashiCorp Vault SecretStore
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: vault-backend
namespace: production
spec:
provider:
vault:
server: "https://vault.internal.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "eso-role"
serviceAccountRef:
name: "external-secrets-sa"
Vault Policy Configuration
# Vault policy: restrict ESO to specific paths
path "secret/data/production/*" {
capabilities = ["read"]
}
path "secret/data/production/database/*" {
capabilities = ["read", "list"]
}
# Deny access to other environments
path "secret/data/staging/*" {
capabilities = ["deny"]
}
path "secret/data/development/*" {
capabilities = ["deny"]
}
ExternalSecret Resource
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
namespace: production
spec:
refreshInterval: 15m
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: db-credentials
creationPolicy: Owner
template:
type: Opaque
data:
DATABASE_URL: "postgresql://{{ .username }}:{{ .password }}@db.internal:5432/mydb"
data:
- secretKey: username
remoteRef:
key: secret/data/production/database
property: username
- secretKey: password
remoteRef:
key: secret/data/production/database
property: password
Multi-Source: ClusterSecretStore
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: eso-aws-sa
namespace: external-secrets
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: aws-api-keys
namespace: production
spec:
refreshInterval: 5m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: aws-api-keys
dataFrom:
- extract:
key: production/api-keys
Pattern 3: SOPS + Age/GPG Encryption
SOPS (Secrets OPerationS) is a secret encryption tool developed by Mozilla, supporting multiple encryption backends including AES, PGP, and Age. Unlike Sealed Secrets, SOPS encrypts the file itself — it can encrypt YAML/JSON/ENV formats and only encrypts values, not keys. This means you can Git diff to see structural changes in secrets.
Architecture
┌──────────────────────────────────────────────────────┐
│ Developer Workstation │
│ ┌────────────┐ ┌────────────┐ │
│ │ secret.yaml │───▶│ SOPS │ │
│ │ (plaintext, │ │ + Age/GPG │ │
│ │ not commit)│ │ │ │
│ └────────────┘ └─────┬──────┘ │
│ │ encrypt │
│ ┌─────▼──────┐ │
│ │secret.enc. │ │
│ │yaml(cipher) │ │
│ └─────┬──────┘ │
└──────────────────────────┼───────────────────────────┘
│ git push
┌──────────────────────────▼───────────────────────────┐
│ Git Repository │
│ (stores encrypted files, keys visible, │
│ values encrypted) │
└──────────────────────────┬───────────────────────────┘
│ git pull
┌──────────────────────────▼───────────────────────────┐
│ CI/CD Pipeline │
│ ┌──────────────────────────────┐ │
│ │ sops --decrypt + kubectl apply│ │
│ │ or Flux Kustomization SOPS │ │
│ │ integration │ │
│ └──────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
Installing SOPS and Age
# Install SOPS
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.0/sops-v3.9.0.linux.amd64
chmod +x sops-v3.9.0.linux.amd64
sudo mv sops-v3.9.0.linux.amd64 /usr/local/bin/sops
# Install Age (recommended encryption backend, simpler than GPG)
curl -sLO https://github.com/FiloSottile/age/releases/download/v1.2.0/age-v1.2.0-linux-amd64.tar.gz
tar xzf age-v1.2.0-linux-amd64.tar.gz
sudo mv age/age /usr/local/bin/
sudo mv age/age-keygen /usr/local/bin/
# Generate Age key pair
age-keygen -o age.key
# Public key: age1abc123xyz...
# Verify
sops --version
age --version
Encrypting Secret Files
# secret.yaml (before encryption)
apiVersion: v1
kind: Secret
metadata:
name: app-config
namespace: production
type: Opaque
stringData:
DB_HOST: "db.internal.example.com"
DB_PASSWORD: "SuperSecret123!"
API_KEY: "sk-proj-abc123xyz456"
REDIS_URL: "redis://redis.internal:6379"
# Encrypt with Age public key
sops --encrypt \
--age age1abc123xyz456... \
--encrypted-regex '^(DB_PASSWORD|API_KEY)$' \
--in-place secret.yaml
# secret.yaml (after encryption)
apiVersion: v1
kind: Secret
metadata:
name: app-config
namespace: production
type: Opaque
stringData:
DB_HOST: "db.internal.example.com"
DB_PASSWORD: ENC[AES256_GCM,data:Wk5kPQ==,tag:abc123==,type:str]
API_KEY: ENC[AES256_GCM,data:Zm9vYmFy,tag:def456==,type:str]
REDIS_URL: "redis://redis.internal:6379"
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1abc123xyz456...
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA...
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-06-16T10:00:00Z"
mac: ENC[AES256_GCM,data:abc==,tag:xyz==,type:str]
pgp: []
encrypted_regex: ^(DB_PASSWORD|API_KEY)$
version: 3.9.0
Flux CD SOPS Integration
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: app-secrets
namespace: flux-system
spec:
interval: 10m
path: ./clusters/production
prune: true
sourceRef:
kind: GitRepository
name: flux-system
decryption:
provider: sops
secretRef:
name: sops-age-key
# Create Age private key Secret for Flux decryption
kubectl create secret generic sops-age-key \
--namespace flux-system \
--from-file=age.agekey=age.key \
--dry-run=client -o yaml | \
kubeseal --format yaml > sealed-sops-key.yaml
Multi-Key Encryption (Team Collaboration)
# .sops.yaml config file — project-level encryption configuration
cat > .sops.yaml << 'EOF'
creation_rules:
- path_regex: ^clusters/production/.*\.yaml$
key_groups:
- age:
- age1abc123xyz456 # production key
- age1def789uvw012 # SRE team key
- path_regex: ^clusters/staging/.*\.yaml$
key_groups:
- age:
- age1ghi345rst678 # staging key
- path_regex: ^clusters/.*\.yaml$
key_groups:
- age:
- age1abc123xyz456 # default key
EOF
Pattern 4: Automatic Secret Rotation
Secret rotation is the most overlooked aspect of DevOps GitOps secret management. Static secrets that never change create an infinite attack window once leaked. The 2026 best practice: rotate all production secrets every 90 days, and high-sensitivity secrets every 7 days.
Rotation Architecture
┌──────────────────────────────────────────────────────┐
│ Secret Auto-Rotation Architecture │
│ │
│ ┌──────────┐ trigger ┌──────────────┐ │
│ │ CronJob │─────────────▶│ Vault Rotate │ │
│ │ (schedule)│ │ (generate new)│ │
│ └──────────┘ └──────┬───────┘ │
│ │ new secret │
│ ┌──────▼───────┐ │
│ │ ExternalSecret│ │
│ │ (auto refresh)│ │
│ └──────┬───────┘ │
│ │ update │
│ ┌──────▼───────┐ │
│ │ K8s Secret │ │
│ │ (auto update) │ │
│ └──────┬───────┘ │
│ │ rolling restart │
│ ┌──────▼───────┐ │
│ │ Pods │ │
│ │ (read new key)│ │
│ └──────────────┘ │
└──────────────────────────────────────────────────────┘
ESO Auto-Rotation Configuration
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: rotating-api-key
namespace: production
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: api-key
creationPolicy: Owner
template:
type: Opaque
metadata:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
data:
- secretKey: api-key
remoteRef:
key: secret/data/production/api
property: key
Vault Dynamic Secrets
# Vault database dynamic credential configuration
resource "vault_database_secret_backend_connection" "postgresql" {
backend = "database"
name = "postgresql-production"
allowed_roles = ["app-readonly", "app-readwrite"]
postgresql {
connection_url = "postgresql://{{username}}:{{password}}@db.internal:5432/mydb?sslmode=require"
username = "vault_admin"
password = "VaultAdminPassword123!"
}
}
resource "vault_database_secret_backend_role" "app_readwrite" {
backend = "database"
name = "app-readwrite"
db_name = vault_database_secret_backend_connection.postgresql.name
default_ttl = 3600
max_ttl = 86400
creation_statements = [
"CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";"
]
}
Auto-Sensing Secret Changes in Applications
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: api-server
template:
metadata:
labels:
app: api-server
annotations:
secret.reloader.stakater.com/reload: "api-key,db-credentials"
spec:
containers:
- name: api-server
image: registry.example.com/api-server:v2.1.0
envFrom:
- secretRef:
name: api-key
- secretRef:
name: db-credentials
# Install Reloader — auto rolling restart Pods on Secret changes
helm repo add stakater https://stakater.github.io/stakater-charts
helm repo update
helm install reloader stakater/reloader \
--namespace reloader \
--create-namespace \
--set reloader.watchGlobally=false
Pattern 5: Multi-Cluster Secret Synchronization
In multi-cluster scenarios, secrets need to be securely synchronized across clusters. DevOps GitOps secret management requires: each cluster has its own secret lifecycle while maintaining consistency.
Multi-Cluster Architecture
┌──────────────────────────────────────────────────────┐
│ Multi-Cluster Secret Sync Architecture │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ HashiCorp Vault (Central) │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │prod-east/│ │prod-west/│ │ │
│ │ │ secrets │ │ secrets │ │ │
│ │ └──────────┘ └──────────┘ │ │
│ └──────────┬───────────────┬──────────┘ │
│ │ │ │
│ ┌────────▼──────┐ ┌──────▼────────┐ │
│ │ Cluster East │ │ Cluster West │ │
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │
│ │ │ ESO │ │ │ │ ESO │ │ │
│ │ │Controller│ │ │ │Controller│ │ │
│ │ └────┬─────┘ │ │ └────┬─────┘ │ │
│ │ ▼ │ │ ▼ │ │
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │
│ │ │ Secrets │ │ │ │ Secrets │ │ │
│ │ └──────────┘ │ │ └──────────┘ │ │
│ └───────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────┘
Per-Cluster Independent SecretStore
# Cluster East
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: vault-east
namespace: production
spec:
provider:
vault:
server: "https://vault.internal.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes/east"
role: "eso-east-role"
serviceAccountRef:
name: "external-secrets-sa"
# Cluster West
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: vault-west
namespace: production
spec:
provider:
vault:
server: "https://vault.internal.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes/west"
role: "eso-west-role"
serviceAccountRef:
name: "external-secrets-sa"
TLS Certificate Multi-Cluster Distribution
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: wildcard-tls
namespace: ingress-nginx
spec:
refreshInterval: 24h
secretStoreRef:
name: vault-east
kind: SecretStore
target:
name: wildcard-tls
creationPolicy: Owner
template:
type: kubernetes.io/tls
data:
- secretKey: tls.crt
remoteRef:
key: secret/data/shared/tls/wildcard
property: cert
- secretKey: tls.key
remoteRef:
key: secret/data/shared/tls/wildcard
property: key
Sealed Secrets Multi-Cluster Master Key Sync
# Export master key
kubectl get secret -n kube-system sealed-secrets-key \
-o yaml > sealed-secrets-master-key.yaml
# Import to target cluster
kubectl apply -f sealed-secrets-master-key.yaml -n kube-system
# Restart Controller to load key
kubectl rollout restart deployment/sealed-secrets-controller -n kube-system
Pattern 6: Audit and Compliance
DevOps GitOps secret management must satisfy audit requirements: who accessed what secret and when. 2026 compliance standards (SOC2, ISO 27001, MLPS 2.0) all require traceable secret access.
Vault Audit Logs
# Enable Vault audit logging
audit {
type = "file"
options = {
file_path = "/vault/audit/audit.log"
mode = "0600"
}
}
# Enable Syslog audit
audit {
type = "syslog"
options = {
facility = "AUTH"
tag = "vault"
address = "syslog.internal:514"
}
}
ESO Access Logging
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: audited-vault
annotations:
audit.external-secrets.io/enabled: "true"
audit.external-secrets.io/log-access: "true"
spec:
provider:
vault:
server: "https://vault.internal.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "eso-audited-role"
serviceAccountRef:
name: "external-secrets-sa"
Kubernetes Audit Policy
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
namespaces: ["production", "staging"]
omitStages:
- RequestReceived
- level: Metadata
resources:
- group: ""
resources: ["secrets"]
namespaces: ["default"]
omitStages:
- RequestReceived
- level: RequestResponse
resources:
- group: "external-secrets.io"
resources: ["externalsecrets", "secretstores"]
omitStages:
- RequestReceived
Secret Access Monitoring
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: restrict-secret-access
spec:
validationFailureAction: Audit
background: true
rules:
- name: block-default-sa-secret-access
match:
resources:
kinds: [Pod]
validate:
message: "Accessing secrets with default ServiceAccount is forbidden"
pattern:
spec:
serviceAccountName: "!default"
- name: require-secret-annotations
match:
resources:
kinds: [Secret]
names: ["db-*", "api-*", "tls-*"]
validate:
message: "Production secrets must have owner and expiry annotations"
pattern:
metadata:
annotations:
owner: "?*"
expiry: "?*"
5 Common Pitfalls and Solutions
Pitfall 1: Sealed Secrets Master Key Loss
Symptom: After cluster rebuild, all SealedSecrets cannot be decrypted. Controller reports failed to unseal.
Root Cause: Sealed Secrets uses asymmetric encryption. The private key only exists in-cluster. Destroying the cluster loses the private key.
Solution:
# 1. Regular master key backup (automated)
kubectl get secret -n kube-system \
sealed-secrets-key -o yaml | \
sops --encrypt --age age1abc123xyz456... \
/dev/stdin > sealed-secrets-key.enc.yaml
# 2. Store encrypted key in another Git repo
git add sealed-secrets-key.enc.yaml
git commit -m "backup: sealed secrets master key $(date +%Y%m%d)"
# 3. Recovery process
sops --decrypt sealed-secrets-key.enc.yaml | \
kubectl apply -f -
Pitfall 2: ExternalSecret Refresh Delay Causing Pod Startup Failure
Symptom: Newly deployed Pods fail to start because the Secret doesn't exist yet — ESO hasn't pulled it from Vault.
Solution:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
spec:
refreshInterval: 5m
target:
creationPolicy: Owner
template:
type: Opaque
dataFrom:
- extract:
key: production/app-secrets
# Ensure ESO has synced before deploying the application
# Method 1: Use ArgoCD Sync Hooks
# Method 2: Wait for Secret to exist in CI
kubectl wait --for=condition=Ready \
externalsecret/app-secrets -n production --timeout=120s
Pitfall 3: SOPS Key Rotation Breaks Old Ciphertext Decryption
Symptom: After the team rotates Age keys, old encrypted files cannot be decrypted.
Solution:
# Use multi-key encryption — coexist with old and new keys
sops --encrypt \
--age age1NEWKEY...,age1OLDKEY... \
--in-place secret.yaml
# Or manage key groups via .sops.yaml
# After key update, decrypt with old key then re-encrypt with new key
sops --decrypt --age age1OLDKEY... secret.yaml | \
sops --encrypt --age age1NEWKEY... \
--filename-override secret.yaml /dev/stdin > secret_new.yaml
Pitfall 4: Cannot Quickly Revoke Leaked Secrets
Symptom: An API Key is leaked, but SealedSecret requires re-encryption and commit — the revocation window is too long.
Solution:
# ESO approach: directly disable old key in Vault
vault kv metadata put -delete-version-after=0s \
secret/data/production/api-key
# Sealed Secrets approach: emergency deletion via Controller
kubectl delete secret api-key -n production
# Immediately re-encrypt and commit new SealedSecret
# Generic approach: network-level blocking
kubectl apply -f - <<EOF
apiVersion: networkpolicies.k8s.io/v1
kind: NetworkPolicy
metadata:
name: block-compromised
namespace: production
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- compromised-api.example.com/32
EOF
Pitfall 5: Shared Secrets Across Teams Lead to Access Control Chaos
Symptom: Multiple teams use the same Vault Token to access secrets, making it impossible to distinguish who did what.
Solution:
# Create Vault policies per team
path "secret/data/team-a/*" {
capabilities = ["read", "list"]
}
path "secret/data/team-b/*" {
capabilities = ["read", "list"]
}
# Explicit deny for cross-access
path "secret/data/team-a/*" {
capabilities = ["deny"]
}
# Bind to team-b role
# Use Vault namespaces for isolation
namespace "team-a" {
path "secret/*" {
capabilities = ["read", "list", "create", "update"]
}
}
10 Common Error Troubleshooting
1. failed to unseal: no private key found
# Check if Controller holds the master key
kubectl get secrets -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key
# Restore master key
kubectl apply -f sealed-secrets-key-backup.yaml -n kube-system
kubectl rollout restart deployment/sealed-secrets-controller -n kube-system
2. ExternalSecret "not ready": could not get secret data
# Check SecretStore connection
kubectl describe secretstore vault-backend -n production
# Check Vault authentication
kubectl logs -n external-secrets -l app.kubernetes.io/name=external-secrets
# Verify ServiceAccount permissions
kubectl auth can-i get secret -n production \
--as=system:serviceaccount:production:external-secrets-sa
3. sops error decrypting: could not find a matching key
# Check if Age key is correct
age-keygen -y age.key
# Verify .sops.yaml key configuration
cat .sops.yaml
# Try specifying key for decryption
sops --decrypt --age age1YOURKEY... secret.yaml
4. SealedSecret "sealed-secrets" is invalid: metadata.name
# Check namespace and name specified during encryption
# strict mode requires exact match
kubeseal --scope strict --namespace production \
--name db-credentials < secret.yaml > sealed.yaml
5. Vault seal status: sealed
# Check Vault status
vault status
# Unseal Vault (requires 2 of 3 unseal keys)
vault operator unseal <key1>
vault operator unseal <key2>
# Auto-unseal configuration (recommended for production)
# Use AWS KMS / GCP KMS / Azure Key Vault for auto-unseal
6. refreshInterval: cannot unmarshal
# Ensure refreshInterval format is correct
# Correct: "15m", "1h", "24h"
# Wrong: 15, "15", "15minutes"
spec:
refreshInterval: 15m
7. kubeseal: error: failed to get certificate
# Check if Controller is running
kubectl get deployment sealed-secrets-controller -n kube-system
# Check certificate Secret
kubectl get secret -n kube-system sealed-secrets-key
# Fetch public key from Controller
kubeseal --fetch-cert > sealed-secrets-cert.pem
kubeseal --cert sealed-secrets-cert.pem < secret.yaml
8. ExternalSecret status: SecretSyncedError
# View detailed error
kubectl describe externalsecret db-credentials -n production
# Common cause: Vault path doesn't exist
# Check paths in Vault
vault kv list secret/production/
# Common cause: insufficient permissions
vault policy read eso-role
9. SOPS: mac mismatch: file has been modified
# File was manually modified after encryption, MAC verification failed
# Safe approach: re-encrypt
sops --decrypt secret.yaml > secret_plain.yaml
# Modify then re-encrypt
sops --encrypt --age age1YOURKEY... secret_plain.yaml > secret.yaml
10. Kubernetes: secret "xxx" not found
# ESO hasn't finished syncing yet
kubectl get externalsecret -n production
# Check ESO Controller logs
kubectl logs -n external-secrets -l app.kubernetes.io/name=external-secrets \
--tail=100
# Manually trigger sync
kubectl annotate externalsecret db-credentials \
force-sync=$(date +%s) -n production \
--overwrite
Advanced Optimization Tips
1. Git Pre-commit Hook to Prevent Plaintext Secret Commits
#!/bin/bash
# .git/hooks/pre-commit
# Scan staged files for suspected secrets
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.yaml$|\.yml$|\.env$')
for FILE in $STAGED_FILES; do
# Detect Base64-encoded K8s Secret
if grep -qE 'kind:\s*Secret' "$FILE" 2>/dev/null; then
if ! grep -qE 'kind:\s*SealedSecret|encryptedData|sops:' "$FILE" 2>/dev/null; then
echo "ERROR: Unencrypted Secret file found: $FILE"
echo "Please encrypt with kubeseal or sops before committing"
exit 1
fi
fi
# Detect common secret patterns
if grep -qiE '(password|secret|api.key|token)\s*[:=]\s*["\x27]?[A-Za-z0-9+/=]{16,}' "$FILE" 2>/dev/null; then
if ! grep -qE 'ENC\[|sops:|encryptedData' "$FILE" 2>/dev/null; then
echo "WARNING: Suspected plaintext secret in: $FILE"
echo "Please confirm encryption or use external secret management"
fi
fi
done
exit 0
2. ArgoCD Secret Management Integration
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: app-with-secrets
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/myorg/gitops-manifests.git
targetRevision: main
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
3. Secret Template Engine
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-config
namespace: production
spec:
refreshInterval: 15m
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: app-config
template:
type: Opaque
engineVersion: v2
data:
DATABASE_URL: "postgresql://{{ .db_user }}:{{ .db_pass }}@{{ .db_host }}:5432/{{ .db_name }}?sslmode=require"
REDIS_URL: "redis://:{{ .redis_pass }}@{{ .redis_host }}:6379/0"
JWT_SECRET: "{{ .jwt_secret }}"
CONFIG_JSON: |
{
"database": {
"host": "{{ .db_host }}",
"port": 5432,
"name": "{{ .db_name }}"
},
"redis": {
"host": "{{ .redis_host }}"
}
}
data:
- secretKey: db_user
remoteRef:
key: secret/data/production/database
property: username
- secretKey: db_pass
remoteRef:
key: secret/data/production/database
property: password
- secretKey: db_host
remoteRef:
key: secret/data/production/database
property: host
- secretKey: db_name
remoteRef:
key: secret/data/production/database
property: dbname
- secretKey: redis_pass
remoteRef:
key: secret/data/production/redis
property: password
- secretKey: redis_host
remoteRef:
key: secret/data/production/redis
property: host
- secretKey: jwt_secret
remoteRef:
key: secret/data/production/auth
property: jwt_secret
4. Secret Health Check
apiVersion: batch/v1
kind: CronJob
metadata:
name: secret-health-check
namespace: security
spec:
schedule: "0 8 * * 1"
jobTemplate:
spec:
template:
spec:
serviceAccountName: secret-checker
containers:
- name: checker
image: bitnami/kubectl:1.30
command:
- /bin/bash
- -c
- |
echo "=== Secret Health Check $(date) ==="
echo ""
echo "--- Checking expiring TLS certificates ---"
kubectl get secrets --all-namespaces \
-o json | jq -r '.items[] |
select(.type=="kubernetes.io/tls") |
"\(.metadata.namespace)/\(.metadata.name)"' | \
while read secret; do
ns=$(echo $secret | cut -d/ -f1)
name=$(echo $secret | cut -d/ -f2)
cert=$(kubectl get secret $name -n $ns \
-o jsonpath='{.data.tls\.crt}' | base64 -d)
expiry=$(echo "$cert" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -n "$expiry" ]; then
days=$(( ($(date -d "$expiry" +%s) - $(date +%s)) / 86400 ))
if [ $days -lt 30 ]; then
echo "WARNING: $secret certificate expires in ${days} days"
fi
fi
done
echo ""
echo "--- Checking ExternalSecret sync status ---"
kubectl get externalsecrets --all-namespaces \
-o json | jq -r '.items[] |
select(.status.conditions[]?.type=="Ready" and
.status.conditions[]?.status!="True") |
"\(.metadata.namespace)/\(.metadata.name): NOT READY"'
restartPolicy: OnFailure
Comparison: Sealed Secrets vs External Secrets vs SOPS
| Dimension | Sealed Secrets | External Secrets Operator | SOPS |
|---|---|---|---|
| Encryption | Asymmetric (cluster pubkey) | No Git content encryption | Symmetric/Asymmetric |
| Git Content | Ciphertext (SealedSecret) | References (ExternalSecret) | Ciphertext (values encrypted) |
| External Deps | None (self-contained) | Vault/AWS/GCP etc. | None (self-contained) |
| Secret Rotation | Re-encrypt required | Native support | Re-encrypt required |
| Multi-Cluster | Sync master key | Per-cluster SecretStore | Share encryption key |
| Git Diff Friendly | No (ciphertext unreadable) | Yes (references readable) | Yes (keys readable, values encrypted) |
| Offline Decrypt | No (needs cluster privkey) | No (needs external service) | Yes (local key sufficient) |
| Learning Curve | Low | Medium | Medium |
| Ops Complexity | Low | High (need Vault) | Low |
| Audit Capability | Git history | Vault audit logs | Git history |
| Dynamic Secrets | Not supported | Supported (Vault dynamic creds) | Not supported |
| Scale Fit | Small-medium teams | Medium-large enterprises | Any scale |
| Best For | Simple GitOps | Enterprise secret management | Multi-format encryption |
Selection Decision Tree
Do you already have Vault/AWS Secrets Manager?
├── Yes → External Secrets Operator
│ └── Need dynamic secrets? → ESO + Vault dynamic credentials
└── No → Need to store encrypted ciphertext in Git?
├── Yes → Need Git Diff friendly?
│ ├── Yes → SOPS + Age
│ └── No → Sealed Secrets
└── No → Need dynamic secrets?
├── Yes → Deploy Vault + ESO
└── No → Sealed Secrets
Recommended Online Tools
- Base64 Encode/Decode: /en/encode/base64 — Encode/decode K8s Secret and SealedSecret data
- RSA Key Generator: /en/encode/rsa — Generate RSA key pairs for SOPS GPG encryption
- Hash Calculator: /en/encode/hash — Calculate secret fingerprints and checksums
Summary: There's no silver bullet for DevOps GitOps secret management, but there is a best-practice path. Small teams start with Sealed Secrets — zero external dependencies for GitOps secret security. Medium-to-large enterprises choose External Secrets Operator + Vault, with native secret rotation and dynamic credentials. When you need multi-format encryption and Git Diff friendliness, SOPS + Age is the best choice. The 2026 core principles: zero plaintext storage, automatic rotation, auditable and traceable. Remember — secret leaks aren't a matter of "if" but "when."
Related Posts:
- GitOps with ArgoCD in Production — Complete ArgoCD deployment automation guide
- GitOps with Flux CD in Production — Flux CD continuous delivery in practice
- Docker Container Security Hardening — 8-layer container defense system
External References:
Try these browser-local tools — no sign-up required →