“Encrypted at rest” is one of those phrases that shows up on feature checklists and means anywhere from “the disk is LUKS’d, which the hosting provider did, not us” to “every byte your data touches on its way to stable storage goes through an authenticated cipher,” and the distance between those two readings is where most of the actual security lives. We ship two products that store sensitive things, MongrelDB as the database and Roamarr as the travel organizer that sits on top of it, and both encrypt at rest with defaults the user never has to configure beyond one secret, which sounds like marketing until you look at what the defaults actually cover, because the interesting question was never whether to encrypt, it was which files people forget about when they say a database is encrypted.
The files nobody remembers
Ask someone what an encrypted database protects and they picture the table files, and that picture is roughly where most implementations stop, but a running storage engine leaves its data all over the place, and every one of those places is a leak. MongrelDB’s page-level encryption is compiled into mongreldb-core always, not behind a feature flag that enterprise pricing unlocks, and the coverage list is the part worth reading: the sorted-run page payloads in the .sr files are AES-256-GCM per page, the write-ahead log segments are encrypted frame by frame, the result cache is encrypted, the global index checkpoint is encrypted, and even the per-page min/max zone maps, the little statistics the engine uses to skip pages during filtered scans, travel inside a per-run AES-256-GCM envelope that gets decrypted once at open, so an encrypted column prunes identically to a plaintext one and the bounds never sit on disk in the clear. That last one is the detail that tells you the design was done by people who had been burned, because zone maps are exactly the kind of metadata file a first-pass implementation leaves plaintext, and a plaintext min/max on an encrypted column is a range oracle handed to anyone holding the drive.
The key hierarchy is a tree with one root the user controls: the passphrase goes through Argon2id and HKDF into a 256-bit key-encryption key that is never persisted anywhere, and that KEK in turn derives the whole working family, a fresh random data-encryption key per sorted run (stored wrapped in the run’s own descriptor), a separate key for the WAL frames, another for the result cache, another for the index checkpoint, plus a MAC key that signs each run’s header and directory so structural tampering gets caught on open rather than silently served. Because every working key hangs off the KEK, rotating the passphrase is a re-wrap operation instead of a full re-encrypt of the database, which is the difference between key rotation being a maintenance task and key rotation being a migration, and the passphrase itself is not the only root on offer, since a raw key file or a Vault Transit envelope can stand in when an operations team wants the secret outside the process environment. Opening one looks like this:
// Create: generates a random salt, persists it to _meta/keys
let db = Table::create_encrypted(dir, schema, 1 /* table_id */, "my-passphrase")?;
// Open: reads the salt, re-derives the same KEK
let db = Table::open_encrypted(dir, "my-passphrase")?;
One caveat we keep in the README on purpose: not everything on disk is ciphertext, and the doc says so in a table labeled “what is and isn’t encrypted.” The run headers and directory are authenticated by that keyed HMAC rather than encrypted, and the manifest and schema stay plaintext entirely, because an encryption feature whose documentation only lists the good half is how people build threat models on sand, and knowing that the schema is visible is exactly the kind of fact a reviewer needs before they need it.
Why Roamarr encrypts twice
Whole-database encryption has a well-known hole, and the hole is that the database decrypts pages for anyone who can read them through the engine, so a backup taken through the application’s own export path, or a query result rendered into a log line, or a support screenshot of an admin page, all carry plaintext the moment the data crosses out of the storage layer. Roamarr stores exactly the categories of data that hurt most in those moments: passport numbers, SMTP credentials, OIDC client secrets, TOTP seeds, notification tokens, receipt and document scans, so on top of the encrypted MongrelDB database underneath, Roamarr encrypts the sensitive fields individually with AES-256-GCM before they ever reach a row, and the attachment store chunks files and encrypts the chunks on disk separately. The result is two independent layers with two different failure modes, and an export of the trips table hands you ciphertext in the columns that matter.
The whole thing hangs off one environment variable, and this is where the “by default” part earns the title:
export ROAMARR_SECRET=$(openssl rand -base64 32)
If the secret is missing or the wrong length, Roamarr refuses to boot, the setup page blocks admin creation until a valid key exists, and there is no fallback path where the app starts anyway with encryption off and a warning nobody reads, because a security feature that degrades to a log line is a security feature that will be off in production within a month. Twenty years ago the equivalent decision was whether to stripslashes at the front door, and the lesson from the magic_quotes era was that security you have to remember to enable gets remembered exactly once, in the incident report, so we made the wrong configuration unbootable instead of merely discouraged.
The honest part of the docs
Just as important as what Roamarr encrypts is that the docs say what it does not: journal free-text fields, notes, and various metadata columns live inside the encrypted database but carry no field-level cipher of their own, and the docs tell you not to type secrets into them. The same honesty applies to the cost side of field-level encryption, which rarely gets said out loud: a column of AES-256-GCM ciphertext cannot be searched, deduplicated, or joined on, because every encryption of the same value produces different bytes, so the passport number you encrypted is one the database can fetch by row but never look up by value, and Roamarr accepts that tradeoff deliberately on the fields where exposure hurts more than convenience. MongrelDB attacks the same problem from the other direction for its own indexable encrypted columns, deriving a per-column key that feeds deterministic HMAC-SHA256 equality tokens and order-preserving range tokens, which gets you lookups back at the price of leaking equality and ordering to anyone holding the files, and we treat that as a decision the schema author makes field by field rather than a default we impose, because the right answer genuinely differs between an email address and a passport scan. That kind of admission looks like weakness on a feature matrix and is actually the strongest signal in the whole design, because a product that claims everything is encrypted equally is a product whose authors have stopped thinking in layers, and layers are the only thing that works, since each one fails differently and the failures don’t line up.
What this does not protect you from
Encrypted at rest defends against a stolen drive, a leaked backup archive, a decommissioned VPS whose disks get resold, and a database dump that ends up somewhere it shouldn’t, and it defends against essentially nothing else, so if an attacker gets code execution next to the running process, the process holds the keys and the pages decrypt on read, which is why the threat model sentence matters more than the algorithm sentence. AES-256-GCM is the right choice and also the easy choice; the part that actually earns the engineering hours is everything around it, because GCM dies quietly the moment a nonce repeats under the same key, so every page and every field needs its own nonce discipline across millions of encryptions, and each ciphertext needs its associated data wired so a page lifted out of one file can’t be replayed into another, which is the unglamorous work that never makes the feature list and absolutely belongs in the audit. The modern equivalent of the old advice holds: encrypt the disk too, keep the backups somewhere you control, and treat “encrypted at rest” as the floor of a privacy posture rather than the ceiling of one.
