Skip to content

End-to-End Secrets Management & Key Rotation Governance: Best Practices for Infrastructure, Applications, and CI/CD

Introduction

Secrets management is the foundation of infrastructure security. This guide covers the complete lifecycle: creation, storage, distribution, injection, rotation, revocation, and auditing. It addresses both connected and air-gapped environments, supporting Kubernetes (RKE2), Docker Compose, bare-metal services, and CI/CD pipelines.

What This Guide Covers: - Secret storage options (Vault, SOPS, KMS, Kubernetes Secrets) - Injection patterns for all workload types - Automated key rotation strategies - CI/CD integration (GitHub Actions, GitLab CI) - Air-gapped environment patterns - Logging, auditing, and incident response - Reference implementations

Prerequisites: - Understanding of Kubernetes, Docker, and CI/CD basics - Familiarity with infrastructure as code (Terraform, Ansible) - Access to infrastructure for implementation

Scope & Goals

Complete Lifecycle Coverage

This guide addresses every phase of secrets management:

  1. Creation: How secrets are generated (random passwords, PKI certs, API keys)
  2. Storage: Where secrets are stored (Vault, SOPS, KMS, encrypted at rest)
  3. Distribution: How secrets reach workloads (injection, sidecars, env vars)
  4. Injection: How applications consume secrets (files, env vars, API calls)
  5. Expiration: How secrets expire and trigger rotation
  6. Rotation: How secrets are rotated without downtime
  7. Revocation: How compromised secrets are invalidated
  8. Auditing: How secret access is logged and monitored

Integration Requirements

Must Support: - RKE2 Kubernetes clusters with Rancher management - Docker Compose for local and edge deployments - Terraform/Ansible for infrastructure as code - Air-gapped environments with no external API access - Machine identities (service-to-service auth, not just human users)

Workload Types: - PostgreSQL databases (primary/replica clusters) - Redis clusters - MLflow tracking servers - Grafana/Prometheus/Loki observability stack - NGINX reverse proxies - Internal APIs and microservices - CI/CD pipelines (GitHub Actions, GitLab CI)

Non-Goals

  • Not a basic "don't commit secrets" tutorial: Assumes you already know this
  • Not vendor-specific: Covers multiple approaches (Vault, SOPS, KMS, native K8s)
  • Not theoretical: Every pattern includes working examples

Core Architecture

System Overview

graph TB
    subgraph Sources["Secret Sources"]
        Vault["HashiCorp Vault"]
        SOPS["SOPS + Git"]
        KMS["Cloud KMS<br/>(AWS/GCP/Azure)"]
        K8S["Kubernetes Secrets<br/>(encrypted at rest)"]
        ESO["External Secrets<br/>Operator"]
    end

    subgraph Rotation["Rotation Controllers"]
        Cron["Cron Jobs<br/>(pg_cron, systemd)"]
        Ansible["Ansible Playbooks"]
        GitOps["GitOps Operators<br/>(Flux, ArgoCD)"]
        CertMgr["cert-manager<br/>(ACME, CA)"]
    end

    subgraph Platforms["Workload Platforms"]
        RKE2["RKE2 Cluster"]
        Rancher["Rancher UI"]
        Compose["Docker Compose"]
        BareMetal["Bare Metal Services"]
        CI["CI/CD Pipelines"]
    end

    subgraph Workloads["Workloads"]
        PG["PostgreSQL"]
        Redis["Redis"]
        MLflow["MLflow"]
        Grafana["Grafana"]
        NGINX["NGINX"]
        APIs["Internal APIs"]
    end

    subgraph Audit["Audit & Monitoring"]
        Loki["Loki Logs"]
        Prom["Prometheus Metrics"]
        GrafanaDash["Grafana Dashboards"]
        Alerts["Alertmanager"]
    end

    Vault --> ESO
    SOPS --> GitOps
    KMS --> ESO
    K8S --> RKE2

    ESO --> RKE2
    GitOps --> RKE2
    CertMgr --> RKE2

    Cron --> PG
    Cron --> Redis
    Ansible --> RKE2
    Ansible --> BareMetal

    RKE2 --> PG
    RKE2 --> Redis
    RKE2 --> MLflow
    RKE2 --> Grafana
    RKE2 --> NGINX
    RKE2 --> APIs

    Compose --> PG
    Compose --> Redis
    Compose --> APIs

    BareMetal --> PG
    BareMetal --> Redis

    CI --> Vault
    CI --> SOPS
    CI --> KMS

    PG --> Loki
    Redis --> Loki
    RKE2 --> Prom
    ESO --> Prom
    CertMgr --> Prom

    Loki --> GrafanaDash
    Prom --> GrafanaDash
    Prom --> Alerts

Data Flow

  1. Secret Creation: Secrets generated in Vault, SOPS, or KMS
  2. Storage: Encrypted storage (Vault transit, SOPS encryption, KMS encryption)
  3. Distribution: External Secrets Operator or GitOps syncs to Kubernetes
  4. Injection: Pods mount secrets as volumes or env vars
  5. Rotation: Cron jobs or operators trigger rotation workflows
  6. Audit: All access logged to Loki, metrics to Prometheus

Storage Options

HashiCorp Vault

Use Case: Enterprise-grade secrets management with dynamic secrets, PKI, and advanced features.

Architecture:

graph LR
    subgraph Vault["Vault Cluster"]
        Transit["Transit Engine<br/>(Encryption)"]
        PKI["PKI Engine<br/>(Certificates)"]
        Database["Database Engine<br/>(Dynamic Secrets)"]
        KV["KV v2<br/>(Static Secrets)"]
    end

    Apps["Applications"] --> Transit
    Apps --> PKI
    Apps --> Database
    Apps --> KV

    Transit --> KMS["KMS Backend"]
    PKI --> CA["CA Backend"]
    Database --> PG["Postgres"]

Configuration Example:

# vault.hcl
storage "consul" {
  address = "127.0.0.1:8500"
  path    = "vault/"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_cert_file = "/etc/vault/tls/vault.crt"
  tls_key_file  = "/etc/vault/tls/vault.key"
}

api_addr = "https://vault.example.com:8200"
ui = true

# Enable audit logging
audit_device "file" {
  path = "/var/log/vault/audit.log"
  format = "json"
}

Enable Engines:

# Enable transit engine for encryption
vault secrets enable transit

# Enable PKI engine
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki

# Enable database secrets engine
vault secrets enable database

# Configure Postgres dynamic secrets
vault write database/config/postgres \
  plugin_name=postgresql-database-plugin \
  allowed_roles="readonly,readwrite" \
  connection_url="postgresql://{{username}}:{{password}}@postgres:5432/mydb" \
  username="vault" \
  password="vault-password"

Pros: - Dynamic secrets (auto-rotating database credentials) - PKI engine for certificate management - Transit engine for encryption as a service - Fine-grained access policies - Audit logging built-in

Cons: - Operational complexity - Requires HA setup for production - Learning curve for teams

SOPS + Git (Encrypted GitOps)

Use Case: GitOps workflows where secrets are committed to Git in encrypted form.

Architecture:

graph LR
    Dev["Developer"] --> SOPS["SOPS<br/>(encrypt)"]
    SOPS --> Git["Git Repo<br/>(encrypted secrets)"]
    Git --> GitOps["GitOps Operator<br/>(Flux/ArgoCD)"]
    GitOps --> K8S["Kubernetes<br/>(decrypted)"]

    SOPS -.-> AgeKey["Age Key<br/>(private key)"]
    GitOps -.-> AgeKey

Setup:

# Install SOPS
brew install sops  # macOS
# or
wget https://github.com/mozilla/sops/releases/download/v3.8.1/sops-v3.8.1.linux
sudo mv sops-v3.8.1.linux /usr/local/bin/sops
sudo chmod +x /usr/local/bin/sops

# Generate Age key pair
age-keygen -o age-key.txt
# Public key: age1abc123...
# Private key: age-secret-key-1abc123...

# Create .sops.yaml
cat > .sops.yaml <<EOF
creation_rules:
  - path_regex: secrets/.*\.yaml$
    age: age1abc123...  # Public key
  - path_regex: secrets/.*\.env$
    age: age1abc123...
EOF

Encrypt Secret:

# Create secret file
cat > secrets/postgres-credentials.yaml <<EOF
postgres:
  username: myapp
  password: super-secret-password
  host: postgres.example.com
  port: 5432
EOF

# Encrypt with SOPS
sops -e -i secrets/postgres-credentials.yaml

