ENCRYPTION_KEY seals every secret Coldfeet stores: DKIM private keys, downstream
relay passwords, integration credentials and TOTP secrets. Replacing it naively
makes all of them unreadable — signing breaks, delivery breaks, and 2FA locks
everyone out, all without an obvious error.
Ciphertext therefore carries the id of the key that produced it:
enc:<key-id>:<iv>:<tag>:<data>
Old keys stay available for reads while a background sweep re-encrypts each row, so rotation is a migration rather than a cutover.
Back up first. A rotation touches every sealed column.
sudo ./install/backup.sh --label pre-rotation
Add the new key, keeping the old one readable. In .env.production:
ENCRYPTION_KEY=<new 64-char hex secret>
ENCRYPTION_KEY_ID=2
ENCRYPTION_KEYS_OLD=1:<the previous ENCRYPTION_KEY value>
Generate the new secret with openssl rand -hex 32. If the previous install
never set ENCRYPTION_KEY_ID, its values are tagged legacy, so use
ENCRYPTION_KEYS_OLD=legacy:<previous value>.
Restart so both keys are loaded.
docker compose -f docker-compose.prod.yml up -d --force-recreate api worker
The API logs how many retired keys it loaded at startup. Everything keeps working at this point: new writes use the new key, old values still read.
Check what is outstanding. Admin → Security, or:
curl -s -H "Authorization: Bearer $TOKEN" \
https://your-host/api/v1/admin/security/encryption-key
Run the rotation.
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
https://your-host/api/v1/admin/security/encryption-key/rotate
The response reports how many values moved per column, and the action is written to the audit log.
Confirm nothing is pending, then remove the old key:
ENCRYPTION_KEYS_OLD=
Restart once more. Keep the old secret in your password manager until the backups that contain it have aged out — restoring one of those needs it.
The rotation report counts unreadable rows. That means a value was sealed with
a key not present in ENCRYPTION_KEYS_OLD, usually because ENCRYPTION_KEY was
changed at some point without rotating. Find the old secret and add it, then run
the rotation again.
If the old secret is genuinely gone, the affected data must be recreated:
| Column | Recovery |
|---|---|
domain.dkimPrivateKeyEnc | Regenerate DKIM for the domain and republish the DNS record |
integrationConfig relay password | Re-enter it in the domain's relay settings |
user.totpSecret | Have the user re-enrol 2FA |
platformSetting.valueEnc | Re-enter the setting in Admin → Integrations |