Andrew Mercer
on this page

This doc runs from the lightest to the heaviest way of handling "more than one address" on a Postfix server: simple redirects, forwarding an entire extra domain, a mailing list from aliases, and finally full multi-domain virtual mailboxes backed by a database.

Forwarding domain (virtual alias domain)

You want an entire additional domain's mail forwarded to addresses that already work somewhere else. There are no new mailboxes and no new users, only redirection.

# /etc/postfix/main.cf
virtual_alias_domains = newdomain1.tld newdomain2.tld
virtual_alias_maps    = hash:/etc/postfix/virtual
# /etc/postfix/virtual
[email protected]    postmaster
[email protected]         postmaster
[email protected]         [email protected]
[email protected]          [email protected], [email protected]
@newdomain2.tld              [email protected]          # catch-all: everything else
sudo postmap /etc/postfix/virtual
sudo postfix reload

Send a test to each alias and confirm it lands where expected (or ask Postfix without sending: sendmail -bv [email protected]).

Things to know:

  • Always define postmaster@ (and ideally abuse@) for every domain you accept. RFC 5321 requires a working postmaster address, and abuse and bounce handling rely on it.
  • Catch-alls (@domain) attract spam and make you accept mail for addresses that don't exist. That breaks recipient validation and causes backscatter. Use them sparingly.
  • Forwarding external mail breaks SPF at the final destination, because your server's IP isn't in the original sender's SPF record. Large receivers may then reject or spam-folder forwarded mail. For anything beyond personal use, run SRS (Sender Rewriting Scheme, e.g. postsrsd) so the envelope sender is rewritten to your domain. DKIM signatures survive forwarding as long as the message isn't modified.
  • Don't list a virtual alias domain in mydestination or virtual_mailbox_domains too (see Mailbox Formats and Delivery).

Mailing lists via /etc/aliases

For a small, low-maintenance list of local recipients, /etc/aliases is enough:

Anything sent to sales@<a mydestination domain> now fans out to all three. Apply with:

sudo postalias /etc/aliases      # or: sudo newaliases

postalias, not postmap: aliases use a slightly different format, though the idea is identical. Edit, compile, done. If you forget, the log says database /etc/aliases.db is older than source file /etc/aliases.

/etc/aliases applies only to local (mydestination) domains. For a list in a virtual domain, put the same fan-out in virtual_alias_maps:

An alias fan-out is a list in the "everyone gets a copy" sense only. It has no subscribe/unsubscribe, no moderation, no archive, no per-recipient bounce handling, and no List-* headers. Beyond a handful of trusted recipients, run real list software (Mailman 3, Sympa, mlmmj) behind Postfix.

Full virtual mailboxes with SQL and Dovecot

This is the heaviest pattern. Domains, mailboxes and aliases live in a database, mail is stored under one service account, and Dovecot delivers mail (LMTP), serves it (IMAP/POP3), and authenticates users for both reading and sending. It's the right model once you host several domains and don't want a Unix account per mailbox.

Storage account

sudo groupadd -g 5000 vmail
sudo useradd -u 5000 -g vmail -d /var/vmail -s /usr/sbin/nologin -m vmail
sudo chmod 0770 /var/vmail

Postfix: main.cf

# Identity
myhostname    = mail.domain.tld
mydomain      = domain.tld
mydestination = localhost              # hosted domains are virtual, never listed here
mynetworks    = 127.0.0.0/8 [::1]/128

# Which domains/addresses exist: live SQL lookups through proxymap
virtual_mailbox_domains = proxy:mysql:/etc/postfix/sql/virtual_domains.cf
virtual_mailbox_maps    = proxy:mysql:/etc/postfix/sql/virtual_mailboxes.cf
virtual_alias_maps      = proxy:mysql:/etc/postfix/sql/virtual_aliases.cf

# Hand final delivery to Dovecot over LMTP
virtual_transport = lmtp:unix:private/dovecot-lmtp

# Let Dovecot authenticate SMTP AUTH (same user database as IMAP)
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = no            # enabled only on submission ports, in master.cf
smtpd_sasl_security_options = noanonymous
smtpd_sasl_authenticated_header = no   # don't reveal usernames in Received: headers

