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:
- Creation: How secrets are generated (random passwords, PKI certs, API keys)
- Storage: Where secrets are stored (Vault, SOPS, KMS, encrypted at rest)
- Distribution: How secrets reach workloads (injection, sidecars, env vars)
- Injection: How applications consume secrets (files, env vars, API calls)
- Expiration: How secrets expire and trigger rotation
- Rotation: How secrets are rotated without downtime
- Revocation: How compromised secrets are invalidated
- 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¶
- Secret Creation: Secrets generated in Vault, SOPS, or KMS
- Storage: Encrypted storage (Vault transit, SOPS encryption, KMS encryption)
- Distribution: External Secrets Operator or GitOps syncs to Kubernetes
- Injection: Pods mount secrets as volumes or env vars
- Rotation: Cron jobs or operators trigger rotation workflows
- 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:
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
}
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
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¶
- Secrets Management Best Practices - High-level secrets management patterns
- PostgreSQL Security Best Practices - Database security
- CI/CD Pipeline Best Practices - Secure CI/CD patterns
- Monitoring & Observability - Security monitoring
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.