# File is now encrypted
cat secrets/postgres-credentials.yaml
# Output: encrypted binary/JSON

Decrypt in CI/CD:

# .github/workflows/deploy.yml
name: Deploy Secrets

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Install SOPS
        run: |
          wget https://github.com/mozilla/sops/releases/download/v3.8.1/sops-v3.8.1.linux
          sudo mv sops-v3.8.1.linux /usr/local/bin/sops
          sudo chmod +x /usr/local/bin/sops

      - name: Decrypt secrets
        env:
          SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
        run: |
          sops -d secrets/postgres-credentials.yaml > decrypted.yaml
          kubectl apply -f decrypted.yaml

Pros: - GitOps-friendly (secrets in Git) - No external service dependency - Works in air-gapped environments - Simple key management (Age keys)

Cons: - Manual rotation of encryption keys - No dynamic secrets - Requires key distribution to CI/CD systems

Kubernetes Secrets (with Encryption at Rest)

Use Case: Native Kubernetes secrets with encryption at rest enabled.

Enable Encryption at Rest:

# EncryptionConfiguration
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <base64-encoded-32-byte-key>
      - identity: {}  # Fallback

Apply Configuration:

# Generate encryption key
ENCRYPTION_KEY=$(head -c 32 /dev/urandom | base64)

# Update EncryptionConfiguration
kubectl create secret generic encryption-config \
  --from-file=encryption.yaml=encryption-config.yaml \
  -n kube-system

# Update kube-apiserver manifest
# Add: --encryption-provider-config=/etc/kubernetes/encryption/encryption.yaml

Create Secret:

# postgres-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: postgres-credentials
  namespace: production
type: Opaque
stringData:
  username: myapp
  password: super-secret-password
  host: postgres.example.com
  port: "5432"

Pros: - Native Kubernetes integration - No external dependencies - Encryption at rest available

Cons: - Base64 encoding (not encryption) by default - Requires encryption at rest for security - No automatic rotation - Secrets visible to anyone with cluster access

Docker Secrets

Use Case: Docker Swarm or Docker Compose with secrets.

Create Secret:

# Create secret
echo "super-secret-password" | docker secret create postgres_password -

# Or from file
docker secret create postgres_password ./password.txt

Use in Docker Compose:

# docker-compose.yml
version: '3.8'

services:
  app:
    image: myapp:latest
    secrets:
      - postgres_password
      - postgres_username
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
      - POSTGRES_USERNAME_FILE=/run/secrets/postgres_username

secrets:
  postgres_password:
    external: true
  postgres_username:
    file: ./username.txt  # Plain text (not recommended for production)

Access in Container:

# Secret mounted at /run/secrets/<secret-name>
cat /run/secrets/postgres_password

Pros: - Native Docker integration - Secrets not in image layers - Simple for Docker Compose

Cons: - Docker Swarm only (not standard Docker) - Limited rotation capabilities - No encryption at rest by default

External Secrets Operator (ESO)

Use Case: Sync secrets from external sources (Vault, AWS Secrets Manager, GCP Secret Manager) to Kubernetes.

Architecture:

graph LR
    Vault["Vault"] --> ESO["External Secrets<br/>Operator"]
    AWS["AWS Secrets<br/>Manager"] --> ESO
    GCP["GCP Secret<br/>Manager"] --> ESO

    ESO --> SecretStore["SecretStore<br/>(auth config)"]
    SecretStore --> ExternalSecret["ExternalSecret<br/>(sync config)"]
    ExternalSecret --> K8SSecret["Kubernetes<br/>Secret"]
    K8SSecret --> Pod["Pod"]

Install ESO:

# Install via Helm
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets \
  external-secrets/external-secrets \
  -n external-secrets-system \
  --create-namespace

Configure SecretStore:

# vault-secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: production
spec:
  provider:
    vault:
      server: "https://vault.example.com:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "external-secrets"
          serviceAccountRef:
            name: external-secrets

Create ExternalSecret:

# postgres-external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: postgres-credentials
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: postgres-credentials
    creationPolicy: Owner
  data:
    - secretKey: username
      remoteRef:
        key: database/postgres
        property: username
    - secretKey: password
      remoteRef:
        key: database/postgres
        property: password

Pros: - Automatic sync from external sources - Supports multiple backends (Vault, AWS, GCP, Azure) - Kubernetes-native (CRDs) - Automatic refresh

Cons: - Requires external secret source - Additional operator to manage - Learning curve for CRDs

Secret Formats

PKI Certificates

Use Case: TLS certificates for services, mTLS for service-to-service auth.

Certificate Structure:

Certificate Chain:
  - Root CA (long-lived, offline)
  - Intermediate CA (medium-lived, online)
  - Leaf Certificate (short-lived, auto-rotating)

Vault PKI Setup:

# Generate root CA
vault secrets enable -path=pki pki
vault secrets tune -max-lease-ttl=87600h pki

vault write -field=certificate pki/root/generate/internal \
  common_name="example.com Root CA" \
  ttl=87600h > ca_cert.pem

# Configure CA and CRL URLs
vault write pki/config/urls \
  issuing_certificates="https://vault.example.com/v1/pki/ca" \
  crl_distribution_points="https://vault.example.com/v1/pki/crl"

# Create intermediate CA
vault secrets enable -path=pki_int pki
vault secrets tune -max-lease-ttl=43800h pki_int

# Generate CSR
vault write -field=csr pki_int/intermediate/generate/internal \
  common_name="example.com Intermediate CA" \
  ttl=43800h > pki_intermediate.csr

# Sign with root CA
vault write -field=certificate pki/root/sign-intermediate \
  csr=@pki_intermediate.csr \
  common_name="example.com Intermediate CA" \
  ttl=43800h > intermediate_cert.pem

# Set intermediate certificate
vault write pki_int/intermediate/set-signed certificate=@intermediate_cert.pem

# Create role for leaf certificates
vault write pki_int/roles/example-dot-com \
  allowed_domains="example.com" \
  allow_subdomains=true \
  max_ttl="720h" \
  key_type="ec" \
  key_bits=256

Issue Leaf Certificate:

# Issue certificate
vault write -format=json pki_int/issue/example-dot-com \
  common_name="api.example.com" \
  ttl="24h" | jq -r '.data.certificate' > api.crt

# Get private key
vault write -format=json pki_int/issue/example-dot-com \
  common_name="api.example.com" \
  ttl="24h" | jq -r '.data.private_key' > api.key

JWT Signing Keys

Use Case: Service authentication, API tokens.

Key Rotation Pattern:

graph LR
    Key1["Key v1<br/>(kid: key-1)"] --> Active["Active Keys<br/>(v1, v2)"]
    Key2["Key v2<br/>(kid: key-2)"] --> Active

    Active --> Verify["JWT Verification<br/>(try v2, fallback v1)"]

    Key3["Key v3<br/>(kid: key-3)"] --> Rotate["Rotation<br/>(add v3, keep v2)"]
    Rotate --> Active

Key Generation:

# Generate RSA key pair
openssl genrsa -out jwt-signing-key-v1.pem 2048
openssl rsa -in jwt-signing-key-v1.pem -pubout -out jwt-signing-key-v1.pub

# Generate EC key pair (smaller, faster)
openssl ecparam -genkey -name secp256r1 -noout -out jwt-signing-key-v2.pem
openssl ec -in jwt-signing-key-v2.pem -pubout -out jwt-signing-key-v2.pub

JWT with Key ID:

# Python example
import jwt
from cryptography.hazmat.primitives import serialization

