---
title: "ArgoCD Secrets Management: Sealed Secrets, External Secrets Operator, and SOPS"
description: "Managing secrets in a GitOps workflow where everything lives in Git but secrets cannot be stored in plaintext. Covers Sealed Secrets, External Secrets Operator, and SOPS with practical ArgoCD integration patterns."
url: https://agent-zone.ai/knowledge/cicd/argocd-secrets-management/
section: knowledge
date: 2026-02-22
categories: ["cicd"]
tags: ["argocd","gitops","secrets","sealed-secrets","external-secrets","sops","security"]
skills: ["gitops-secrets","secrets-management","argocd-patterns"]
tools: ["argocd","kubectl","kubeseal","sops","external-secrets"]
levels: ["intermediate"]
word_count: 1482
formats:
  json: https://agent-zone.ai/knowledge/cicd/argocd-secrets-management/index.json
  html: https://agent-zone.ai/knowledge/cicd/argocd-secrets-management/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=ArgoCD+Secrets+Management%3A+Sealed+Secrets%2C+External+Secrets+Operator%2C+and+SOPS
---


# 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 normally
```

### Installation

```bash
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-controller
```

Install the CLI:

```bash
# 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/kubeseal
```

### Encrypting a Secret

Start with a regular Secret manifest:

```yaml
# 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:

```bash
kubeseal --format yaml < my-secret.yaml > sealed-secret.yaml
```

The output is a SealedSecret resource:

```yaml
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-app
```

Commit `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.

```bash
# 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.yaml
```

### ArgoCD 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:

```yaml
# 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 hs
```

### Key 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:

```bash
kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > sealed-secrets-key-backup.yaml
```

Store 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:

```bash
kubeseal --re-encrypt < sealed-secret.yaml > sealed-secret-new.yaml
```

## Approach 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 normally
```

### Installation

```bash
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace
```

### Configuring 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:

```yaml
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-secrets
```

HashiCorp Vault example:

```yaml
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-secrets
```

### Creating an ExternalSecret

This is what goes in Git -- a reference to the external secret, not the secret value:

```yaml
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: password
```

ESO 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:

```yaml
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: password
```

This 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 cluster
```

### Encrypting with SOPS and age

`age` is the simplest key management option for SOPS:

```bash
# 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
EOF
```

Encrypt a Secret manifest:

```bash
sops --encrypt --in-place secrets/db-credentials.yaml
```

The result keeps YAML structure intact but encrypts the values:

```yaml
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:

```yaml
# 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/age
```

Create the age key as a Kubernetes Secret in the ArgoCD namespace:

```bash
kubectl create secret generic sops-age-key \
  --from-file=keys.txt=key.txt \
  --namespace argocd
```

In your application manifests, use a Kustomize generator with KSOPS:

```yaml
# kustomization.yaml
generators:
  - secret-generator.yaml

# secret-generator.yaml
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
  name: db-credentials
files:
  - secrets/db-credentials.yaml
```

### ArgoCD Integration with helm-secrets

For Helm-based applications, the `helm-secrets` plugin decrypts SOPS-encrypted values files:

```yaml
spec:
  source:
    path: charts/my-app
    helm:
      valueFiles:
        - values.yaml
        - secrets+age-import:///sops/age/keys.txt?secrets.yaml
```

## Choosing 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

1. **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.
2. **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.
3. **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.
4. **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.
5. **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.

