Skip to content

Secret Rotation Schedule

Last updated: 2026-02-28

Rotation procedures and schedule for all secrets in the k3s cluster.

1. Secret Inventory

Secret Namespace Key(s) Rotation Cadence Method
proxmox_api_token — (Terraform) proxmox_api_token 90 days Proxmox UI + tfvars
k3s_token — (Ansible / env) K3S_TOKEN 180 days Ansible re-deploy
vaultwarden-secret vaultwarden ADMIN_TOKEN 90 days kubectl re-create
authentik-secret authentik AUTHENTIK_SECRET_KEY, POSTGRES_PASSWORD 90 days kubectl re-create + pod restart
n8n-secret n8n N8N_ENCRYPTION_KEY, N8N_USER_MANAGEMENT_JWT_SECRET 180 days kubectl re-create + pod restart
code-server-secret apps PASSWORD 180 days kubectl re-create + pod restart
pangolin-secret apps PANGOLIN_APP_SECRET 180 days kubectl re-create + pod restart
discourse-secret discourse POSTGRESQL_PASSWORD, DISCOURSE_PASSWORD 90 days kubectl re-create + pod restart
notify-channel-secrets apps TELEGRAM_BOT_TOKEN, TWILIO_AUTH_TOKEN, SMTP creds 90 days kubectl re-create + pod restart
SSH keys (ed25519) — (all nodes) ~/.ssh/id_ed25519 365 days Key regen + Ansible push
NPM admin password — (Docker host) Web UI login 90 days NPM web UI
Cloudflare API token — (NPM DNS challenge) Let's Encrypt DNS-01 180 days Cloudflare dashboard + NPM

2. Rotation Procedures

2.1 Proxmox API Token

# 1. Generate new token in Proxmox UI:
#    Datacenter → Permissions → API Tokens → Add
#    User: root@pam (or dedicated service account)

# 2. Update local tfvars (never committed)
vim terraform/terraform.tfvars
# Set: proxmox_api_token = "PVEAPIToken=user@pam!tokenid=NEW_SECRET"

# 3. Validate
cd terraform && terraform plan

# 4. Revoke old token in Proxmox UI

2.2 k3s Cluster Token

The k3s token authenticates worker nodes to the control plane. Rotation requires rejoining all workers.

# 1. Generate new token
NEW_TOKEN=$(openssl rand -hex 32)

# 2. On master node — update the token file
ssh K8-Master "sudo k3s token rotate --new-token ${NEW_TOKEN}"

# 3. On each worker — rejoin with new token
for w in k3s-worker-01 k3s-worker-02 k3s-worker-03; do
  ssh $w "sudo systemctl stop k3s-agent"
  ssh $w "sudo sed -i \"s|^K3S_TOKEN=.*|K3S_TOKEN=${NEW_TOKEN}|\" /etc/systemd/system/k3s-agent.service.env"
  ssh $w "sudo systemctl daemon-reload && sudo systemctl start k3s-agent"
done

# 4. Update local env for future Ansible runs
export K3S_TOKEN="${NEW_TOKEN}"
# Store in password manager

# 5. Verify all nodes rejoin
kubectl get nodes

2.3 App-Level Kubernetes Secrets

All app secrets follow the same pattern: re-create the secret, then restart the deployment.

# Generic rotation template
kubectl create secret generic <SECRET_NAME> -n <NAMESPACE> \
  --from-literal=KEY1='NEW_VALUE_1' \
  --from-literal=KEY2='NEW_VALUE_2' \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl rollout restart deployment/<DEPLOYMENT> -n <NAMESPACE>
kubectl rollout status deployment/<DEPLOYMENT> -n <NAMESPACE>

Vaultwarden

NEW_ADMIN=$(openssl rand -hex 32)

kubectl create secret generic vaultwarden-secret -n vaultwarden \
  --from-literal=ADMIN_TOKEN="${NEW_ADMIN}" \
  --from-literal=DOMAIN='https://vault.smartmur.ca' \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl rollout restart deployment/vaultwarden -n vaultwarden

