Rotate the payments database credentials
Swap the password payments-api uses for Postgres while every pod keeps serving.
The payments database has two application roles, payments_app_a and payments_app_b. Only one is active at a time; the other holds the previous password and stays valid until the next rotation. Rotation writes a new password to the standby role, points Vault at it, and reloads the pods one by one. No pod ever holds a password that Postgres has already rejected.
Assumptions in this runbook: Postgres 15 on RDS, eight payments-api pods, and Vault at secret/payments/db. A pod reads the secret at boot and again on SIGHUP. The rotation takes about 20 minutes and needs no change window. Run it from the ops bastion with the payments-ops role.
Procedure
- 1Find the active role
The active role is the one Vault serves today. The other role is the target of this rotation.
bashvault kv get -field=username secret/payments/dbIf the output is payments_app_a, the target is payments_app_b, and the reverse.
- 2Generate the new password and set it on the target role
The password is 32 bytes from /dev/urandom. It never appears in shell history because it lives in a variable.
bashNEWPW=$(openssl rand -base64 32) psql "$PAYMENTS_ADMIN_DSN" -c "ALTER ROLE payments_app_b WITH PASSWORD '$NEWPW' VALID UNTIL 'infinity';" - 3Prove the target role connects
A failed login here costs nothing. A failed login after step 4 costs a pod.
bashPGPASSWORD="$NEWPW" psql -h payments-db.internal -U payments_app_b -d payments -c "select 1;"Stop if this fails. The pods still use the old credential.
- 4Write the new credential to Vault
Vault keeps the previous version, so the old username and password stay readable for the rollback.
bashvault kv put secret/payments/db username=payments_app_b password="$NEWPW" - 5Reload the pods one at a time
On SIGHUP a pod opens a new pool with the new credential. It drains the old pool over 30 seconds. Wait for the pod to report healthy before the next one.
bashfor p in $(kubectl -n payments get pods -l app=payments-api -o name); do kubectl -n payments exec "$p" -- kill -HUP 1 kubectl -n payments wait --for=condition=Ready "$p" --timeout=90s doneWatch the payments-api error rate during the loop. Stop the loop on any rise above 0.1%.
- 6Confirm no session uses the old role
The old pools drain 30 seconds after the last reload. A session that stays means one pod did not reload.
bashpsql "$PAYMENTS_ADMIN_DSN" -c "select count(*) from pg_stat_activity where usename = 'payments_app_a';" - 7Expire the old role's password
The old role keeps its grants, so the next rotation reuses it. Only its password ends.
bashpsql "$PAYMENTS_ADMIN_DSN" -c "ALTER ROLE payments_app_a VALID UNTIL '$(date -u -d '+1 hour' +%FT%TZ)';"One hour, not now. A pod that restarts during the drain must still be able to reconnect with the old credential.
- 8Record the rotation
Post the date, the new active role, and your name in
Checks and where each one sends you
Rollback is always a Vault version rollback plus a SIGHUP. Postgres still accepts the old credential until step 7, so a rollback at any earlier step needs no database change. After step 7 the old password expires in one hour. A rollback inside that hour still works. After the hour, the fix is a fresh rotation onto the old role.
Who holds what
| Credential | Holder | Where it lives | Changes when |
|---|---|---|---|
| payments_app_a password | Postgres, Vault version N-1 | Vault secret/payments/db, previous version | Every second rotation |
| payments_app_b password | Postgres, Vault version N | Vault secret/payments/db, current version | Every second rotation |
| Pod connection pool | payments-api pods | Process memory; refreshed on SIGHUP | Each rotation, one pod at a time |
| PAYMENTS_ADMIN_DSN | Ops bastion | Vault secret/payments/admin, 1-hour lease | Never rotated by this runbook |
The admin credential is a Vault lease. Run vault login before the runbook if the lease is older than an hour.