# Relay policy (Postfix ≥ 2.10): who may send to *other* domains
smtpd_relay_restrictions =
    permit_mynetworks,
    permit_sasl_authenticated,
    reject_unauth_destination

# Inbound policy on port 25: who may send to *our* domains
smtpd_helo_required = yes
smtpd_recipient_restrictions =
    reject_non_fqdn_recipient,
    reject_unknown_recipient_domain,
    permit_mynetworks,
    permit_sasl_authenticated,
    reject_non_fqdn_helo_hostname,
    reject_invalid_helo_hostname,
    reject_non_fqdn_sender,
    reject_unknown_sender_domain,
    reject_unauth_pipelining,
    reject_rbl_client zen.spamhaus.org,
    reject_rbl_client bl.spamcop.net

Unknown recipients in your own domains are rejected automatically, because the SQL maps define which addresses exist (smtpd_reject_unlisted_recipient = yes is the default).

Notes on the restriction lists:

  • smtpd_relay_restrictions vs smtpd_recipient_restrictions: since Postfix 2.10, relay control has its own list, so a mistake in your anti-spam rules can't accidentally make you an open relay. Old configs put everything in smtpd_recipient_restrictions. That still works, but reject_unauth_destination must then come before any permit that unauthenticated clients can reach.
  • Renamed restrictions: reject_invalid_hostname and reject_non_fqdn_hostname from older guides are now reject_invalid_helo_hostname and reject_non_fqdn_helo_hostname.
  • DNSBLs: sbl-xbl.spamhaus.org in old configs is superseded by zen.spamhaus.org. Query it through a local resolver, never a public one (see Email Authentication and Anti-Spam). postscreen (see Security and Hardening) is a better home for DNSBL checks than smtpd.
  • broken_sasl_auth_clients = yes only existed for Outlook Express-era clients that sent non-standard AUTH= syntax. Leave it out.

The SQL query files

Each .cf file is a small connection definition plus a query template. %s is the full lookup key, %u the local part, and %d the domain. A schema like PostfixAdmin's looks like this:

# /etc/postfix/sql/virtual_domains.cf
hosts    = 127.0.0.1
user     = postfix_ro
password = CHANGE_ME
dbname   = mailserver
query    = SELECT 1 FROM domain WHERE domain = '%s' AND active = 1

# /etc/postfix/sql/virtual_mailboxes.cf
query    = SELECT maildir FROM mailbox WHERE username = '%s' AND active = 1

# /etc/postfix/sql/virtual_aliases.cf
query    = SELECT goto FROM alias WHERE address = '%s' AND active = 1