Authentik

NEW_SECRET_KEY=$(openssl rand -hex 50)
NEW_PG_PASS=$(openssl rand -hex 24)

# WARNING: Changing POSTGRES_PASSWORD requires updating the PG database user too.
# If only rotating the app secret key, keep the existing PG password.

kubectl create secret generic authentik-secret -n authentik \
  --from-literal=AUTHENTIK_SECRET_KEY="${NEW_SECRET_KEY}" \
  --from-literal=POSTGRES_DB='authentik' \
  --from-literal=POSTGRES_USER='authentik' \
  --from-literal=POSTGRES_PASSWORD="${NEW_PG_PASS}" \
  --dry-run=client -o yaml | kubectl apply -f -

# If PG password changed, update the database role BEFORE restarting:
kubectl exec -n authentik deploy/authentik-postgres -- \
  psql -U authentik -c "ALTER ROLE authentik WITH PASSWORD '${NEW_PG_PASS}';"

kubectl rollout restart deployment/authentik-server -n authentik
kubectl rollout restart deployment/authentik-worker -n authentik

n8n

# CAUTION: Rotating N8N_ENCRYPTION_KEY will make existing encrypted
# credentials unreadable. Export credentials first if needed.

NEW_JWT=$(openssl rand -hex 32)

kubectl create secret generic n8n-secret -n n8n \
  --from-literal=DB_TYPE='sqlite' \
  --from-literal=N8N_ENCRYPTION_KEY='KEEP_EXISTING_UNLESS_COMPROMISED' \
  --from-literal=N8N_USER_MANAGEMENT_JWT_SECRET="${NEW_JWT}" \
  --from-literal=WEBHOOK_URL='https://n8n.smartmur.ca' \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl rollout restart deployment/n8n -n n8n

Notification Channel Secrets (Telegram, Twilio, SMTP)

# Rotate individual provider credentials as needed.
# Regenerate Telegram bot token via @BotFather.
# Regenerate Twilio auth token via Twilio Console.
# Regenerate SMTP app password via email provider.

kubectl create secret generic notify-channel-secrets -n apps \
  --from-literal=TELEGRAM_BOT_TOKEN='...' \
  --from-literal=TELEGRAM_CHAT_ID='...' \
  --from-literal=TWILIO_ACCOUNT_SID='...' \
  --from-literal=TWILIO_AUTH_TOKEN='...' \
  --from-literal=TWILIO_WHATSAPP_FROM='whatsapp:+14155238886' \
  --from-literal=TWILIO_WHATSAPP_TO='whatsapp:+10000000000' \
  --from-literal=ALERT_SMTP_HOST='smtp.gmail.com' \
  --from-literal=ALERT_SMTP_PORT='587' \
  --from-literal=ALERT_EMAIL_USER='...' \
  --from-literal=ALERT_EMAIL_PASSWORD='...' \
  --from-literal=ALERT_FROM_EMAIL='notify@kwe2.org' \
  --from-literal=ALERT_TO_EMAIL_1='you@example.com' \
  --dry-run=client -o yaml | kubectl apply -f -

# Restart any pods that consume this secret

Code Server / Pangolin / Discourse

# Code Server
kubectl create secret generic code-server-secret -n apps \
  --from-literal=PASSWORD="$(openssl rand -base64 24)" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment/code-server -n apps

# Pangolin
kubectl create secret generic pangolin-secret -n apps \
  --from-literal=PANGOLIN_APP_SECRET="$(openssl rand -hex 32)" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment/pangolin -n apps

# Discourse (PG password + admin password)
kubectl create secret generic discourse-secret -n discourse \
  --from-literal=POSTGRESQL_PASSWORD="$(openssl rand -hex 24)" \
  --from-literal=DISCOURSE_EMAIL='admin@example.com' \
  --from-literal=DISCOURSE_PASSWORD="$(openssl rand -base64 24)" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment/discourse -n discourse

