Skip to main content

Secrets Management

This homelab uses Sealed Secrets for GitOps-compatible secrets management, allowing encrypted secrets to be safely stored in Git and automatically decrypted by the cluster.

Overview

  • Solution: Bitnami Sealed Secrets
  • Namespace: kube-system (controller)
  • Deployment: Managed by ArgoCD
  • Sync Wave: -25 (deploys early, before applications needing secrets)

Why Sealed Secrets?

The Problem with Kubernetes Secrets

Standard Kubernetes Secrets are base64-encoded (not encrypted) and cannot be safely committed to Git repositories. This creates challenges for GitOps workflows where all configuration should be version-controlled.

Previous Approach: git-crypt

Previously, secrets were encrypted with git-crypt in the repository. However:

  • ArgoCD cannot decrypt git-crypt files
  • Required manual kubectl apply before ArgoCD sync
  • Not truly GitOps-compliant

Sealed Secrets Solution

Sealed Secrets solves this by:

  • Encrypting secrets client-side using the cluster's public key
  • Storing encrypted SealedSecret CRDs in Git (safe to commit)
  • Decrypting at runtime via the Sealed Secrets controller
  • Full GitOps compatibility - ArgoCD can sync SealedSecrets directly

Architecture

┌─────────────────────────────────────────────────────────────────┐
│ Sealed Secrets Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Developer Workstation Kubernetes Cluster │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ │ kubeseal │ Sealed Secrets │ │
│ │ Plain Secret │ ──────────► │ Controller │ │
│ │ (local only) │ │ (kube-system) │ │
│ └──────────────────┘ └──────────┬───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ SealedSecret │ Git Push │ Decrypted Secret │ │
│ │ (encrypted) │ ──────────► │ (runtime only) │ │
│ │ Stored in Git │ ArgoCD │ Used by Pods │ │
│ └──────────────────┘ └──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘

Managed Secrets

Secret NameNamespaceApplicationPurpose
unipoller-secretunipollerUniFi PollerUniFi controller API key
cloudflare-api-tokenexternal-dnsExternal DNSCloudflare DNS API token
unifi-credentialsexternal-dnsExternal DNSUniFi webhook credentials
alertmanager-smtp-credentialsdefaultAlertmanagerEmail notification SMTP credentials
snmp-exporter-credentialsdefaultSNMP ExporterSynology NAS SNMPv3 credentials
cloudflare-api-token-secretcert-managercert-managerDNS01 challenge API token
client-info-secretsynology-csiSynology CSINAS iSCSI authentication
velero-b2-credentialsveleroVeleroBackblaze B2 backup storage credentials

Secrets NOT Managed by Sealed Secrets

SecretReasonHow to Apply
my-ssh-repo-secret-homelabBootstrap secret - ArgoCD needs this to read the repokubectl apply -f secrets/argocd-git-access.yaml
kube-prometheus-stack-grafanaAuto-generated by Helm chartManaged automatically

Using kubeseal

Prerequisites

Install the kubeseal CLI:

# macOS
brew install kubeseal

# Linux
wget https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/kubeseal-0.24.0-linux-amd64.tar.gz
tar -xvzf kubeseal-0.24.0-linux-amd64.tar.gz
sudo install -m 755 kubeseal /usr/local/bin/kubeseal

Creating a New SealedSecret

Step 1: Create a standard Kubernetes Secret YAML:

# my-secret.yaml (DO NOT commit this file)
apiVersion: v1
kind: Secret
metadata:
name: my-secret
namespace: my-namespace
type: Opaque
stringData:
username: myuser
password: mysecretpassword

Step 2: Seal the secret using kubeseal:

kubeseal --controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
< my-secret.yaml \
> my-sealed.yaml

Step 3: Commit the SealedSecret:

# The sealed secret is safe to commit
git add my-sealed.yaml
git commit -m "feat: Add my-secret as SealedSecret"

Step 4: Delete the unencrypted secret:

rm my-secret.yaml

Secret Rotation Procedures

Rotating a Secret Value

Use this procedure when a credential needs to be updated (e.g., password change, API token refresh, compromised secret).

Step 1: Create the New Plain Secret

Create a temporary file with the new secret value. Never commit this file to Git.

# Example: Rotating cloudflare-api-token for external-dns
cat > /tmp/new-secret.yaml <<EOF
apiVersion: v1
kind: Secret
metadata:
name: cloudflare-api-token
namespace: external-dns
type: Opaque
stringData:
cloudflare_api_token: "NEW_TOKEN_VALUE_HERE"
EOF

Step 2: Seal the New Secret

kubeseal --controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
< /tmp/new-secret.yaml \
> manifests/base/external-dns/cloudflare-sealed.yaml

Step 3: Clean Up Plain Secret

rm /tmp/new-secret.yaml

Step 4: Commit and Deploy

# Create feature branch
git checkout -b rotate/external-dns-cloudflare-token

# Commit the updated sealed secret
git add manifests/base/external-dns/cloudflare-sealed.yaml
git commit -m "chore: Rotate cloudflare-api-token for external-dns"

