Postgres Encryption at Rest: LUKS vs pg_vault_tde and What Stays Plaintext
Short version for the impatient: if "encrypt the database" is a checkbox on a client's security questionnaire, full disk encryption on the VPS ticks it, and a table access method extension does not tick it any harder. If
Short version for the impatient: if "encrypt the database" is a checkbox on a client's security questionnaire, full disk encryption on the VPS ticks it, and a table access method extension does not tick it any harder. If the question is "can someone who walks off with a backup read customer rows without a key you hold elsewhere," that's a different box, and it's the one transparent data encryption is actually for. The rest of this post is me working out which box I'm being asked about, because I got it wrong on a call last month and said yes to the wrong thing.
The trigger for writing this was the pg_vault_tde 1.7.1 release on postgresql.org last week. It's an open source, PostgreSQL-licensed extension that encrypts every tuple with AES-256-GCM at the table access method layer, keeps the keys in HashiCorp Vault, OpenBao, a PKCS#11 token, or a local wallet, and needs no changes to your application. It works on Postgres 17 and 18. I spent an evening reading the README instead of installing it, and I'm glad I read first, because the release note contains a migration step that can leave pg_dump exiting 1 on a table you cared about.
What full disk encryption already covers
Every VPS I run has LUKS on the data volume. Hetzner will happily hand you a box where the whole disk is encrypted and you enter a passphrase at boot, or you set it up yourself in the rescue system. I wrote up what this blog actually runs on a while ago and the disk encryption was the least interesting line in that post, which is the point. It's boring and it works.
Full disk encryption covers exactly one threat: someone gets your physical disk, or a snapshot of it, without also getting a running system. Decommissioned drive, stolen server, a cloned volume, a provider snapshot leaked from an object store. For all of those, the data is ciphertext and the attacker has nothing.
It covers nothing once the machine is up. Postgres reads plaintext pages. Any user with pg_read_server_files, any superuser, anyone with a shell as the postgres OS user, and anyone who can read your backups sees rows in the clear. The Postgres docs are honest about this in their encryption options page: disk encryption protects the disk, not the running database.
So when a questionnaire says "data encrypted at rest," LUKS is a truthful yes. When it says "are data files and backups unreadable without a key held outside the database host," LUKS is a no, and that's where I said yes on the call and shouldn't have.
What pg_vault_tde adds on top
The extension registers a table access method called encrypted_heap. You create a table with it and forget about it:
CREATE EXTENSION pg_vault_tde;
CREATE TABLE customers (
id bigserial PRIMARY KEY,
email text,
iban text,
dob date
) USING encrypted_heap;
INSERT INTO customers (email, iban, dob)
VALUES ('[email protected]', 'DE89 3704 0044 0532 0130 00', '1990-01-15');
SELECT email, iban FROM customers WHERE id = 1;
The SELECT returns plaintext, because decryption happens after the tuple leaves the storage manager and before the executor sees it. Your ORM doesn't know. Your migrations don't know, apart from the USING encrypted_heap clause. The bit that changes the threat model is where the key lives:
# postgresql.conf
shared_preload_libraries = 'pg_vault_tde'
pg_vault_tde.kms_provider = 'vault'
pg_vault_tde.vault_url = 'https://vault.internal:8200'
pg_vault_tde.vault_transit_mount = 'transit'
pg_vault_tde.vault_key_name = 'pg-tde-dek'
pg_vault_tde.vault_ca_cert = '/etc/ssl/vault/ca.pem'
pg_vault_tde.enabled = on
The key encryption key never touches the Postgres host. Each table gets its own data encryption key, wrapped by Vault's Transit engine, cached in shared memory, and rotatable online with pg_vault_tde_rotate_online(). Someone who copies the data directory, or the base backup, gets AES-256-GCM ciphertext and no key. Someone who has a database superuser session still sees plaintext, because the running server can decrypt. TDE does not protect you from your own DBA. It protects you from your DBA's laptop backup and from the person who finds an old pg_basebackup tarball in a bucket.
That's a real, narrower promise than "encrypted database," and I'd rather write it down that way than let a client believe something broader.
What stays in plaintext
This is the section the marketing page won't give you, and to the maintainers' credit the README does. I'm listing it because the questionnaire answer depends on it.
Tuple headers stay plaintext. xmin, xmax, ctid, the infomask bits. MVCC needs them and there's no way around that in an extension. So an attacker with the files can see how many rows exist, when they were touched, and how the table churns. They can't see what's in them.
Statistics stay plaintext. pg_statistic holds most common values and histograms for every column the planner has analysed. On an encrypted_heap table with an email column, the top twenty emails by frequency sit in a catalog table, unencrypted, in 1.7. The README lists this as planned for 1.8. Until then, if the point of encrypting a column is to keep its values out of the files, run ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 0 on that column and accept the worse plans, or don't rely on TDE for that column at all.
Index keys are optional. A regular B-tree on an encrypted table stores plaintext keys, and by default the extension refuses to create one and tells you so. The alternative is tde_btree, which encrypts keys with AES-256-SIV. SIV is equality-preserving and nothing else, so WHERE iban > 'DE' on a tde_btree index returns an empty result rather than an error, and index-only scans are off. If your access pattern needs a range scan on a sensitive column, you're going to get a sequential scan and you should plan capacity for it.
WAL is not encrypted as a stream. The tuple bytes inside WAL records are already ciphertext, because encryption happens before heap_insert(), but the record structure around them is readable. Full WAL encryption needs hooks in XLogInsert() that an extension can't reach, and the README says so plainly rather than promising it for 1.8.
And a WITH HOLD cursor that spills past work_mem writes its result set to a temporary file in plaintext. That one surprised me. It's a niche path, but if you have reporting code that declares held cursors over encrypted tables, that's a plaintext file on disk with no key protecting it.
The performance shape
Every encrypted tuple carries 37 extra bytes: a 12-byte IV, a 16-byte GCM tag, a version byte, and an 8-byte generation counter for key rotation. On a table of narrow rows that's a real percentage. On a table of JSON blobs it's noise.
The cost I'd worry about more is that HOT updates are structurally disabled. The IV-first layout means the encrypted blob has no byte-stable prefix, so Postgres can't do the in-page update that makes UPDATE cheap on heap tables. Every update to an encrypted_heap row is a new tuple plus index maintenance. If you have a hot counter column on a table you want encrypted, split the counter into its own plain table. I've done this before for other reasons; pg_stat_statements will show you which tables are update-heavy before you commit to encrypting them.
I haven't benchmarked this myself yet, so I'm not going to quote a percentage. The README ships a benchmark suite in its containerised tests, and I'd run that against your own row shapes before believing anyone's number, mine included.
The 1.7.1 upgrade trap
Now the release note itself. 1.7.1 fixes ALTER TABLE ... SET ACCESS METHOD encrypted_heap on a populated table. The fix changes how the additional authenticated data is derived for TOAST relations: 1.7.0 bound the GCM tag to the TOAST relation's own OID, and 1.7.1 binds it to the parent table's OID, because that's the one the key lookup already used.
The AAD is never written to disk. The reader has to reproduce exactly what the writer did. So any out-of-line TOAST value written by 1.7.0 or earlier fails authentication under 1.7.1 with AES-256-GCM authentication FAILED, and pg_dump of the affected table exits 1. Inline values under the roughly 2 kB TOAST threshold are fine. Tables with no TOAST data are byte-identical. The ciphertext on disk is untouched, and reinstalling 1.7.0 makes it readable again, so nothing is lost. But pg_dump stops working after the upgrade, which means the export has to happen before it.
The README gives the query to find affected tables while you're still on 1.7.0:
SELECT c.oid::regclass AS table_to_export,
pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_size
FROM pg_class c
JOIN pg_am a ON a.oid = c.relam
WHERE a.amname = 'encrypted_heap'
AND c.reltoastrelid <> 0
AND pg_relation_size(c.reltoastrelid) > 0;
Run that, dump each table it returns, install the new binary, restore. The order matters and it isn't the order most of us do package upgrades in. If your unattended-upgrades or Ansible role pulls the new .deb on its own schedule, the export step gets skipped and you find out when the nightly dump job fails.
I don't think this reflects badly on the project. A one-line AAD change that invalidates old ciphertext is exactly what you'd expect from an AEAD construction doing its job, and they documented the migration in detail. It does mean this is not yet an extension I'd let auto-update. Pin the version and upgrade by hand, with the release notes open.
Would I run it on a client VPS
For most of the small Laravel and Next.js apps I ship, no. LUKS on the volume, pg_anonymizer on the staging dump, encrypted backups with a key that doesn't live on the box, and honest questionnaire answers. That stack answers "encrypted at rest" truthfully and costs nothing at query time.
I'd reach for pg_vault_tde in two situations. First, when a contract or regulator actually asks for key separation, meaning the key must be held somewhere the database host can't reach without a network call. Vault Transit gives you that and LUKS can't. Second, when backups leave the box in a form I don't fully control, say a managed backup product that ships base backups to a bucket I don't own. Ciphertext in someone else's bucket is a much better feeling than plaintext in someone else's bucket.
In both cases I'd run the local wallet provider first for a week on staging, because it needs no Vault and the operational surface is smaller, then move to Vault or OpenBao once I've seen how the app behaves without HOT updates.
One thing to do this week
Open the last security questionnaire you answered for a client and find the encryption questions. For each one, write down which threat it's actually asking about: disk gone, backups leaked, or insider with database access. Then check your current setup against that threat and nothing wider. If everything you have is LUKS, you cover the first one and part of the second. If a question is about the third and you answered yes, fix the answer this week and decide whether TDE is the fix or whether the honest answer is "no, and here's why that's fine for this data."
I do this kind of infrastructure review for agencies who'd rather not learn Postgres internals the hard way. If that's you, my site has the details.
Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.