
Bulk-Export Postfix Virtual Maps to an Encrypted Backup
Before you run this
This guide builds a small bash script that reads your Postfix virtual-table configuration, copies the source files behind each file-based map (hash:, lmdb:, btree:, cdb:, texthash:) plus main.cf and master.cf, bundles them into a tarball, and encrypts that tarball with GnuPG so you can store it off-box safely. The purpose is a routine, restorable snapshot of your mail routing and alias data.
A few things to be clear about before you touch a production mail server:
- This script is read-only against Postfix. It copies files and runs
postconfto read values. It does not edit config, runpostmap, or reload anything. The only thing it writes is the encrypted archive in your backup directory. - It needs root (or sudo). Some map source files — especially SQL config files like
mysql:/etc/postfix/mysql-virtual.cf— are mode640 root:postfixand contain database credentials. You must be able to read them.main.cfis usually world-readable, but don't count on that for everything. - The archive contains secrets. If any of your maps are SQL-backed, the copied
.cffiles hold your DB password. That is exactly why we encrypt. Treat the resulting.gpgfile, and the GPG private key that can open it, as sensitive. - Test it somewhere safe first. Read the script, then run it on a non-production mail box or a VM with a representative
/etc/postfixbefore you schedule it on the live server. Confirm the archive decrypts and contains what you expect before you trust it. - Plaintext touches
/tmpbriefly. The script stages copied files in amktempdirectory before piping them through GPG. On a multi-tenant or disk-encrypted-only-at-rest box, pointTMPDIRat a path only root can read (noted below).
This is a backup job, so there is no destructive "undo" to fear — but the restore path (decrypt, extract, rebuild indexes) does overwrite live files, so I cover that carefully at the end.
Assumptions
- Debian/Ubuntu, Postfix installed, config under
/etc/postfix. - Your virtual tables are file-based —
virtual_alias_maps,virtual_mailbox_maps, and friends pointing athash:orlmdb:source files. If yours are SQL-backed, see the note at the end; the real user data lives in the database and this script only captures the connector files, not the rows. - GnuPG is installed (
gpg), andcoreutilsforshred. - You will encrypt to a GPG public key. For an automated cron job this is the standard choice: no passphrase has to sit on disk. (Symmetric
gpg --symmetricalso works but means storing a passphrase somewhere the job can read it.)
Prepare a GPG key for the backup
If you don't already have a key to encrypt to, generate one — ideally on a different machine, and import only the public key onto the mail server so the private key never lives beside the backups:
# On your admin workstation (not the mail server):
gpg --full-generate-key # follow the prompts; note the email/UID you choose
gpg --export --armor backup@example.com > postfix-backup-pub.asc
# Copy postfix-backup-pub.asc to the mail server, then as root:
gpg --import postfix-backup-pub.asc
Replace backup@example.com with your own key's identity. Keep the private key safe off-box — without it, the backups are unrecoverable, which is the point.
The export script
Save this as /usr/local/sbin/postfix-virtual-backup.sh and chmod 700 it.
#!/usr/bin/env bash
set -euo pipefail
# ---- edit these ----
BACKUP_DIR="/var/backups/postfix" # where the .gpg archive is written
GPG_RECIPIENT="backup@example.com" # your GPG public key's UID
POSTFIX_DIR="/etc/postfix"
# Parameters whose file-based maps we want to capture. Add your own if you use more.
PARAMS=(virtual_alias_maps virtual_mailbox_maps \
virtual_mailbox_domains virtual_alias_domains)
# --------------------
timestamp="$(date +%Y%m%d-%H%M%S)"
staging="$(mktemp -d)" # root-only temp dir (mode 700)
trap 'rm -rf "$staging"' EXIT # always clean up plaintext
# Always keep the main config files for context.
cp -p "$POSTFIX_DIR/main.cf" "$POSTFIX_DIR/master.cf" "$staging/"
for p in "${PARAMS[@]}"; do
# postconf -h prints the value only, without the "name = " prefix.
value="$(postconf -h "$p" 2>/dev/null || true)"
[ -z "$value" ] && continue
echo "$value" | tr ',' '\n' | while read -r entry; do
entry="$(echo "$entry" | xargs)" # trim surrounding whitespace
case "$entry" in
hash:*|btree:*|lmdb:*|cdb:*|texthash:*)
src="${entry#*:}" # path after the "type:" prefix
[ -f "$src" ] && cp -p "$src" "$staging/"
;;
*) : ;; # skip sql:, ldap:, pcre:, etc.
esac
done
done
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
archive="$BACKUP_DIR/postfix-virtual-$timestamp.tar.gz.gpg"
# Pipe the tar straight into gpg so no plaintext archive ever lands on disk.
tar -czf - -C "$staging" . \
| gpg --batch --yes --encrypt --recipient "$GPG_RECIPIENT" --output "$archive"
chmod 600 "$archive"
echo "Wrote $archive"
Notes on the non-obvious parts:
postconf -h <name>prints the value with noname =prefix, so parsing is simple.- The
caseblock is a deliberate allow-list. It only copies the map types that are plain files. It skipssql:,ldap:,pcre:,regexp:and anything else, because those either hold their data elsewhere or are patterns, not user records. tar ... | gpgkeeps the unencrypted tarball out of the filesystem entirely. The only plaintext at rest is inside$staging, whichmktemp -dcreates mode700and thetrapremoves on exit.
If /tmp is not safe enough for your box, run the script with a tighter temp root:
TMPDIR=/root/tmp /usr/local/sbin/postfix-virtual-backup.sh
Schedule it
A root cron entry is the standard path. crontab -e as root and add, for a nightly run at 02:30:
30 2 * * * /usr/local/sbin/postfix-virtual-backup.sh >> /var/log/postfix-virtual-backup.log 2>&1
Because we encrypt to a public key, there is no passphrase in the cron environment. Prune old archives with whatever you already use (a find … -mtime +N -delete or your backup rotation tool) — I'm not scripting deletion here so an accident can't wipe your history.
Verify it worked
Don't assume the file is good because the job exited 0. Check it on a machine that holds the private key:
# List the archive contents without extracting anything:
gpg --decrypt /var/backups/postfix/postfix-virtual-20240101-023000.tar.gz.gpg \
| tar -tzf -
You should see main.cf, master.cf, and each of your virtual source files (virtual, vmailbox, etc.). Spot-check one:
gpg --decrypt .../postfix-virtual-20240101-023000.tar.gz.gpg \
| tar -xzO -f - virtual | head
(-O writes the extracted file to stdout instead of to disk.)
If gpg --decrypt fails with "no secret key", you're on a box without the private key — expected if you kept it off-box. Test the decrypt where that key lives.
Restore (and the one dangerous step)
Restoring overwrites your live map sources, so do it deliberately, ideally into a staging directory first:
mkdir -p /root/postfix-restore && cd /root/postfix-restore
gpg --decrypt .../postfix-virtual-<timestamp>.tar.gz.gpg | tar -xzf -
Review the files, then copy the ones you need back into /etc/postfix. For the indexed map types (hash, lmdb, btree, cdb) you must rebuild the .db/.lmdb index after restoring the text source — the index is not in the backup:
postmap /etc/postfix/virtual # repeat for each indexed source file
postfix reload
texthash: maps need no postmap — Postfix reads the text file directly. SQL and LDAP maps aren't rebuilt this way at all.
If your virtual tables are SQL-backed
Then the records live in your database, not in flat files, and this script only captures the *.cf connector files (useful, but not the data). For the actual mailbox/alias rows, dump the database with the vendor's own tool — mysqldump for MySQL/MariaDB, pg_dump for PostgreSQL — and feed that dump through the same gpg --encrypt step. Check the mysqldump or pg_dump manual page for the exact flags for your version before scripting it.
For the full list of Postfix map types and what postconf -h returns, see the official Postfix documentation (postconf(5) and the "Postfix Lookup Table" overview on postfix.org).
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
Related guides
Automate Postfix Transport Map Updates Without Downtime
This guide gives you a small bash script that rebuilds a Postfix transport map from its flat-file source, validates the configuration, and reloads Postfix so the new routing takes effect. postfix reload re-reads configuration and recycles daemons gracefully — it does not drop in-flight SMTP connections or stop the queue, which is why we use it instead of restart .
Alert on a Growing Postfix Mail Queue with a Cron Script
A mail queue that quietly grows is one of those failures you notice late — usually when a user asks why their mail from three hours ago hasn't arrived. A backlog can mean a dead relay host, a DNS problem, a downstream server rejecting everything, or an outbound spam run from a compromised account. This guide sets up a small cron job that counts the queue and emails you when it crosses a threshold, so you hear about it early.
Email a Daily Postfix Delivery Summary with Bash
This guide sets up a small bash script that reads yesterday's Postfix log, runs it through pflogsumm to build a delivery summary (messages received/delivered/deferred/ bounced, top senders, deferral reasons), and emails that summary to you once a day from cron.
One Report Correlating Mail Service Errors and Delivery Failures
This is a read-only reporting script . It queries the systemd journal and prints a single, time-ordered report that interleaves two things: systemd service-level errors for your mail stack (Postfix and Dovecot logging at priority err or worse) and Postfix delivery failures (bounces, deferrals, and NOQUEUE rejects). The point is to see, in one place, whether a run of deferred deliveries at 02:15 lines up with Dovecot restarting at 02:14.