(The second and third files need the same connection lines as the first. They're omitted above for brevity.)

sudo chown root:postfix /etc/postfix/sql/*.cf && sudo chmod 640 /etc/postfix/sql/*.cf
# Test each map directly; it should print the result, or nothing if not found
postmap -q domain.tld          proxy:mysql:/etc/postfix/sql/virtual_domains.cf
postmap -q [email protected]     proxy:mysql:/etc/postfix/sql/virtual_mailboxes.cf
postmap -q [email protected]    proxy:mysql:/etc/postfix/sql/virtual_aliases.cf

Give Postfix a read-only database user. It never needs to write. The proxy: prefix makes all Postfix processes share a pool of connections through proxymap, instead of each smtpd opening its own.

Dovecot side (essentials)

# /etc/dovecot/conf.d/10-mail.conf
mail_location = maildir:/var/vmail/%d/%n          # per-domain/per-user Maildir
mail_uid = vmail
mail_gid = vmail

# /etc/dovecot/conf.d/10-master.conf
service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    mode = 0600
    user = postfix
    group = postfix
  }
}
service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}

User lookup and password checking point at the same SQL database (auth-sql.conf.ext with a dovecot-sql.conf.ext query). Dovecot's documentation covers the password-scheme details. Use a modern scheme such as ARGON2ID or BLF-CRYPT, never PLAIN-MD5.

This is what the moving parts buy you:

  • virtual_transport = lmtp:…: Dovecot does the delivery, so Sieve filters, quotas and index updates all happen at delivery time.
  • smtpd_sasl_type = dovecot: Postfix has no password database of its own. It asks Dovecot, so "can this person send" and "can this person read" are always the same answer.

Older setups use Dovecot's LDA through a pipe(8) service (virtual_transport = dovecot plus a dovecot … pipe flags=DRhu … argv=/usr/libexec/dovecot/deliver … line in master.cf). That works, but it forks a process per message and requires dovecot_destination_recipient_limit = 1 in main.cf, otherwise the log shows pipe flag 'D' requires dovecot_destination_recipient_limit = 1. LMTP avoids both problems.

Submission service for your users

Port 25 is for other MTAs. Your users' mail clients should submit on 587 (STARTTLS) and/or 465 (implicit TLS) with authentication required. Enable them in master.cf:

# /etc/postfix/master.cf
submission inet n       -       n       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_tls_auth_only=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_login_maps=proxy:mysql:/etc/postfix/sql/sender_login.cf
  -o smtpd_sender_restrictions=reject_sender_login_mismatch
  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING

submissions inet n      -       n       -       -       smtpd
  -o syslog_name=postfix/submissions
  -o smtpd_tls_wrappermode=yes
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_login_maps=proxy:mysql:/etc/postfix/sql/sender_login.cf
  -o smtpd_sender_restrictions=reject_sender_login_mismatch
  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING

reject_sender_login_mismatch with smtpd_sender_login_maps (address → owning login, e.g. SELECT username FROM mailbox WHERE username='%s' UNION SELECT goto FROM alias WHERE address='%s') stops an authenticated user from sending as someone else. milter_macro_daemon_name=ORIGINATING tells a DKIM milter to sign this mail rather than verify it.

Managing virtual domains with PostfixAdmin

Hand-editing SQL rows for every domain, mailbox and alias gets old fast. PostfixAdmin is a PHP web front end that manages exactly the tables the queries above read. Install the current release from its own documentation (or your distribution's package) rather than following old version-specific procedures. The setup flow, PHP requirements and password hashing defaults have all changed over the years.

The division of labour is unchanged: PostfixAdmin writes database rows, Postfix (through proxy:mysql:) and Dovecot read them, and nothing needs a postmap or reload when a mailbox is added. Put PostfixAdmin behind HTTPS and an access restriction (VPN, IP allow-list or SSO), because it's a full administrative interface to your mail system.

Auto-reply transport

This is a small but distinct pattern: an address (or a whole subdomain) whose mail goes to a script instead of a mailbox. For per-user vacation messages, use Dovecot's Sieve vacation extension instead. It tracks who has been answered and handles the loop-prevention rules for you. The transport approach below suits a single role address ("we've received your request").

# main.cf
transport_maps = hash:/etc/postfix/transport
# /etc/postfix/transport
autoreply.domain.tld    autoreply:

The domain must also be one Postfix accepts mail for, for example listed in relay_domains:

relay_domains = autoreply.domain.tld
# master.cf: define the pipe service
autoreply  unix  -  n  n  -  -  pipe
  flags=Rq user=nobody argv=/usr/local/bin/autoreply ${sender} ${recipient}
#!/bin/sh
# /usr/local/bin/autoreply: reply once, safely
sender="$1"

# Never answer bounces, list mail, or other automated senders (RFC 3834)
case "$sender" in
  ""|*MAILER-DAEMON*|*mailer-daemon*|*-bounces@*|*noreply*|*no-reply*) exit 0 ;;
esac

/usr/sbin/sendmail -f '<>' -t <<EOF
To: ${sender}
From: Support <[email protected]>
Subject: Your request has been received
Auto-Submitted: auto-replied

$(cat /etc/postfix/autoreply.txt)
EOF
sudo chmod 755 /usr/local/bin/autoreply
sudo postmap /etc/postfix/transport && sudo postfix reload

Why it's written this way:

  • The null envelope sender (-f '<>') means any bounce of the auto-reply is discarded rather than answered, which prevents mail loops.
  • Auto-Submitted: auto-replied tells other auto-responders not to answer.
  • Skipping bounces, list and no-reply senders stops you from spamming mailing lists and bounce handlers.
  • flags=Rq adds a Return-Path: header and quotes addresses safely for the command line.
  • The script doesn't track who has already been answered. If it gets heavy use, add a small rate-limit file (sender + date) or switch to Sieve vacation.