# Push and create PR
git push -u origin rotate/external-dns-cloudflare-token
gh pr create --title "chore: Rotate cloudflare-api-token" \
--body "Rotates the Cloudflare API token for external-dns."

Step 5: Verify Deployment

After the PR is merged and ArgoCD syncs:

# Verify the secret was updated (check annotation timestamp)
kubectl get secret cloudflare-api-token -n external-dns -o jsonpath='{.metadata.annotations}'

# Verify the application is working
kubectl logs -n external-dns deployment/external-dns-cloudflare | tail -20

Step 6: Restart Affected Pods (if needed)

Some applications cache secrets and need a restart to pick up new values:

kubectl rollout restart deployment/external-dns-cloudflare -n external-dns
kubectl rollout status deployment/external-dns-cloudflare -n external-dns

Batch Secret Rotation

For rotating multiple secrets at once (e.g., after a security incident):

#!/bin/bash
# batch-rotate-secrets.sh

SECRETS=(
"unipoller:unipoller-secret:manifests/base/unipoller/unipoller-sealed.yaml"
"external-dns:cloudflare-api-token:manifests/base/external-dns/cloudflare-sealed.yaml"
"external-dns:unifi-credentials:manifests/base/external-dns/unifi-sealed.yaml"
)

for entry in "${SECRETS[@]}"; do
IFS=':' read -r namespace name path <<< "$entry"
echo "Processing: $name in $namespace"
echo " - Update /tmp/${name}.yaml with new values"
echo " - Then run: kubeseal --controller-name=sealed-secrets-controller --controller-namespace=kube-system --format yaml < /tmp/${name}.yaml > $path"
done

Sealing Key Management

The sealing key is an RSA key pair stored as a Kubernetes Secret in kube-system. The controller uses the private key to decrypt SealedSecrets.

Backup Sealing Key

Critical

Back up the sealing key immediately after cluster creation. Without this backup, all SealedSecrets become unrecoverable if the cluster is rebuilt.

# Export all sealing keys
kubectl get secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key=active \
-o yaml > sealed-secrets-key-backup.yaml

# Verify the backup contains the key
grep -c "tls.crt" sealed-secrets-key-backup.yaml
grep -c "tls.key" sealed-secrets-key-backup.yaml

Store securely:

  • Password manager (1Password, Bitwarden)
  • Encrypted USB drive
  • Cloud storage with client-side encryption

Rotate Sealing Key

Key rotation creates a new sealing key while keeping old keys for decryption.

# 1. Backup current key first
kubectl get secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key=active \
-o yaml > sealed-secrets-key-backup-$(date +%Y%m%d).yaml

# 2. Delete the controller pod to trigger new key generation
kubectl delete pod -n kube-system -l app.kubernetes.io/name=sealed-secrets

# 3. Wait for controller to restart
kubectl wait --for=condition=ready pod \
-l app.kubernetes.io/name=sealed-secrets \
-n kube-system \
--timeout=60s

# 4. Verify new key was created
kubectl get secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key \
--show-labels

Re-seal All Secrets After Key Rotation

After rotating the sealing key, re-seal all secrets to use the new key:

#!/bin/bash
# reseal-all-secrets.sh

SEALED_SECRETS=(
"manifests/base/unipoller/unipoller-sealed.yaml:unipoller-secret:unipoller"
"manifests/base/external-dns/cloudflare-sealed.yaml:cloudflare-api-token:external-dns"
"manifests/base/external-dns/unifi-sealed.yaml:unifi-credentials:external-dns"
"manifests/base/kube-prometheus-stack/alertmanager-smtp-sealed.yaml:alertmanager-smtp-credentials:default"
"manifests/base/kube-prometheus-stack/snmp-exporter-sealed.yaml:snmp-exporter-credentials:default"
"manifests/base/cert-manager/cloudflare-sealed.yaml:cloudflare-api-token-secret:cert-manager"
"manifests/base/synology-csi/client-info-sealed.yaml:client-info-secret:synology-csi"
"manifests/base/velero/b2-credentials-sealed.yaml:velero-b2-credentials:velero"
)

for entry in "${SEALED_SECRETS[@]}"; do
IFS=':' read -r path name namespace <<< "$entry"
echo "Re-sealing: $name ($namespace)"

# Get current decrypted secret from cluster and re-seal
kubectl get secret "$name" -n "$namespace" -o yaml | \
kubectl neat | \
kubeseal --controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml > "$path"

echo " Updated: $path"
done

echo "Done! Review changes with: git diff"
note

This script requires kubectl-neat plugin: kubectl krew install neat

Restore Sealing Key

Use this procedure when rebuilding the cluster:

# 1. Install Sealed Secrets controller first (ArgoCD will do this)

# 2. Delete the auto-generated key
kubectl delete secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key

# 3. Restore from backup
kubectl apply -f sealed-secrets-key-backup.yaml

# 4. Restart the controller to load the restored key
kubectl delete pod -n kube-system -l app.kubernetes.io/name=sealed-secrets

