Encrypt Backups Before Uploading
Encrypt database dumps with age recipients before they leave the host, keep the identity offline, and prove decryption during a restore test.
Task
Protect backup archives so a leaked object storage bucket or misconfigured ACL does not expose customer data.
When to use this
Nightly pg_dump files land in an object storage bucket protected only by bucket policy. A support engineer downloads one to debug an issue, and the dump now sits unencrypted on a laptop, violating the data handling policy.
Prerequisites
- age 1.1 or newer on the backup host
- pg_dump and pg_restore available for the database version in use
- Object storage with lifecycle rules and versioning
- An offline secret store for the age identity
Steps
1. Generate an age keypair once and store the private identity in an offline vault, never in CI variables.
age-keygen -o /secure/backup-identity.txtExpected output: Public key: age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq
2. Stream the dump directly into age so plaintext is never written to disk.
set -o pipefail
pg_dump -Fc -d appdb \
| age -r age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq \
-o /backups/appdb-2026-10-21.dump.age
Expected output: A single encrypted file named appdb-2026-10-21.dump.age; no plaintext dump exists on the host.
3. Upload the encrypted file and record a checksum for integrity verification.
sha256sum /backups/appdb-2026-10-21.dump.age && aws s3 cp /backups/appdb-2026-10-21.dump.age s3://acme-db-backups/prod/Expected output: The checksum is stored in the backup manifest and the object appears in the bucket with server-side encryption enabled.
4. Restore into a scratch database to prove the identity decrypts the archive and the dump is complete.
age -d -i /secure/backup-identity.txt /backups/appdb-2026-10-21.dump.age \
| pg_restore -h drill-db.internal -U postgres -d restore_drill --no-owner
Expected output: pg_restore completes without errors and row counts in restore_drill match the source snapshot.
Troubleshooting
Problem
age reports failed to open identity file
Solution
Verify the identity file permissions and path; the key must be readable only by the backup user and should never be committed to Git.
Problem
The decrypting host runs out of disk
Solution
Decrypt as a stream into pg_restore instead of writing the plaintext dump first, as shown in step 4.
Limitations
- Encryption at rest does not protect against a compromised host that has the identity mounted.
- Rotating the recipient requires re-encrypting existing archives; keep old identities until retention expires.
Encrypt before storage
Bucket policies, TLS, and server-side encryption protect data only while it stays in the bucket. The moment someone downloads a dump for debugging, the protection ends. Client-side encryption with age keeps the archive unreadable outside the backup host, and the private identity never needs to be available to the storage provider.
Separate the two keys
The age recipient is public and safe to embed in the backup script. The identity is private and belongs in an offline vault or hardware token, not in a CI secret or the same bucket. Split custody: one engineer holds the identity for restores, a second holds the recovery procedure. Losing the identity makes every archive unrecoverable, so test access to it quarterly.
pg_dump -Fc -d appdb \
| age -r age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq \
-o /backups/appdb-2026-10-21.dump.age
Verify integrity
Encryption does not guarantee a usable backup. Store a SHA-256 checksum alongside each object and run a restore drill into a scratch database. If the restore fails, the checksum tells you whether the object was corrupted in transit or the dump itself was incomplete. Record the restore duration as part of the drill.
Plan rotation
Add a second recipient before rotating: encrypt to both keys, verify both decrypt, then retire the old identity after the retention window. Keep the recipient list in version control and the identities in the vault. Do not encrypt with a passphrase typed by a human; automation needs a non-interactive key or a KMS-backed flow.