# Load keys
with open("jwt-signing-key-v2.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(f.read(), password=None)

# Sign JWT with key ID
payload = {"sub": "user123", "exp": 1234567890}
token = jwt.encode(
    payload,
    private_key,
    algorithm="ES256",
    headers={"kid": "key-2"}  # Key ID for rotation
)

# Verify (try multiple keys)
keys = {
    "key-1": load_public_key("jwt-signing-key-v1.pub"),
    "key-2": load_public_key("jwt-signing-key-v2.pub"),
}

kid = jwt.get_unverified_header(token)["kid"]
public_key = keys.get(kid)
if public_key:
    payload = jwt.decode(token, public_key, algorithms=["ES256"])

Database Credentials

PostgreSQL Password:

-- Create user with password
CREATE USER myapp WITH PASSWORD 'initial-password';

-- Grant permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO myapp;
GRANT USAGE ON SCHEMA public TO myapp;

-- Store in Vault (dynamic secret)
-- Vault will auto-rotate this password

Redis AUTH Token:

# redis.conf
requirepass super-secret-token

# Or via command
CONFIG SET requirepass "super-secret-token"

API Keys

Dual-Key Rotation Pattern:

graph LR
    App["Application"] --> Key1["API Key v1<br/>(active)"]
    App --> Key2["API Key v2<br/>(active)"]

    Rotate["Rotation"] --> Key3["API Key v3<br/>(new)"]
    Rotate --> Revoke["Revoke Key v1"]

    Key3 --> App

Implementation:

# api_key_manager.py
class APIKeyManager:
    def __init__(self):
        self.keys = {
            "v1": "sk_live_abc123...",
            "v2": "sk_live_def456...",
        }
        self.active_keys = ["v1", "v2"]

    def validate_key(self, api_key: str) -> bool:
        # Try all active keys
        for key_id, key_value in self.keys.items():
            if key_id in self.active_keys and api_key == key_value:
                return True
        return False

    def rotate(self):
        # Generate new key
        new_key_id = f"v{len(self.keys) + 1}"
        new_key = f"sk_live_{generate_random_token()}"
        self.keys[new_key_id] = new_key

        # Add to active keys
        self.active_keys.append(new_key_id)

        # Remove oldest key after grace period
        if len(self.active_keys) > 2:
            oldest = self.active_keys.pop(0)
            # Revoke after 24h grace period
            schedule_revocation(oldest, delay_hours=24)

Secret Injection Patterns

Kubernetes Pod-Level Secret Injection

Volume Mount:

# postgres-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: myapp
  namespace: production
spec:
  containers:
    - name: app
      image: myapp:latest
      volumeMounts:
        - name: postgres-secret
          mountPath: /etc/secrets/postgres
          readOnly: true
  volumes:
    - name: postgres-secret
      secret:
        secretName: postgres-credentials
        items:
          - key: username
            path: username
          - key: password
            path: password

Environment Variables:

# postgres-pod-env.yaml
apiVersion: v1
kind: Pod
metadata:
  name: myapp
  namespace: production
spec:
  containers:
    - name: app
      image: myapp:latest
      env:
        - name: POSTGRES_USERNAME
          valueFrom:
            secretKeyRef:
              name: postgres-credentials
              key: username
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: postgres-credentials
              key: password

Deployment with Secrets:

# postgres-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: app
          image: myapp:latest
          envFrom:
            - secretRef:
                name: postgres-credentials
          # Or use env with secretKeyRef
          env:
            - name: POSTGRES_HOST
              value: postgres.example.com

Sidecar-Based Dynamic Secret Rotation

Vault Agent Sidecar:

# vault-sidecar-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: myapp-with-vault
  namespace: production
  annotations:
    vault.hashicorp.com/agent-inject: "true"
    vault.hashicorp.com/role: "myapp"
    vault.hashicorp.com/agent-inject-secret-database: "database/creds/myapp"
    vault.hashicorp.com/agent-inject-template-database: |
      {{- with secret "database/creds/myapp" -}}
      POSTGRES_USERNAME={{ .Data.username }}
      POSTGRES_PASSWORD={{ .Data.password }}
      {{- end }}
spec:
  serviceAccountName: myapp
  containers:
    - name: app
      image: myapp:latest
      env:
        - name: POSTGRES_USERNAME
          valueFrom:
            secretKeyRef:
              name: vault-env
              key: POSTGRES_USERNAME
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: vault-env
              key: POSTGRES_PASSWORD
    - name: vault-agent
      image: hashicorp/vault:latest
      args:
        - "agent"
        - "-config=/vault/config/agent.hcl"
      volumeMounts:
        - name: vault-config
          mountPath: /vault/config

Docker Compose Environment Variable Injection

Environment File:

# .env (DO NOT COMMIT)
POSTGRES_USERNAME=myapp
POSTGRES_PASSWORD=super-secret-password
POSTGRES_HOST=postgres.example.com
POSTGRES_PORT=5432

Docker Compose:

# docker-compose.yml
version: '3.8'

services:
  app:
    image: myapp:latest
    env_file:
      - .env
    environment:
      - POSTGRES_HOST=${POSTGRES_HOST}
      - POSTGRES_USERNAME=${POSTGRES_USERNAME}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    depends_on:
      - postgres

  postgres:
    image: postgres:15
    environment:
      - POSTGRES_USER=${POSTGRES_USERNAME}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Docker Secrets (Swarm):

# docker-compose.secrets.yml
version: '3.8'

services:
  app:
    image: myapp:latest
    secrets:
      - postgres_username
      - postgres_password
    environment:
      - POSTGRES_USERNAME_FILE=/run/secrets/postgres_username
      - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password

secrets:
  postgres_username:
    external: true
  postgres_password:
    external: true

File-Based Credential Injection

Systemd Service:

# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=network.target

[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp
EnvironmentFile=/etc/myapp/secrets.env
Restart=on-failure

[Install]
WantedBy=multi-user.target

Secrets File:

# /etc/myapp/secrets.env (permissions: 600, owner: myapp)
POSTGRES_USERNAME=myapp
POSTGRES_PASSWORD=super-secret-password
POSTGRES_HOST=postgres.example.com

Sealed Secrets (GitOps)

Install Sealed Secrets Controller:

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

Create Sealed Secret:

# Create Kubernetes secret
kubectl create secret generic postgres-credentials \
  --from-literal=username=myapp \
  --from-literal=password=super-secret-password \
  --dry-run=client -o yaml > secret.yaml

# Seal it
kubeseal < secret.yaml > sealed-secret.yaml

# Now safe to commit
git add sealed-secret.yaml
git commit -m "Add sealed secret for postgres"

Sealed Secret YAML:

# sealed-secret.yaml (safe to commit)
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: postgres-credentials
  namespace: production
spec:
  encryptedData:
    username: AgBx...
    password: AgBy...
  template:
    metadata:
      name: postgres-credentials
      namespace: production
    type: Opaque

Rancher Secret Store Integration

Rancher Secret:

# Via Rancher UI or API
apiVersion: v1
kind: Secret
metadata:
  name: postgres-credentials
  namespace: production
  labels:
    cattle.io/creator: norman
type: Opaque
stringData:
  username: myapp
  password: super-secret-password

Use in Rancher Workload:

# Rancher workload YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  template:
    spec:
      containers:
        - name: app
          image: myapp:latest
          envFrom:
            - secretRef:
                name: postgres-credentials

Prefect Agent Secrets

Prefect Secret:

# Create secret in Prefect
from prefect import flow, task
from prefect.blocks.system import Secret

@task
def get_db_credentials():
    username = Secret.load("postgres-username").get()
    password = Secret.load("postgres-password").get()
    return username, password

@flow
def my_flow():
    username, password = get_db_credentials()
    # Use credentials
    pass

Prefect Deployment with Secrets:

# prefect-deployment.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: prefect-deployment
data:
  deployment.yaml: |
    name: my-flow
    work_queue_name: k8s-default
    work_pool_name: k8s-work-pool
    job_variables:
      env:
        - name: POSTGRES_USERNAME
          valueFrom:
            secretKeyRef:
              name: postgres-credentials
              key: username
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: postgres-credentials
              key: password

Key Rotation Strategies

Postgres Password Rotation (Zero-Downtime)

Dual-Secret Pattern:

sequenceDiagram
    participant Admin as Admin/Ansible
    participant PG as PostgreSQL
    participant App1 as App Instance 1
    participant App2 as App Instance 2

    Note over Admin,PG: Phase 1: Create New Credential
    Admin->>PG: CREATE USER myapp_v2 WITH PASSWORD 'new-password'
    Admin->>PG: GRANT permissions TO myapp_v2

    Note over Admin,App1: Phase 2: Rolling Update (App 1)
    Admin->>App1: Update secret to v2
    App1->>PG: Connect with v2 credentials
    App1->>PG: Verify connection works

    Note over Admin,App2: Phase 3: Rolling Update (App 2)
    Admin->>App2: Update secret to v2
    App2->>PG: Connect with v2 credentials

    Note over Admin,PG: Phase 4: Cleanup
    Admin->>PG: DROP USER myapp_v1

Ansible Playbook:

# rotate-postgres-password.yml
---
- name: Rotate Postgres Password
  hosts: postgres_servers
  vars:
    app_user: myapp
    new_password: "{{ vault_postgres_new_password }}"
    old_user: "{{ app_user }}_v1"
    new_user: "{{ app_user }}_v2"

  tasks:
    - name: Generate new password
      set_fact:
        generated_password: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters,digits') }}"
      when: new_password is not defined

    - name: Create new user with new password
      postgresql_user:
        name: "{{ new_user }}"
        password: "{{ new_password | default(generated_password) }}"
        priv: "ALL:public"
        state: present
      become: yes
      become_user: postgres

    - name: Grant permissions to new user
      postgresql_privs:
        db: mydb
        privs: ALL
        objs: ALL_IN_SCHEMA
        schema: public
        roles: "{{ new_user }}"
      become: yes
      become_user: postgres

    - name: Update Kubernetes secret (v2)
      kubernetes.core.k8s:
        state: present
        definition:
          apiVersion: v1
          kind: Secret
          metadata:
            name: postgres-credentials-v2
            namespace: production
          type: Opaque
          stringData:
            username: "{{ new_user }}"
            password: "{{ new_password | default(generated_password) }}"

    - name: Rolling update deployment (canary)
      kubernetes.core.k8s_scale:
        state: present
        definition:
          apiVersion: apps/v1
          kind: Deployment
          metadata:
            name: myapp
            namespace: production
          spec:
            replicas: 1
        wait: true
        wait_condition:
          type: Available
          status: "True"

    - name: Update deployment to use v2 secret
      kubernetes.core.k8s:
        state: present
        definition:
          apiVersion: apps/v1
          kind: Deployment
          metadata:
            name: myapp
            namespace: production
          spec:
            template:
              spec:
                containers:
                  - name: app
                    envFrom:
                      - secretRef:
                          name: postgres-credentials-v2

    - name: Scale up deployment
      kubernetes.core.k8s_scale:
        state: present
        definition:
          apiVersion: apps/v1
          kind: Deployment
          metadata:
            name: myapp
            namespace: production
          spec:
            replicas: 3
        wait: true

    - name: Wait for all pods healthy
      kubernetes.core.k8s_info:
        api_version: v1
        kind: Pod
        namespace: production
        label_selectors:
          - app=myapp
      register: pods
      until: pods.resources | selectattr('status.phase', 'equalto', 'Running') | list | length == 3
      retries: 30
      delay: 10

    - name: Drop old user (after grace period)
      postgresql_user:
        name: "{{ old_user }}"
        state: absent
      become: yes
      become_user: postgres
      when: cleanup_old_user | default(false)

PgCron Automated Rotation:

-- Function to rotate Postgres password
CREATE OR REPLACE FUNCTION rotate_app_password()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
    new_password text;
    new_username text;
    old_username text;
BEGIN
    -- Generate new password
    new_password := encode(gen_random_bytes(32), 'base64');
    new_username := 'myapp_v' || extract(epoch from now())::bigint;
    old_username := (SELECT usename FROM pg_user WHERE usename LIKE 'myapp_v%' ORDER BY usename DESC LIMIT 1);

    -- Create new user
    EXECUTE format('CREATE USER %I WITH PASSWORD %L', new_username, new_password);
    EXECUTE format('GRANT ALL PRIVILEGES ON DATABASE mydb TO %I', new_username);
    EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO %I', new_username);

    -- Update Kubernetes secret (via API call or external script)
    -- This would be done by an external service that watches for new users

    -- Drop old user after 24h grace period
    -- Scheduled separately via pg_cron
END;
$$;

-- Schedule rotation (monthly)
SELECT cron.schedule(
    'rotate-postgres-password',
    '0 2 1 * *',  -- First day of month at 02:00
    $$SELECT rotate_app_password();$$
);

Redis AUTH Token Rotation

Rotation Script:

#!/bin/bash
# rotate-redis-auth.sh

set -euo pipefail

OLD_TOKEN="${REDIS_OLD_TOKEN}"
NEW_TOKEN=$(openssl rand -base64 32)
REDIS_HOST="${REDIS_HOST:-localhost}"
REDIS_PORT="${REDIS_PORT:-6379}"

# Connect with old token and set new token
redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" -a "$OLD_TOKEN" \
  CONFIG SET requirepass "$NEW_TOKEN"

# Update Kubernetes secret
kubectl create secret generic redis-credentials \
  --from-literal=password="$NEW_TOKEN" \
  --dry-run=client -o yaml | \
  kubectl apply -f -

# Rolling restart of Redis clients
kubectl rollout restart deployment/myapp -n production

# Wait for rollout
kubectl rollout status deployment/myapp -n production

# After grace period, old token will be invalid
echo "Rotation complete. Old token will expire after grace period."

Dual-Token Support:

# redis_client.py
import redis
from redis.connection import ConnectionPool

class RotatingRedisClient:
    def __init__(self, primary_token: str, secondary_token: str = None):
        self.primary_token = primary_token
        self.secondary_token = secondary_token
        self.pool = ConnectionPool(
            host='redis.example.com',
            port=6379,
            password=primary_token,
            decode_responses=True
        )
        self.client = redis.Redis(connection_pool=self.pool)

    def execute_command(self, *args, **kwargs):
        try:
            return self.client.execute_command(*args, **kwargs)
        except redis.AuthenticationError:
            # Try secondary token if primary fails
            if self.secondary_token:
                self.pool.connection_kwargs['password'] = self.secondary_token
                self.client = redis.Redis(connection_pool=self.pool)
                return self.client.execute_command(*args, **kwargs)
            raise

TLS Certificate Rotation

Cert-Manager (ACME):

# cert-manager-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: nginx

Certificate Resource:

# tls-certificate.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: api-example-com
  namespace: production
spec:
  secretName: api-example-com-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - api.example.com
    - www.api.example.com
  renewBefore: 720h  # Renew 30 days before expiry

Manual Certificate Rotation:

#!/bin/bash
# rotate-tls-cert.sh

CERT_NAME="api-example-com"
NAMESPACE="production"
SECRET_NAME="${CERT_NAME}-tls"

# Generate new certificate
certbot certonly --standalone -d api.example.com

# Create new secret
kubectl create secret tls "$SECRET_NAME" \
  --cert=/etc/letsencrypt/live/api.example.com/fullchain.pem \
  --key=/etc/letsencrypt/live/api.example.com/privkey.pem \
  --dry-run=client -o yaml | \
  kubectl apply -f -

# Update ingress to use new secret
kubectl patch ingress api-ingress -n "$NAMESPACE" \
  -p '{"spec":{"tls":[{"secretName":"'$SECRET_NAME'"}]}}'

# Rolling restart NGINX
kubectl rollout restart deployment/nginx -n "$NAMESPACE"
kubectl rollout status deployment/nginx -n "$NAMESPACE"

NGINX Certificate Reload:

# nginx.conf
server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate /etc/nginx/ssl/api-example-com.crt;
    ssl_certificate_key /etc/nginx/ssl/api-example-com.key;

    # Reload certificates without restart
    # Use: nginx -s reload
}
# Reload NGINX after cert update
kubectl exec -n production deployment/nginx -- nginx -s reload

JWT Signing Key Rotation (Kid-Based)

Key Rotation Function:

# jwt_key_rotation.py
import jwt
import time
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec

class JWTKeyManager:
    def __init__(self, key_storage_path: str):
        self.key_storage_path = key_storage_path
        self.keys = self._load_keys()

    def _load_keys(self) -> dict:
        """Load all active keys from storage"""
        keys = {}
        for key_file in Path(self.key_storage_path).glob("jwt-key-*.pem"):
            kid = key_file.stem.split("-")[-1]  # Extract kid from filename
            with open(key_file, "rb") as f:
                keys[kid] = serialization.load_pem_private_key(
                    f.read(), password=None
                )
        return keys

    def rotate_key(self) -> str:
        """Generate new key and return kid"""
        # Generate new EC key
        private_key = ec.generate_private_key(ec.SECP256R1())
        kid = f"key-{int(time.time())}"

        # Save private key
        key_path = Path(self.key_storage_path) / f"jwt-key-{kid}.pem"
        with open(key_path, "wb") as f:
            f.write(private_key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.PKCS8,
                encryption_algorithm=serialization.NoEncryption()
            ))

        # Save public key
        pub_key_path = Path(self.key_storage_path) / f"jwt-key-{kid}.pub"
        public_key = private_key.public_key()
        with open(pub_key_path, "wb") as f:
            f.write(public_key.public_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PublicFormat.SubjectPublicKeyInfo
            ))

        # Add to active keys
        self.keys[kid] = private_key

        return kid

    def sign_token(self, payload: dict, kid: str = None) -> str:
        """Sign JWT with specified or latest key"""
        if kid is None:
            kid = max(self.keys.keys())  # Use latest key

        private_key = self.keys[kid]
        headers = {"kid": kid, "alg": "ES256"}

        return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)

    def verify_token(self, token: str) -> dict:
        """Verify JWT, trying all active keys"""
        # Get kid from token
        unverified = jwt.decode(token, options={"verify_signature": False})
        kid = jwt.get_unverified_header(token).get("kid")

        # Try kid first, then all keys
        keys_to_try = [kid] if kid else []
        keys_to_try.extend(self.keys.keys())

        for key_id in keys_to_try:
            if key_id not in self.keys:
                continue

            try:
                # Load public key
                pub_key_path = Path(self.key_storage_path) / f"jwt-key-{key_id}.pub"
                with open(pub_key_path, "rb") as f:
                    public_key = serialization.load_pem_public_key(f.read())

                # Verify
                payload = jwt.decode(token, public_key, algorithms=["ES256"])
                return payload
            except jwt.InvalidSignatureError:
                continue

        raise jwt.InvalidTokenError("No valid key found")

