Shore Up
A flat geometric illustration showing stacked documents sliding into an encrypted safe or chest with a padlock.
MailLinux

Bulk-Export Postfix Virtual Maps to an Encrypted Backup

Ketan Aagja9 min read
No ratings yet

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 postconf to read values. It does not edit config, run postmap, 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 mode 640 root:postfix and contain database credentials. You must be able to read them. main.cf is 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 .cf files hold your DB password. That is exactly why we encrypt. Treat the resulting .gpg file, 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/postfix before you schedule it on the live server. Confirm the archive decrypts and contains what you expect before you trust it.
  • Plaintext touches /tmp briefly. The script stages copied files in a mktemp directory before piping them through GPG. On a multi-tenant or disk-encrypted-only-at-rest box, point TMPDIR at 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 at hash: or lmdb: 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), and coreutils for shred.
  • 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 --symmetric also 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 no name = prefix, so parsing is simple.
  • The case block is a deliberate allow-list. It only copies the map types that are plain files. It skips sql:, ldap:, pcre:, regexp: and anything else, because those either hold their data elsewhere or are patterns, not user records.
  • tar ... | gpg keeps the unencrypted tarball out of the filesystem entirely. The only plaintext at rest is inside $staging, which mktemp -d creates mode 700 and the trap removes 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).

Written by
Ketan Aagja

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.

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 .

8 min read

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.

9 min read

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.

8 min read

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.

10 min read