GitOps Secrets Management

Kubernetes Secrets must not be committed to git in plaintext. Two production-grade approaches: Sealed Secrets (encrypt-in-git) and External Secrets Operator (reference-in-git).

Most sections below end with a quick knowledge check — track your progress as you go:

0/0 checks

The Problem

flowchart LR
    BAD["kubectl create secret generic db-pass<br/>--from-literal=password=s3cr3t<br/>--> base64 in git = plaintext"]:::warn
    GOOD1["Sealed Secrets<br/>Encrypt with cluster key<br/>commit ciphertext to git"]
    GOOD2["External Secrets Operator<br/>Store secret in AWS SM/Vault<br/>git holds only a reference"]
    BAD -->|"never"| GOOD1
    BAD -->|"never"| GOOD2

    classDef warn fill:#e74c3c,stroke:#c0392b,color:#fff

Base64 is NOT encryption. A K8s Secret in git is fully readable by anyone with repo access.

A teammate argues that base64-encoding a Secret before committing it is "safe enough" since it's not human-readable. What's wrong with that reasoning?


Sealed Secrets

How it works

sequenceDiagram
    participant Dev as Developer
    participant kubeseal as kubeseal CLI
    participant Ctrl as SealedSecrets Controller (in cluster)
    participant K8s as Kubernetes API

    Dev->>kubeseal: kubeseal --raw < secret.yaml
    kubeseal->>Ctrl: Fetch cluster public key
    Ctrl-->>kubeseal: RSA public key
    kubeseal->>kubeseal: Encrypt with public key
    kubeseal-->>Dev: SealedSecret YAML (safe to commit)
    Dev->>K8s: git commit + ArgoCD syncs SealedSecret
    K8s->>Ctrl: SealedSecret created
    Ctrl->>Ctrl: Decrypt with private key (only in cluster)
    Ctrl->>K8s: Create real Kubernetes Secret

Key properties:

  • Ciphertext is cluster-specific — a secret sealed for cluster A cannot be decrypted by cluster B
  • Private key lives only in the cluster (sealed-secrets-key Secret in kube-system)
  • Safe to commit the SealedSecret YAML to any git repo, including public repos

You copy a working SealedSecret YAML from staging's git repo into production's, expecting production's controller to decrypt it. What happens?

Install

# Install controller via Helm
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets -n kube-system

# Install kubeseal CLI (macOS)
brew install kubeseal

Sealing a secret

# 1. Create a plain secret YAML (do NOT apply this to cluster)
kubectl create secret generic db-credentials \
  --from-literal=password=s3cr3t \
  --from-literal=username=appuser \
  --dry-run=client -o yaml > secret.yaml

# 2. Seal it (fetches public key from cluster automatically)
kubeseal --format yaml < secret.yaml > sealed-secret.yaml

# 3. Commit sealed-secret.yaml to git — safe!
# ArgoCD syncs it, controller decrypts it into a real Secret

# Seal for a specific namespace (secret is namespace-scoped by default)
kubeseal --namespace production --format yaml < secret.yaml > sealed-secret.yaml

SealedSecret output

apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: db-credentials
  namespace: production
spec:
  encryptedData:
    password: AgBk3n...  # encrypted, safe to commit
    username: AgCm2p...
  template:
    metadata:
      name: db-credentials
      namespace: production

Key rotation

# The controller generates a new key every 30 days automatically
# Old keys are retained for decryption of existing secrets

# Manually rotate (emergency — after key compromise)
kubectl -n kube-system delete secret sealed-secrets-key
kubectl -n kube-system rollout restart deployment/sealed-secrets-controller
# All existing SealedSecrets must be re-sealed with the new key!

# Backup the private key (store in a vault, not in git)
kubectl -n kube-system get secret sealed-secrets-key -o yaml > sealed-secrets-key-backup.yaml

An emergency rotation isn't instant — it's a sequence, and every existing SealedSecret is broken until the last step completes. Step through it:

1. Compromise suspected. The private key (or something with access to it) may have leaked. Every SealedSecret currently in git was sealed against the key you're about to invalidate.
2. Delete the key. kubectl -n kube-system delete secret sealed-secrets-key. The old keypair is gone from the cluster the moment this runs.
3. Restart the controller. On restart, with no existing key found, the controller generates a brand-new RSA keypair. Every SealedSecret already applied and decrypted into a real Secret keeps working — but nothing new can be sealed against the old public key anymore.
4. Re-seal everything. Every SealedSecret YAML in every git repo was encrypted against the now-deleted public key. Each one must be re-created with kubeseal against the new key and re-committed before it can be applied or updated again.