2.4 SSH Keys

# 1. Generate new key pair on Mac
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_new -C "dre@lab-$(date +%Y%m)"

# 2. Push new public key to all nodes (while old key still works)
for host in K8-Master k3s-worker-01 k3s-worker-02 k3s-worker-03; do
  ssh-copy-id -i ~/.ssh/id_ed25519_new.pub $host
done

# 3. Also push to Proxmox and TrueNAS
ssh-copy-id -i ~/.ssh/id_ed25519_new.pub root@192.168.100.100
ssh-copy-id -i ~/.ssh/id_ed25519_new.pub ray@192.168.13.69

# 4. Test new key on all hosts
for host in K8-Master k3s-worker-01 k3s-worker-02 k3s-worker-03; do
  ssh -i ~/.ssh/id_ed25519_new $host "hostname"
done

# 5. Swap into place
mv ~/.ssh/id_ed25519 ~/.ssh/id_ed25519_old
mv ~/.ssh/id_ed25519.pub ~/.ssh/id_ed25519_old.pub
mv ~/.ssh/id_ed25519_new ~/.ssh/id_ed25519
mv ~/.ssh/id_ed25519_new.pub ~/.ssh/id_ed25519.pub

# 6. Update terraform.tfvars with new public key
# 7. Remove old key from all hosts after confirming new key works

# 8. Remove old authorized key from nodes
OLD_KEY_FINGERPRINT=$(ssh-keygen -lf ~/.ssh/id_ed25519_old.pub | awk '{print $2}')
echo "Remove entries matching: ${OLD_KEY_FINGERPRINT}"

3. Post-Rotation Validation

Run these checks after rotating each secret category.

Secret Rotated Validation Command Expected Result
Proxmox API token cd terraform && terraform plan Plan succeeds, no auth errors
k3s token kubectl get nodes All nodes Ready
Vaultwarden curl -sf https://vault.smartmur.ca/alive HTTP 200
Authentik curl -sf https://auth.smartmur.ca/-/health/live/ HTTP 200
n8n curl -sf https://n8n.smartmur.ca/healthz HTTP 200
Code Server curl -sf -o /dev/null -w '%{http_code}' https://code.smartmur.ca HTTP 200/302
Pangolin curl -sf -o /dev/null -w '%{http_code}' https://pangolin.smartmur.ca HTTP 200/302
Discourse kubectl logs -n discourse deploy/discourse --tail=20 No crash loops
Notify channels Trigger a test alert via n8n or monitoring Alert arrives on Telegram/email
SSH keys ssh K8-Master hostname Returns hostname
NPM admin Log in at https://npm.smartmur.ca Login succeeds
Cloudflare token Check cert renewal: NPM SSL → *.smartmur.ca expiry date Cert valid

Full cluster health check after any rotation:

kubectl get nodes
kubectl get pods -A | grep -v Running | grep -v Completed
kubectl get events -A --sort-by='.lastTimestamp' | tail -20

4. Rotation Schedule

Cadence Secrets Next Due
90 days Proxmox API token, Vaultwarden admin token, Authentik secret key, Discourse passwords, Notify channel credentials (Telegram/Twilio/SMTP), NPM admin password 2026-05-29
180 days k3s cluster token, n8n JWT secret, Code Server password, Pangolin app secret, Cloudflare API token 2026-08-27
365 days SSH keys 2027-02-28
On compromise Any secret — immediately rotate, follow Security Incident Playbook

Rotation Checklist (run quarterly)

[ ] Proxmox API token rotated
[ ] Vaultwarden admin token rotated
[ ] Authentik secret key rotated
[ ] Discourse DB + admin passwords rotated
[ ] Notify channel secrets reviewed and rotated
[ ] NPM admin password rotated
[ ] All pods healthy after rotation (kubectl get pods -A)
[ ] Updated "Next Due" dates in this document
[ ] Stored new credentials in password manager