A KMS key per project for backups
Decision (2026-10-22): each backup bucket is encrypted with its own customer-managed KMS key, with S3 Bucket Keys on, and Postgres keeps shipping WAL as often as it does today (Crossplane).
Cost
Prices from the AWS KMS pricing page, us-east-1, checked 2026-10-22.
| Item | Price | Volume | Per month |
|---|---|---|---|
| Customer-managed keys | $1 per key | 2 keys (app-backups, cnpg-backups) |
$2.00 |
| KMS requests, worst case | $0.03 per 10,000 | about 18,000 (below) | $0.05 |
| KMS requests, with Bucket Keys | same | a small fraction of that | about $0 |
| Total | about $2 |
The request volume. CNPG ships a WAL file at least every 5 minutes, even
when the database is idle (archive_timeout). Two clusters send roughly 600
files a day, about 18,000 a month. The nightly Gitea and MinIO backups add
a handful more. Even at one KMS call per file, that's about 5 cents.
Bucket Keys make it smaller still. With them on, S3 doesn't call KMS for every object. It gets a bucket-level key from KMS and reuses it for a while, which AWS says cuts KMS request costs by up to 99%. Realistically, request charges round to zero. The keys themselves are the cost.
What it buys
Who can read a backup is decided by the key, not just the bucket. The key policy lets only that project's roles use it, from this account, through S3. A mistake in a bucket policy alone doesn't expose backups.
Writers that can't read. Gitea's and MinIO's backup roles can upload but not read or delete, so anything that takes over the cluster can't wipe or exfiltrate the backups.
An audit trail. Every use of the key is a CloudTrail event, so "who read the backups" has an answer.
Alternatives
S3-managed encryption (SSE-S3), free. Encrypted at rest, but anyone with S3 access to the bucket can read the objects. No second lock, no per-project boundary, no record of each read. That's what the buckets used before.
One key for every project, $1. Saves a dollar, but any project's roles could decrypt another's backups. Per-project keys keep the blast radius to one project.
Ship WAL every few hours to cut KMS calls. Rejected. How often WAL ships decides how much data can be lost: today, a lost database restores to within about 5 minutes. Batching to every few hours would risk hours of Gitea and Zitadel writes to save a fraction of a cent. The setting behind it,
archive_timeout, is a recovery-point decision, not a cost one.
Housekeeping
The same review turned up an older customer-managed key that CloudTrail showed had never encrypted or decrypted anything in 90 days. It was scheduled for deletion with the full 30-day window. AWS doesn't charge for keys pending deletion, so the longer window costs nothing and leaves time to cancel.
Trade-off that isn't about money
Every WAL file now depends on KMS. If KMS were unavailable in us-east-1, or the key were disabled, archiving would pause until it came back. Postgres keeps the WAL locally and retries, so nothing is lost unless the disk fills. That's why no deployer role can disable or delete a project key.
Revisit when
The number of projects grows. Each adds $1 a month. At dozens, weigh per-project keys against grouping projects by data sensitivity.
KMS prices change, or Bucket Keys stop applying to a workload.
WAL volume grows a lot, for example a busy new database. Requests would still be cheap, but the math should be redone.