Limitations

  • If the controller's private key is lost, all sealed secrets are unrecoverable
  • Rotating the key requires re-sealing all secrets
  • Secret values are static — rotation requires a new seal and commit

External Secrets Operator (ESO)

How it works

flowchart TD
    SM["AWS Secrets Manager<br/>(or Vault, SSM, GCP SM)"] -->|"ESO syncs"| K8S_SECRET["Kubernetes Secret<br/>(auto-created, kept in sync)"]
    GIT["Git: ExternalSecret CRD<br/>(only a reference — no value)"] -->|"ArgoCD applies"| ES_CRD["ExternalSecret object in cluster"]
    ES_CRD -->|"ESO controller reads"| SM
    K8S_SECRET -->|"mounted as env/volume"| POD["Pod"]

Key difference from Sealed Secrets: the actual secret value never touches git. Git contains only ExternalSecret — a pointer to where the secret lives.

Someone gets full read access to the git repo holding your ExternalSecret manifests. Have they obtained your database password?

Install

helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets -n external-secrets --create-namespace

EKS + IRSA pattern (recommended for AWS)

ESO needs permission to read from AWS Secrets Manager. Use IRSA — no static credentials.

# 1. Create IAM policy for ESO
cat > eso-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
    "Resource": "arn:aws:secretsmanager:us-east-1:123456789:secret:my-app/*"
  }]
}
EOF
aws iam create-policy --policy-name ESO-SecretsManager --policy-document file://eso-policy.json

# 2. Create IAM role with trust policy for ESO service account
eksctl create iamserviceaccount \
  --name external-secrets \
  --namespace external-secrets \
  --cluster my-cluster \
  --attach-policy-arn arn:aws:iam::123456789:policy/ESO-SecretsManager \
  --approve

SecretStore (cluster-scoped)

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          # the IRSA service account
            namespace: external-secrets

ExternalSecret (commit this to git)

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
  namespace: production
spec:
  refreshInterval: 1h                        # re-sync from AWS SM every hour
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: db-credentials                     # name of the K8s Secret to create
    creationPolicy: Owner                    # ESO owns the secret lifecycle
  data:
    - secretKey: password                    # key in K8s Secret
      remoteRef:
        key: my-app/production/db            # path in AWS Secrets Manager
        property: password                   # JSON key within the secret
    - secretKey: username
      remoteRef:
        key: my-app/production/db
        property: username

Secret rotation (ESO advantage)

# Update secret in AWS Secrets Manager
aws secretsmanager update-secret \
  --secret-id my-app/production/db \
  --secret-string '{"username":"appuser","password":"n3w_p4ss"}'

# ESO syncs automatically within refreshInterval (default 1h)
# Force immediate refresh:
kubectl annotate externalsecret db-credentials -n production \
  force-sync=$(date +%s) --overwrite

No git commit anywhere in this flow — that's the whole point of the ESO sync loop. Step through what actually happens:

1. Value changes at the source. Someone (or an automated rotation Lambda) updates the secret in AWS Secrets Manager. Git and the cluster don't know yet.
2. ESO polls on its own schedule. The controller re-reads the remote value at most once per refreshInterval (default 1h in the example above) — it isn't watching for changes in real time.
3. The Kubernetes Secret is rewritten in place. ESO updates the existing db-credentials Secret with the new value. The ExternalSecret object in git is untouched — it never held the value, so there's nothing in it to change.
4. Or skip the wait. Don't want to sit through step 2's interval? Annotate the ExternalSecret with force-sync=$(date +%s) to trigger an immediate re-sync instead of waiting for the next scheduled poll.

Comparison

Sealed Secrets External Secrets Operator
Secret storage Encrypted in git External store (AWS SM, Vault, SSM)
Git content Ciphertext (SealedSecret YAML) Reference only (ExternalSecret YAML)
Rotation Requires re-seal + git commit Automatic (ESO re-syncs)
Multi-cluster Each cluster needs its own seal One ESO + SecretStore per cluster, same SM
Key loss risk Unrecoverable if private key lost No key — secrets always in SM
AWS native No Yes (IRSA, SM versioning, rotation)
Audit trail Git history AWS CloudTrail + SM version history
Best for Simple setups, no external vault Production EKS, secret rotation needed

Which approach carries the risk of secrets becoming permanently unrecoverable if a key is lost — and why doesn't the other one have that risk?


Decision

Pick the branch that matches your environment:

ESO + AWS Secrets Manager + IRSA.
Secrets rotate without git commits.
CloudTrail audits every secret access.
IRSA means no static credentials sitting in the cluster.
Sealed Secrets.
Backup the private key offline.
Re-seal on key rotation.