Automated Rotation:

#!/bin/bash
# rotate-jwt-keys.sh

KEY_STORAGE="/etc/jwt/keys"
GRACE_PERIOD_DAYS=7

# Generate new key
python3 -c "
from jwt_key_rotation import JWTKeyManager
manager = JWTKeyManager('$KEY_STORAGE')
new_kid = manager.rotate_key()
print(f'New key created: {new_kid}')
"

# After grace period, remove old keys
find "$KEY_STORAGE" -name "jwt-key-*.pem" -mtime +$GRACE_PERIOD_DAYS \
  -exec rm {} \;

SSH Key Rotation

Host Key Rotation:

#!/bin/bash
# rotate-ssh-host-keys.sh

BACKUP_DIR="/etc/ssh/keys.backup"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

# Backup old keys
mkdir -p "$BACKUP_DIR"
cp /etc/ssh/ssh_host_* "$BACKUP_DIR/"

# Generate new keys
ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N "" -C "rotated-$TIMESTAMP"
ssh-keygen -t ecdsa -f /etc/ssh/ssh_host_ecdsa_key -N "" -C "rotated-$TIMESTAMP"
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N "" -C "rotated-$TIMESTAMP"

# Restart SSH (or reload if supported)
systemctl restart sshd

# Update known_hosts on all clients (via Ansible)
ansible-playbook update-known-hosts.yml

