Run cryptsetup status mail_vol on your production box right now. It says active, and it has said active since the last boot, because a mail server that can't read its own mail can't deliver it. That is the whole problem with treating LUKS as your at-rest story: full-disk encryption defends a powered-off drive, and your drive is never powered off. The moment the volume is unlocked, every mailbox under /var/mail is cleartext to anything running as root — including a tar czf mail-backup.tar.gz /var/mail/vhosts that walks out the door in a compromise, a hypervisor snapshot your cloud provider takes, or the disk you ship back under RMA without wiping.
Those are the states where "at rest" actually bites: a stolen backup archive, a leaked block-storage snapshot, a decommissioned drive, a live root compromise reading files it never held a session for. LUKS covers none of them on a running system. Dovecot's mail_crypt plugin does, because it encrypts the message payloads at the storage layer — the bytes on disk are ciphertext regardless of the filesystem underneath, and they stay ciphertext in every backup and every snapshot.
mail_crypt has shipped in Dovecot core since the 2.2 series, and crypt format version 2 is the default from 2.3 onward. It runs in two fundamentally different modes, and picking the wrong one is the difference between an afternoon of work and a support fire six months later. This is a decision guide first and an implementation guide second.
What mail_crypt actually encrypts — and what it can't
mail_crypt encrypts the message payload objects: the m.* files in mdbox storage, the individual message files in sdbox and Maildir. Each encrypted object carries a Dovecot crypt header on disk — the ASCII string CRYPTED followed by the version bytes \x03\x07. Hexdump an encrypted object and you see that header, then noise.
What it does not touch:
- Filenames and folder structure — mailbox names, the directory tree, and per-folder message counts are all still visible.
- Index and cache files —
dovecot.index*anddovecot.mail.cachehold envelope data, and they are not encrypted. - IMAP metadata — flags, keywords, sizes.
So the subject line is protected (it lives in the payload), but the fact that [email protected] has a folder called Legal with 4,200 messages is not. Set that expectation before anyone types a command — mail_crypt is payload confidentiality, not metadata confidentiality.
The threat model splits cleanly:
- Protects against: stolen or RMA'd disks, snapshotted VM images, leaked backup archives, cloud block-storage snooping. In every one of these the key is absent, so the payload is unrecoverable.
- Does NOT protect: a live authenticated session, where the key is resident in memory. And in global-key mode specifically, it does not stop root — the private key sits on the same box.
That last point is the entire reason two modes exist.
Global key vs per-user key: choose your threat
Global key mode uses one ECDH keypair to encrypt all mail on the server. It is the simplest thing that works: delivery keeps working, server-side search keeps working, admin export keeps working, because the private key is always available to the Dovecot process. The cost is that the private key lives on the server. Root can read it, and a live compromise reads it. Global mode buys you protection against cold storage, disk theft, and leaked backups — the compliance-and-hardware threat — and nothing against your own operators.
Per-user key mode gives every user their own keypair, and the private key is wrapped with the user's login password (%w, the cleartext password captured at authentication time). While the user is logged out, their private key is a blob nobody can unwrap — not even root. That is genuine zero-trust-against-operator for offline mailboxes.
Per-user mode is not free. What breaks:
- Server-side full-text search on encrypted mail — Dovecot can't index a body it can't read without the key. You either run a dedicated FTS decrypt worker or push search to the client.
- Delivery-time Sieve that reads the body — a global Sieve filtering on message content can't decrypt an offline user's key.
- Password reset ≠ mail recovery — reset the login password and the wrapped key is now unwrappable. Lose the password, lose the mail, unless you kept a recovery key alongside it.
My recommendation after running this in production: global key for the disk-theft and compliance posture — it's the 95% case and it costs you nothing operationally. Reach for per-user only when you genuinely need the mail unreadable to the operator while the user is offline, and go in knowing you're trading away search, delivery-time body filters, and easy recovery.
Standing up global-key encryption
Generate an EC keypair on the secp521r1 curve (mail_crypt also supports prime256v1 and RSA; secp521r1 is the sane default), lock it down to the Dovecot user, and store it out of the web root:
mkdir -p /etc/dovecot/keys
openssl genpkey -algorithm EC \
-pkeyopt ec_paramgen_curve:secp521r1 \
-out /etc/dovecot/keys/ecprivkey.pem
openssl pkey -in /etc/dovecot/keys/ecprivkey.pem \
-pubout -out /etc/dovecot/keys/ecpubkey.pem
chown dovecot:dovecot /etc/dovecot/keys/*.pem
chmod 0600 /etc/dovecot/keys/ecprivkey.pem
chmod 0644 /etc/dovecot/keys/ecpubkey.pemThen conf.d/10-mail-crypt.conf:
mail_plugins = $mail_plugins zlib mail_crypt
plugin {
mail_crypt_curve = secp521r1
mail_crypt_global_private_key = < /etc/dovecot/keys/ecprivkey.pem
mail_crypt_global_public_key = < /etc/dovecot/keys/ecpubkey.pem
mail_crypt_save_version = 2
}Restart Dovecot, deliver a test message, and prove it — never trust that encryption is on because the config parsed:
# On disk: ciphertext, starts with the CRYPTED magic header
hexdump -C /var/mail/vhosts/evilmail.pro/alice/mdbox/storage/m.1 | head
# 00000000 43 52 59 50 54 45 44 03 07 ... "CRYPTED"
# Through Dovecot: plaintext, proving the round trip
doveadm fetch -u [email protected] text mailbox INBOX 1 | headIf the hexdump shows readable Received: headers, encryption is not active — check plugin load order and that mail_crypt_save_version is 2.
Per-user keys bound to the login password
Per-user mode uses the same curve setting but wraps each user's private key with their login password. Mint the keypair and configure Dovecot to refuse plaintext keys:
doveadm mailbox cryptokey generate -u [email protected] -Uplugin {
mail_crypt_curve = secp521r1
mail_crypt_save_version = 2
mail_crypt_require_encrypted_user_key = yes
mail_crypt_private_password = %w
}%w is the cleartext login password Dovecot holds during authentication. It never touches disk — it exists only long enough to unwrap the private key for that session.
The three-tier hierarchy in the first diagram — user key wraps folder keys, folder keys encrypt messages via per-message ephemeral ECDH — is not academic. It's what lets you share a folder over ACLs (hand out one folder key, not the master) and rotate a user's key without re-encrypting every message they own. Rotation re-wraps a handful of folder keys; the ciphertext bodies never move.
For the things per-user mode breaks, the mitigations are concrete: run a dedicated FTS worker that holds a decrypt path, or accept client-side search (most modern clients cache locally anyway); and keep a global recovery keypair alongside the per-user keys so admins can still run a GDPR export or recover a mailbox when a user forgets their password. Without that recovery key, a password reset is a data-loss event.
Migrating a live mailbox with no downtime
The mail_crypt_save_version flag is what makes this safe. Setting it to 2 means only new mail is encrypted — existing cleartext messages are never touched and stay fully readable. Enabling encryption is therefore zero-risk and reversible: flip the flag, restart, and from that instant everything delivered is ciphertext while the backlog keeps serving.
To encrypt the backlog without a lock window, stand up a second target running the encrypted config, stream the mailbox into it, then swap storage:
doveadm backup -u [email protected] -R remote:/target/alice
# verify, then swap the storage path to the encrypted targetBecause doveadm backup/dsync reads through Dovecot and writes through the encrypted config, the copy lands encrypted with no maintenance window. Your escape hatch to decrypt is the mirror image: set mail_crypt_save_version = 0 (new mail written as plaintext) and run a re-save pass over the messages.
Test the full round-trip on one throwaway account before you touch anyone real. Encrypt it, read it back with doveadm fetch, decrypt it, read it again. If any step surprises you, better on test@ than on your CFO.
Operational reality: the things that bite
Plugin order matters. mail_plugins = zlib mail_crypt — zlib must run *before* mail_crypt so compression happens on cleartext. Reverse them and you're compressing ciphertext, which is incompressible by definition; you pay for zlib CPU and gain zero storage. This is the single most common misconfiguration.
The private key is now a single point of total data loss. Encrypt your mail, lose the key, and your backups are permanently unreadable ciphertext. Back the key up out of band, on separate media from the mail backups it decrypts — if the same breach or the same disk failure takes both, encryption bought you nothing but a self-inflicted outage.
Rotate deliberately. doveadm mailbox cryptokey password -u user@domain -N -o old re-wraps a user's key under a new password. It's cheap because of the hierarchy — no message re-encryption.
FTS and delivery Sieve stay broken in per-user mode; plan search and body-filtering around that, don't discover it in a ticket.
Performance is a non-issue. Per-message ECC is sub-millisecond. If you're avoiding mail_crypt, do it for the feature trade-offs, never for CPU.
Deployment checklist
- Keys stored
0600, owneddovecot:dovecot, outside any web-served path. mail_crypt_save_version = 2set and confirmed active.- Plugin order is
zlib mail_crypt, compress-then-encrypt. - Ciphertext verified on disk via
hexdumpshowing theCRYPTED\x03\x07header. - Plaintext round-trip verified via
doveadm fetch. - Private key backed up off-box and off-backup, on separate media.
- Recovery keypair present if you deployed per-user mode.
- Migration round-trip (encrypt → read → decrypt → read) tested on one throwaway account.
mail_crypt sits on top of LUKS at rest and TLS in transit — it closes the running-server gap that disk encryption structurally cannot, and it does nothing about the wire or the powered-off drive. Deploy all three, and the only place your users' mail is ever cleartext is inside an authenticated session, which is exactly where it has to be.


