End-to-End Secrets Management & Key Rotation Governance: Best Practices for Infrastructure, Applications, and CI/CD¶
Objective: Master production-grade secrets management across hybrid multi-cloud and air-gapped environments. When you need to secure credentials, rotate keys safely, inject secrets into workloads, and maintain compliance—this guide provides complete patterns and automation.
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.