User Key Rotation:

#!/bin/bash
# rotate-user-ssh-key.sh

USER="${1:-myuser}"
AUTHORIZED_KEYS="/home/$USER/.ssh/authorized_keys"
BACKUP="$AUTHORIZED_KEYS.backup.$(date +%Y%m%d)"

# Backup
cp "$AUTHORIZED_KEYS" "$BACKUP"

# Generate new key pair
ssh-keygen -t ed25519 -f "/home/$USER/.ssh/id_ed25519_new" -N "" -C "$USER-$(date +%Y%m%d)"

# Add new public key
cat "/home/$USER/.ssh/id_ed25519_new.pub" >> "$AUTHORIZED_KEYS"

# After grace period, remove old keys
# (Manual or scheduled cleanup)

SOPS Encryption Key Rotation

Rotate Age Key:

#!/bin/bash
# rotate-sops-key.sh

OLD_KEY_ID="age1abc123..."
NEW_KEY_ID="age1def456..."

# Generate new Age key
age-keygen -o age-key-new.txt
NEW_PUBLIC_KEY=$(grep "public key" age-key-new.txt | cut -d: -f2 | tr -d ' ')

# Update .sops.yaml to include both keys (dual-key mode)
cat > .sops.yaml <<EOF
creation_rules:
  - path_regex: secrets/.*\.yaml$
    age: >-
      ${OLD_KEY_ID},
      ${NEW_PUBLIC_KEY}
EOF

# Re-encrypt all secrets with new key
find secrets/ -name "*.yaml" -type f | while read file; do
    sops -r -i "$file"
done

# After grace period, remove old key from .sops.yaml
# Update to use only new key

Docker Registry Credential Rotation

Rotation Script:

#!/bin/bash
# rotate-docker-registry-creds.sh

REGISTRY="${DOCKER_REGISTRY:-registry.example.com}"
NEW_USERNAME="${NEW_USERNAME:-deployer}"
NEW_PASSWORD=$(openssl rand -base64 32)

# Login with new credentials
echo "$NEW_PASSWORD" | docker login "$REGISTRY" -u "$NEW_USERNAME" --password-stdin

# Update Kubernetes image pull secret
kubectl create secret docker-registry registry-credentials \
  --docker-server="$REGISTRY" \
  --docker-username="$NEW_USERNAME" \
  --docker-password="$NEW_PASSWORD" \
  --dry-run=client -o yaml | \
  kubectl apply -f -

# Update all deployments to use new secret
kubectl get deployments -A -o json | \
  jq -r '.items[] | select(.spec.template.spec.imagePullSecrets) | "\(.metadata.namespace) \(.metadata.name)"' | \
  while read namespace name; do
    kubectl patch deployment "$name" -n "$namespace" \
      -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"registry-credentials"}]}}}}'
  done

# Rolling restart to pick up new credentials
kubectl rollout restart deployment --all -A

CI/CD Integration

GitHub Actions with OpenID Connect

OIDC Setup:

# .github/workflows/deploy.yml
name: Deploy with OIDC

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          aws-region: us-east-1

      - name: Get secrets from AWS Secrets Manager
        run: |
          POSTGRES_PASSWORD=$(aws secretsmanager get-secret-value \
            --secret-id production/postgres/password \
            --query SecretString --output text)
          echo "::add-mask::$POSTGRES_PASSWORD"
          echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> $GITHUB_ENV

      - name: Deploy to Kubernetes
        env:
          KUBECONFIG: ${{ secrets.KUBECONFIG }}
        run: |
          kubectl set env deployment/myapp \
            POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
            -n production

Vault Authentication:

# .github/workflows/vault-auth.yml
name: Deploy with Vault

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Authenticate with Vault
        uses: hashicorp/vault-action@v2
        with:
          url: https://vault.example.com
          method: jwt
          jwtGithubAudience: 'https://github.com/myorg'
          jwtMountPath: 'jwt'
          role: 'github-actions'
          secrets: |
            database/creds/myapp postgres_username POSTGRES_USERNAME
            database/creds/myapp postgres_password POSTGRES_PASSWORD

      - name: Deploy
        env:
          POSTGRES_USERNAME: ${{ env.POSTGRES_USERNAME }}
          POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
        run: |
          kubectl set env deployment/myapp \
            POSTGRES_USERNAME="$POSTGRES_USERNAME" \
            POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
            -n production

SOPS Decryption:

# .github/workflows/sops-deploy.yml
name: Deploy with SOPS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Install SOPS
        uses: mozilla/sops@v3
        with:
          sops_version: '3.8.1'

      - name: Decrypt secrets
        env:
          SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
        run: |
          sops -d secrets/postgres-credentials.yaml > decrypted.yaml
          kubectl apply -f decrypted.yaml
          rm decrypted.yaml

GitLab CI with Protected Variables

GitLab CI Configuration:

# .gitlab-ci.yml
stages:
  - deploy

deploy_production:
  stage: deploy
  image: bitnami/kubectl:latest
  environment:
    name: production
  before_script:
    - kubectl config use-context production
  script:
    - |
      kubectl create secret generic postgres-credentials \
        --from-literal=username="$POSTGRES_USERNAME" \
        --from-literal=password="$POSTGRES_PASSWORD" \
        --dry-run=client -o yaml | \
        kubectl apply -f -
    - kubectl rollout restart deployment/myapp -n production
    - kubectl rollout status deployment/myapp -n production
  only:
    - main
  when: manual

Protected Variables (set in GitLab UI): - POSTGRES_USERNAME (protected, masked) - POSTGRES_PASSWORD (protected, masked)

Vault Integration:

# .gitlab-ci.yml with Vault
deploy_production:
  stage: deploy
  image: 
    name: vault:latest
    entrypoint: [""]
  before_script:
    - apk add --no-cache curl jq
    - |
      VAULT_TOKEN=$(vault write -field=token auth/jwt/login \
        role=gitlab-ci \
        jwt=$CI_JOB_JWT)
      export VAULT_TOKEN
  script:
    - |
      POSTGRES_CREDS=$(vault read -format=json database/creds/myapp)
      POSTGRES_USERNAME=$(echo $POSTGRES_CREDS | jq -r '.data.username')
      POSTGRES_PASSWORD=$(echo $POSTGRES_CREDS | jq -r '.data.password')

      kubectl create secret generic postgres-credentials \
        --from-literal=username="$POSTGRES_USERNAME" \
        --from-literal=password="$POSTGRES_PASSWORD" \
        --dry-run=client -o yaml | \
        kubectl apply -f -