# 5. Verify restoration
kubectl logs -n kube-system -l app.kubernetes.io/name=sealed-secrets | grep -i "sealed secrets"

Disaster Recovery

Scenario 1: Cluster Rebuilt, Backup Available

  1. Deploy new cluster with ArgoCD

  2. Restore sealing key backup (see above)

  3. ArgoCD will sync all SealedSecrets

  4. Verify secrets are decrypted:

    kubectl get secrets -A | grep -v default-token

Scenario 2: Cluster Rebuilt, No Backup

warning

If the sealing key backup is lost, all SealedSecrets are unrecoverable. You must regenerate all credentials.

SecretServiceRotation Procedure
cloudflare-api-tokenExternal DNSGenerate new API token in Cloudflare dashboard
cloudflare-api-token-secretCert ManagerUse same token as External DNS or create dedicated one
unifi-credentialsExternal DNSCreate new local user in UniFi controller
unipoller-secretUniPollerUse existing UniFi read-only user or create new one
alertmanager-smtp-credentialsAlertManagerGenerate app password in email provider
snmp-exporter-credentialsSNMP ExporterSNMP community string from Synology NAS
client-info-secretSynology CSIClient credentials from Synology DSM
velero-b2-credentialsVeleroCreate new B2 application key in Backblaze dashboard

Scenario 3: Single Secret Corrupted

If a single SealedSecret is corrupted in Git:

# Get the current decrypted secret from cluster
kubectl get secret <name> -n <namespace> -o yaml | kubectl neat > /tmp/current-secret.yaml

# Re-seal
kubeseal --controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
< /tmp/current-secret.yaml \
> path/to/sealed-secret.yaml

# Clean up and commit
rm /tmp/current-secret.yaml
git add path/to/sealed-secret.yaml
git commit -m "fix: Regenerate corrupted sealed secret"

Troubleshooting

SealedSecret Not Decrypting

Symptoms: Secret not created, SealedSecret shows error in events

# Check SealedSecret status
kubectl describe sealedsecret <name> -n <namespace>

# Check controller logs
kubectl logs -n kube-system -l app.kubernetes.io/name=sealed-secrets --tail=50

Common causes:

  • Wrong namespace (SealedSecrets are namespace-scoped by default)
  • Sealing key mismatch (sealed with different key)
  • Corrupted encrypted data (re-seal from plain secret)

"Unable to decrypt" Error

# Error: "no key could decrypt secret"

# Verify available keys
kubectl get secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key

# If key was rotated, re-seal the secret with current key

kubeseal Connection Failed

# Error: "cannot fetch certificate"

# Verify controller is running
kubectl get pods -n kube-system -l app.kubernetes.io/name=sealed-secrets

# Verify service exists
kubectl get svc -n kube-system sealed-secrets-controller

Secret Updated But Application Uses Old Value

Some applications cache secrets. Force a refresh:

kubectl rollout restart deployment/<name> -n <namespace>

Controller Deployment

ArgoCD Application

Location: manifests/applications/seal-controller.yaml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: sealed-secrets
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-25"
spec:
project: infrastructure
sources:
- repoURL: https://bitnami-labs.github.io/sealed-secrets
chart: sealed-secrets
targetRevision: 2.18.0
helm:
valueFiles:
- $values/manifests/base/sealed-secrets/values.yaml
- repoURL: git@github.com:imcbeth/homelab.git
path: manifests/base/sealed-secrets
targetRevision: HEAD
ref: values
destination:
server: https://kubernetes.default.svc
namespace: kube-system

Resource Configuration

Optimized for Raspberry Pi cluster:

resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi

Actual usage: ~1m CPU, ~9Mi memory

Quick Reference

Seal a New Secret

kubeseal --controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
< plain-secret.yaml \
> sealed-secret.yaml

Backup Sealing Key

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

View Controller Logs

kubectl logs -n kube-system -l app.kubernetes.io/name=sealed-secrets -f

List All SealedSecrets

kubectl get sealedsecrets -A

Best Practices

Security

  1. Never commit unencrypted secrets - Always use kubeseal
  2. Back up sealing keys - Store securely for disaster recovery
  3. Rotate keys periodically - Re-seal secrets with new keys
  4. Limit secret scope - Use namespace-specific secrets when possible

GitOps Workflow

  1. Use output redirect - Always use kubeseal ... > file.yaml instead of copy-paste
  2. Name convention - Use *-sealed.yaml suffix for SealedSecret files
  3. Pre-commit exclusions - SealedSecrets are excluded from yamllint (long encrypted lines)
  4. Test in staging - Verify secrets work before promoting to production

Helm-Managed Secrets

Don't use SealedSecrets for secrets managed by Helm charts:

  • Grafana admin password (auto-generated)
  • Other chart-created secrets

These secrets are created by Helm and would conflict with SealedSecrets.

References


Last Updated: 2026-02-12 Status: Production, All secrets migrated Managed By: ArgoCD (manifests/applications/seal-controller.yaml)