Pipeline for Secret Rotation

Rotation Pipeline:

# .github/workflows/rotate-secrets.yml
name: Rotate Secrets

on:
  schedule:
    - cron: '0 2 1 * *'  # First day of month at 02:00
  workflow_dispatch:

jobs:
  rotate-postgres:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Authenticate with Vault
        uses: hashicorp/vault-action@v2
        with:
          url: https://vault.example.com
          method: jwt
          role: 'github-actions'

      - name: Rotate Postgres password
        run: |
          # Generate new password
          NEW_PASSWORD=$(openssl rand -base64 32)

          # Update in Vault
          vault kv put database/postgres password="$NEW_PASSWORD"

          # Update Kubernetes secret
          kubectl create secret generic postgres-credentials \
            --from-literal=password="$NEW_PASSWORD" \
            --dry-run=client -o yaml | \
            kubectl apply -f -

          # Rolling restart
          kubectl rollout restart deployment/myapp -n production
          kubectl rollout status deployment/myapp -n production

Air-Gapped Secrets Management

Offline CA Structure

CA Hierarchy:

Root CA (offline, 10-year lifetime)
  └── Intermediate CA 1 (online, 2-year lifetime)
      ├── Server Certificates
      └── Client Certificates
  └── Intermediate CA 2 (online, 2-year lifetime)
      ├── Server Certificates
      └── Client Certificates

Generate Root CA (Offline):

#!/bin/bash
# generate-root-ca.sh (run on air-gapped machine)

CA_DIR="/secure/ca/root"
mkdir -p "$CA_DIR"
cd "$CA_DIR"

# Generate private key
openssl genrsa -aes256 -out root-ca.key 4096

# Generate self-signed certificate
openssl req -new -x509 -days 3650 -key root-ca.key -out root-ca.crt \
  -subj "/C=US/ST=State/L=City/O=Organization/CN=Root CA"

# Create certificate database
touch index.txt
echo 1000 > serial

# Secure permissions
chmod 600 root-ca.key
chmod 644 root-ca.crt

Generate Intermediate CA (Online):

#!/bin/bash
# generate-intermediate-ca.sh

CA_DIR="/secure/ca/intermediate"
ROOT_CA_DIR="/secure/ca/root"
mkdir -p "$CA_DIR"
cd "$CA_DIR"

# Generate private key
openssl genrsa -aes256 -out intermediate-ca.key 4096

# Generate CSR
openssl req -new -key intermediate-ca.key -out intermediate-ca.csr \
  -subj "/C=US/ST=State/L=City/O=Organization/CN=Intermediate CA"

# Transfer CSR to offline machine for signing
# (via secure USB or secure network)

# On offline machine, sign CSR:
cd "$ROOT_CA_DIR"
openssl ca -config openssl.cnf -extensions v3_intermediate_ca \
  -days 730 -notext -md sha256 \
  -in "$CA_DIR/intermediate-ca.csr" \
  -out "$CA_DIR/intermediate-ca.crt"

# Transfer signed certificate back to online machine

Issue Server Certificate:

#!/bin/bash
# issue-server-cert.sh

INTERMEDIATE_CA_DIR="/secure/ca/intermediate"
SERVER_NAME="api.example.com"

cd "$INTERMEDIATE_CA_DIR"

# Generate private key
openssl genrsa -out "${SERVER_NAME}.key" 2048

# Generate CSR
openssl req -new -key "${SERVER_NAME}.key" -out "${SERVER_NAME}.csr" \
  -subj "/C=US/ST=State/L=City/O=Organization/CN=${SERVER_NAME}"

# Sign with intermediate CA
openssl ca -config openssl-intermediate.cnf -extensions server_cert \
  -days 90 -notext -md sha256 \
  -in "${SERVER_NAME}.csr" \
  -out "${SERVER_NAME}.crt"

# Create certificate chain
cat "${SERVER_NAME}.crt" intermediate-ca.crt > "${SERVER_NAME}-chain.crt"

Offline Secret Distribution

Secure USB Transfer:

#!/bin/bash
# transfer-secrets.sh

USB_MOUNT="/mnt/secure-usb"
SECRETS_DIR="/secure/secrets"

# On source machine (connected)
# Encrypt secrets
tar czf - "$SECRETS_DIR" | \
  gpg --encrypt --recipient recipient@example.com > secrets.tar.gz.gpg

# Copy to USB
cp secrets.tar.gz.gpg "$USB_MOUNT/"

# On destination machine (air-gapped)
# Decrypt secrets
gpg --decrypt "$USB_MOUNT/secrets.tar.gz.gpg" | tar xzf -

# Verify integrity
sha256sum "$SECRETS_DIR"/* > "$USB_MOUNT/secrets.sha256"

SOPS with Age Keys (Offline):

# Generate Age key on air-gapped machine
age-keygen -o age-key.txt

# Extract public key
PUBLIC_KEY=$(grep "public key" age-key.txt | cut -d: -f2 | tr -d ' ')

# Transfer public key to connected machine (safe to share)
echo "$PUBLIC_KEY" > age-key.pub

# On connected machine, encrypt with public key
sops -e -age "$PUBLIC_KEY" secrets.yaml > secrets.encrypted.yaml

# Transfer encrypted file to air-gapped machine
# On air-gapped machine, decrypt with private key
sops -d -age-key-file age-key.txt secrets.encrypted.yaml > secrets.yaml

Offline Vault Setup

Vault in Air-Gapped Environment:

# vault-offline.hcl
storage "file" {
  path = "/vault/data"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_cert_file = "/vault/tls/vault.crt"
  tls_key_file  = "/vault/tls/vault.key"
  tls_client_ca_file = "/vault/tls/ca.crt"  # mTLS
}

api_addr = "https://vault.internal:8200"
cluster_addr = "https://vault.internal:8201"
ui = true

# Disable external services
disable_mlock = false

Initialize Vault (Offline):

# Initialize Vault
vault operator init -key-shares=5 -key-threshold=3

# Save unseal keys and root token securely
# Store in secure enclave or hardware security module

Logging & Auditing

What Must Be Logged

Secret Access Events: - Secret retrieval (who, when, which secret) - Secret creation/modification - Secret deletion - Failed authentication attempts - Rotation events

Vault Audit Log:

# vault.hcl
audit_device "file" {
  path = "/var/log/vault/audit.log"
  format = "json"
  log_raw = false
  hmac_accessor = true
}

audit_device "syslog" {
  facility = "AUTH"
  tag = "vault"
}

Audit Log Format:

{
  "time": "2024-01-15T10:30:45Z",
  "type": "response",
  "auth": {
    "client_token": "hmac-sha256:abc123...",
    "accessor": "hmac-sha256:def456...",
    "display_name": "github-actions",
    "policies": ["default", "github-actions"]
  },
  "request": {
    "id": "req-789",
    "operation": "read",
    "path": "database/creds/myapp"
  },
  "response": {
    "data": {
      "username": "v-token-myapp-abc123",
      "password": "hmac-sha256:ghi789..."
    }
  }
}

Loki Log Queries

Secret Access Queries:

# All secret access in last hour
{job="vault"} |= "response" | json | request_path=~"secret.*" | line_format "{{.auth.display_name}} accessed {{.request.path}} at {{.time}}"

# Failed authentication attempts
{job="vault"} |= "error" | json | error =~ ".*authentication.*" | line_format "{{.auth.display_name}} failed auth: {{.error}}"

# Secret rotation events
{job="vault"} |= "rotation" | json | line_format "Secret {{.secret_name}} rotated by {{.user}}"

Prometheus Metrics:

# vault-exporter metrics
vault_secret_access_total{secret_path="database/creds/myapp", status="success"} 1250
vault_secret_access_total{secret_path="database/creds/myapp", status="error"} 5
vault_secret_rotation_total{secret_path="database/creds/myapp"} 12
vault_secret_expiry_seconds{secret_path="database/creds/myapp"} 86400

Alert Rules:

# prometheus-alerts.yaml
groups:
  - name: secrets
    rules:
      - alert: SecretAccessFailureRate
        expr: rate(vault_secret_access_total{status="error"}[5m]) > 0.1
        for: 5m
        annotations:
          summary: "High secret access failure rate"
          description: "{{ $labels.secret_path }} has {{ $value }} failures/sec"

      - alert: SecretExpiringSoon
        expr: vault_secret_expiry_seconds < 86400
        for: 1h
        annotations:
          summary: "Secret expiring soon"
          description: "{{ $labels.secret_path }} expires in {{ $value }} seconds"

      - alert: UnauthorizedSecretAccess
        expr: increase(vault_audit_log_unauthorized_total[5m]) > 0
        annotations:
          summary: "Unauthorized secret access attempt"
          description: "Unauthorized access to {{ $labels.secret_path }}"

System-Wide Secret Usage Audit

Kubernetes Secret Audit:

#!/bin/bash
# audit-k8s-secrets.sh

# List all secrets
kubectl get secrets -A -o json | \
  jq -r '.items[] | "\(.metadata.namespace) \(.metadata.name) \(.type)"'

# Find secrets with old creation dates
kubectl get secrets -A -o json | \
  jq -r '.items[] | select(.metadata.creationTimestamp < "2023-01-01") | "\(.metadata.namespace)/\(.metadata.name) created \(.metadata.creationTimestamp)"'

# Find secrets not referenced by any pod
kubectl get secrets -A -o json > /tmp/all-secrets.json
kubectl get pods -A -o json | \
  jq -r '.items[].spec.containers[].envFrom[]?.secretRef.name // empty' | \
  sort -u > /tmp/used-secrets.txt

# Compare
comm -23 <(jq -r '.items[].metadata.name' /tmp/all-secrets.json | sort) \
         <(sort /tmp/used-secrets.txt)

Incident Response

Immediate Revocation Procedures

Revocation Playbook:

#!/bin/bash
# revoke-secret.sh

SECRET_NAME="${1}"
SECRET_TYPE="${2:-generic}"

case "$SECRET_TYPE" in
  "postgres")
    # Revoke Postgres user
    psql -U postgres -c "REVOKE ALL PRIVILEGES ON DATABASE mydb FROM ${SECRET_NAME};"
    psql -U postgres -c "DROP USER IF EXISTS ${SECRET_NAME};"
    ;;

  "redis")
    # Rotate Redis password immediately
    NEW_PASSWORD=$(openssl rand -base64 32)
    redis-cli CONFIG SET requirepass "$NEW_PASSWORD"
    kubectl create secret generic redis-credentials \
      --from-literal=password="$NEW_PASSWORD" \
      --dry-run=client -o yaml | \
      kubectl apply -f -
    ;;

  "jwt")
    # Remove JWT key from active keys
    kubectl delete secret jwt-signing-key-"${SECRET_NAME}"
    # Update key manager to exclude key
    ;;

  "api-key")
    # Revoke API key in database
    psql -U postgres -d mydb -c \
      "UPDATE api_keys SET revoked=true WHERE key_id='${SECRET_NAME}';"
    ;;
esac

# Force pod restarts
kubectl rollout restart deployment --all -A

# Log revocation
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) SECRET_REVOKED: ${SECRET_NAME} (${SECRET_TYPE})" \
  >> /var/log/security/secret-revocations.log

Blast Radius Analysis

Analysis Script:

#!/bin/bash
# analyze-blast-radius.sh

SECRET_NAME="${1}"

echo "Analyzing blast radius for secret: ${SECRET_NAME}"

# Find all pods using this secret
kubectl get pods -A -o json | \
  jq -r --arg secret "$SECRET_NAME" \
    '.items[] | select(.spec.containers[].envFrom[]?.secretRef.name == $secret or .spec.volumes[]?.secret.secretName == $secret) | "\(.metadata.namespace)/\(.metadata.name)"'

# Find all deployments
kubectl get deployments -A -o json | \
  jq -r --arg secret "$SECRET_NAME" \
    '.items[] | select(.spec.template.spec.containers[].envFrom[]?.secretRef.name == $secret) | "\(.metadata.namespace)/\(.metadata.name)"'

# Find all services
kubectl get services -A -o json | \
  jq -r --arg secret "$SECRET_NAME" \
    '.items[] | select(.metadata.annotations."secret" == $secret) | "\(.metadata.namespace)/\(.metadata.name)"'

# Check CI/CD pipelines
grep -r "$SECRET_NAME" .github/workflows/ .gitlab-ci.yml 2>/dev/null || echo "No CI/CD references found"

Rapid Rotation Automation

Emergency Rotation Script:

#!/bin/bash
# emergency-rotate.sh

set -euo pipefail

SECRET_TYPE="${1}"
NAMESPACE="${2:-production}"

case "$SECRET_TYPE" in
  "all")
    ./rotate-postgres-password.sh
    ./rotate-redis-auth.sh
    ./rotate-jwt-keys.sh
    ./rotate-tls-cert.sh
    ;;

  "postgres")
    NEW_PASSWORD=$(openssl rand -base64 32)
    kubectl create secret generic postgres-credentials \
      --from-literal=password="$NEW_PASSWORD" \
      --namespace="$NAMESPACE" \
      --dry-run=client -o yaml | \
      kubectl apply -f -
    kubectl rollout restart deployment -n "$NAMESPACE"
    ;;

  "redis")
    NEW_TOKEN=$(openssl rand -base64 32)
    redis-cli CONFIG SET requirepass "$NEW_TOKEN"
    kubectl create secret generic redis-credentials \
      --from-literal=password="$NEW_TOKEN" \
      --namespace="$NAMESPACE" \
      --dry-run=client -o yaml | \
      kubectl apply -f -
    kubectl rollout restart deployment -n "$NAMESPACE"
    ;;
esac

# Notify team
curl -X POST "$SLACK_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d "{\"text\": \"🚨 Emergency secret rotation completed for ${SECRET_TYPE}\"}"

Developer Notification Template

Notification Script:

#!/bin/bash
# notify-secret-rotation.sh

SECRET_NAME="${1}"
ROTATION_REASON="${2:-scheduled}"

# Email template
cat > /tmp/notification.txt <<EOF
Subject: Secret Rotation Notification: ${SECRET_NAME}

A secret has been rotated:

Secret: ${SECRET_NAME}
Reason: ${ROTATION_REASON}
Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)

Action Required:
- Update local .env files if applicable
- Restart local development services
- Update any cached credentials

If you experience authentication issues, contact the infrastructure team.

This is an automated notification from the secrets management system.
EOF

# Send notification
mail -s "Secret Rotation: ${SECRET_NAME}" team@example.com < /tmp/notification.txt

# Slack notification
curl -X POST "$SLACK_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d "{
    \"text\": \"Secret Rotation: ${SECRET_NAME}\",
    \"blocks\": [
      {
        \"type\": \"section\",
        \"text\": {
          \"type\": \"mrkdwn\",
          \"text\": \"*Secret Rotation Notification*\n\n*Secret:* ${SECRET_NAME}\n*Reason:* ${ROTATION_REASON}\n*Time:* $(date -u +%Y-%m-%dT%H:%M:%SZ)\"
        }
      }
    ]
  }"

Forced Session Invalidation

Postgres Session Invalidation:

-- Terminate all connections for a user
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE usename = 'compromised_user';

-- Revoke and recreate user
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM compromised_user;
DROP USER compromised_user;

Redis Session Invalidation:

# Rotate password (invalidates all connections)
redis-cli CONFIG SET requirepass "$NEW_PASSWORD"

# Kill all existing connections
redis-cli CLIENT KILL TYPE normal

JWT Session Invalidation:

# Add to JWT blacklist
import redis

redis_client = redis.Redis(host='redis.example.com')

def invalidate_jwt(token: str, expiry_seconds: int = 3600):
    """Add JWT to blacklist"""
    jti = jwt.decode(token, options={"verify_signature": False})["jti"]
    redis_client.setex(f"jwt:blacklist:{jti}", expiry_seconds, "1")

def is_jwt_valid(token: str) -> bool:
    """Check if JWT is blacklisted"""
    jti = jwt.decode(token, options={"verify_signature": False})["jti"]
    return not redis_client.exists(f"jwt:blacklist:{jti}")

Reference Implementation

Complete Setup: RKE2 + Rancher + ESO + SOPS

Directory Structure:

secrets-governance/
├── vault/
│   ├── vault.hcl
│   ├── policies/
│   │   ├── github-actions.hcl
│   │   └── external-secrets.hcl
│   └── init.sh
├── kubernetes/
│   ├── external-secrets/
│   │   ├── secretstore.yaml
│   │   └── externalsecret.yaml
│   ├── sealed-secrets/
│   │   └── sealed-secret.yaml
│   └── cert-manager/
│       ├── cluster-issuer.yaml
│       └── certificate.yaml
├── sops/
│   ├── .sops.yaml
│   └── secrets/
│       ├── postgres-credentials.yaml
│       └── redis-credentials.yaml
├── ansible/
│   ├── rotate-postgres-password.yml
│   └── rotate-redis-auth.yml
├── scripts/
│   ├── rotate-all-secrets.sh
│   ├── emergency-revoke.sh
│   └── audit-secrets.sh
└── monitoring/
    ├── prometheus-alerts.yaml
    └── grafana-dashboard.json

Vault Configuration:

# vault/vault.hcl
storage "consul" {
  address = "127.0.0.1:8500"
  path    = "vault/"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_cert_file = "/etc/vault/tls/vault.crt"
  tls_key_file  = "/etc/vault/tls/vault.key"
}

api_addr = "https://vault.example.com:8200"
ui = true

# Enable audit logging
audit_device "file" {
  path = "/var/log/vault/audit.log"
  format = "json"
}

External Secrets Operator Setup:

# kubernetes/external-secrets/secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: production
spec:
  provider:
    vault:
      server: "https://vault.example.com:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "external-secrets"
          serviceAccountRef:
            name: external-secrets
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: postgres-credentials
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: postgres-credentials
    creationPolicy: Owner
  data:
    - secretKey: username
      remoteRef:
        key: database/postgres
        property: username
    - secretKey: password
      remoteRef:
        key: database/postgres
        property: password

Rotation Automation:

# kubernetes/cronjobs/rotate-secrets.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: rotate-postgres-password
  namespace: production
spec:
  schedule: "0 2 1 * *"  # First day of month
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: secret-rotator
          containers:
            - name: rotate
              image: postgres:15
              command:
                - /bin/bash
                - -c
                - |
                  # Generate new password
                  NEW_PASSWORD=$(openssl rand -base64 32)

                  # Update in Vault
                  vault kv put secret/database/postgres password="$NEW_PASSWORD"

                  # Update Kubernetes secret
                  kubectl create secret generic postgres-credentials \
                    --from-literal=password="$NEW_PASSWORD" \
                    --dry-run=client -o yaml | \
                    kubectl apply -f -

                  # Rolling restart
                  kubectl rollout restart deployment/myapp -n production
          restartPolicy: OnFailure

Monitoring Dashboard:

{
  "dashboard": {
    "title": "Secrets Management",
    "panels": [
      {
        "title": "Secret Access Rate",
        "targets": [
          {
            "expr": "rate(vault_secret_access_total[5m])",
            "legendFormat": "{{secret_path}}"
          }
        ]
      },
      {
        "title": "Secret Expiry",
        "targets": [
          {
            "expr": "vault_secret_expiry_seconds",
            "legendFormat": "{{secret_path}}"
          }
        ]
      },
      {
        "title": "Rotation Events",
        "targets": [
          {
            "expr": "increase(vault_secret_rotation_total[1h])",
            "legendFormat": "{{secret_path}}"
          }
        ]
      }
    ]
  }
}

Common Pitfalls & Anti-Patterns

Hardcoded Secrets in Code

Symptom: Secrets appear in source code, Git history, or container images.

Root Cause: Developers commit secrets accidentally or use secrets in code instead of environment variables.

Corrective Action: - Use pre-commit hooks to scan for secrets:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.4.0
    hooks:
      - id: detect-secrets
- Use environment variables or secret injection patterns - Rotate any exposed secrets immediately - Use Git secrets scanning (GitHub Advanced Security, GitLab Secret Detection)

Long-Lived API Keys with No Rotation Plan

Symptom: API keys created years ago, never rotated, no expiration.

Root Cause: No rotation policy or automation.

Corrective Action: - Implement dual-key rotation pattern - Set expiration dates on all API keys - Automate rotation via cron jobs or operators - Monitor key age and alert on old keys

Shared Credentials Between Services

Symptom: Multiple services use the same database user, API key, or token.

Root Cause: Convenience over security, lack of service isolation.

Corrective Action: - Use dynamic secrets (Vault database engine) - Create per-service credentials - Use service accounts with least privilege - Implement service-to-service authentication (mTLS, JWT)

"Temporary" Secrets That Live 3 Years

Symptom: Secrets created for testing that are still in production.

Root Cause: No expiration or cleanup process.

Corrective Action: - Set TTL on all secrets - Implement automated cleanup of expired secrets - Regular audit to identify stale secrets - Use naming conventions (e.g., temp-* prefix with auto-expiry)

Storing Secrets Unencrypted on Persistent Storage

Symptom: Secrets in plain text files, databases, or object storage.

Root Cause: Lack of encryption at rest.

Corrective Action: - Encrypt all secrets at rest (Vault transit, SOPS, KMS) - Use encrypted volumes for secret storage - Enable encryption at rest for Kubernetes Secrets - Never store secrets in plain text files

Using Kubernetes Secrets Without Encryption at Rest

Symptom: Kubernetes Secrets are base64-encoded but not encrypted.

Root Cause: Encryption at rest not enabled on Kubernetes cluster.

Corrective Action: - Enable EncryptionConfiguration in kube-apiserver - Use external secret management (Vault, ESO) - Consider Sealed Secrets for GitOps workflows - Never rely on base64 encoding as security

Forgetting to Rotate Service Tokens

Symptom: Grafana, MLflow, RabbitMQ service tokens never rotated.

Root Cause: Service tokens treated as "set and forget."

Corrective Action: - Document all service tokens in inventory - Implement rotation schedule for all tokens - Use automated rotation where possible - Monitor token age and alert on old tokens

Circular Dependencies Between Secret Providers and Workloads

Symptom: Vault needs database credentials, but database needs Vault credentials.

Root Cause: Poor architecture, no bootstrap process.

Corrective Action: - Design bootstrap process for secret providers - Use different authentication methods for providers (mTLS, OIDC) - Avoid circular dependencies in architecture - Use initial secrets for provider setup, then rotate

Summary & Checklist

Secrets Governance Checklist

Storage: - [ ] All secrets stored in encrypted form (Vault, SOPS, KMS) - [ ] Kubernetes Secrets have encryption at rest enabled - [ ] No secrets in Git (or encrypted with SOPS) - [ ] Secrets backed up securely

Access Control: - [ ] Least privilege access policies - [ ] Service accounts for machine-to-machine auth - [ ] Audit logging enabled for all secret access - [ ] Regular access reviews

Rotation: - [ ] Rotation schedule defined for all secret types - [ ] Automated rotation implemented where possible - [ ] Dual-key/zero-downtime rotation for critical secrets - [ ] Rotation tested in non-production first

Monitoring: - [ ] Secret access logged and monitored - [ ] Alerts for failed authentication - [ ] Alerts for expiring secrets - [ ] Dashboard for secret health

Incident Response: - [ ] Revocation procedures documented - [ ] Emergency rotation scripts tested - [ ] Blast radius analysis tools available - [ ] Notification templates prepared

Rotation Cadence Table

Secret Type Rotation Frequency Method Zero-Downtime
Postgres Passwords Monthly Ansible/PgCron Yes (dual-user)
Redis AUTH Quarterly Script + K8s Yes (grace period)
TLS Certificates 90 days cert-manager/ACME Yes (reload)
JWT Signing Keys Quarterly Kid-based rotation Yes (multi-key)
API Keys Monthly Dual-key pattern Yes
SSH Host Keys Annually Keygen + restart No (planned downtime)
SSH User Keys Quarterly Keygen + authorized_keys Yes
Docker Registry Quarterly Script + K8s Yes (rolling restart)
SOPS Age Keys Annually Re-encrypt all secrets Yes (dual-key)
Vault Root Token Never (if possible) Manual process N/A
Vault Unseal Keys Annually Rekey operation Yes

Directory Layout for Secrets Governance

docs/best-practices/security/
├── secrets-governance.md (this document)
├── vault-setup.md
├── sops-workflow.md
└── incident-response.md

infrastructure/secrets/
├── vault/
│   ├── config/
│   ├── policies/
│   └── scripts/
├── kubernetes/
│   ├── external-secrets/
│   ├── sealed-secrets/
│   └── cert-manager/
├── sops/
│   ├── .sops.yaml
│   └── secrets/
├── ansible/
│   └── rotation-playbooks/
└── scripts/
    ├── rotation/
    ├── audit/
    └── emergency/

See Also


This guide provides a complete framework for secrets management and key rotation. Start with storage and injection patterns, then implement rotation automation. The goal is security without operational burden.