← Tutorials
Server stack advanced · ~60 min ·

A complete mail server on AlmaLinux and Rocky Linux: Postfix, Dovecot, Rspamd and DKIM

Build a mail server for your own domain: Postfix for SMTP, Dovecot for IMAP, Rspamd for spam filtering and DKIM signing, and a Let's Encrypt certificate on every port.

This tutorial builds a mail server for one domain, example.com, on the server server.example.com you prepared in the initial setup. When you finish:

  • Postfix receives mail for example.com on port 25, and sends mail for authenticated users on ports 587 and 465.
  • Dovecot stores mailboxes and serves them over IMAPS on port 993. It also checks the passwords Postfix asks about.
  • Rspamd scores incoming mail for spam and signs outgoing mail with DKIM.
  • Let’s Encrypt provides the certificate every one of those ports presents.

Mailboxes are virtual: user@example.com is a line in a file, not a Linux account. All mail is stored under one system user, vmail. That is how mail servers for more than a handful of people are built.

Get a certificate

Every port in this tutorial speaks TLS, so get the certificate first. Nothing listens on port 80 yet, so certbot can run its own temporary web server to prove you control the name. The A record for server.example.com must already point at this server.

Terminal window
dnf -y install dnf-plugins-core
dnf config-manager --set-enabled crb
dnf -y install epel-release
dnf -y install certbot
certbot certonly --standalone -d server.example.com --non-interactive --agree-tos -m postmaster@example.com --deploy-hook "systemctl try-reload-or-restart postfix dovecot"
systemctl enable --now certbot-renew.timer

The --deploy-hook is remembered: every renewal reloads Postfix and Dovecot, so they pick up the new certificate without anyone remembering to restart them. try-reload-or-restart does nothing for a service that is not running, which is the case right now, before either is installed.

Install the packages

Postfix and Dovecot come from the distribution. Rspamd publishes its own repository, which is more current than anything packaged elsewhere.

Terminal window
dnf -y install postfix dovecot swaks policycoreutils-python-utils
curl -fsSL "https://rspamd.com/rpm-stable/centos-$(rpm -E %rhel)/rspamd.repo" -o /etc/yum.repos.d/rspamd.repo
dnf -y install rspamd

dnf imports Rspamd’s signing key from the URL in the .repo file the first time it installs from it. Older instructions add an rpm --import …/gpg.key step; skip it. That key is signed with SHA-1, which AlmaLinux and Rocky Linux 9 and 10 reject, and the import fails.

swaks is a tool for testing SMTP, used at the end to check everything works. policycoreutils-python-utils provides semanage, needed for one SELinux label.

Create the mail store and the first mailbox

All mail lives under /var/vmail/<domain>/<user>, owned by a vmail user. It gets a fixed UID of 5000: Dovecot refuses to deliver mail as any UID below 1000 (first_valid_uid), which rules out an ordinary system account. SELinux also has to be told that this directory holds mail, or Dovecot will not be allowed to write to it:

Terminal window
useradd --uid 5000 --user-group --home-dir /var/vmail --create-home --shell /sbin/nologin vmail
chmod 750 /var/vmail
semanage fcontext -a -t mail_spool_t "/var/vmail(/.*)?"
restorecon -R /var/vmail

Mailbox passwords go in /etc/dovecot/users, hashed. This generates a random password for user@example.com and prints it once, so write it down:

Terminal window
MAILPASS=$(openssl rand -base64 18)
echo "Password for user@example.com: $MAILPASS"
MAILHASH=$(openssl passwd -6 "$MAILPASS")
echo "user@example.com:$MAILHASH" > /etc/dovecot/users
chown root:dovecot /etc/dovecot/users
chmod 640 /etc/dovecot/users

openssl passwd -6 produces the SHA512-CRYPT hash Dovecot expects. Many guides use doveadm pw instead, which does the same but refuses to run until Dovecot’s own configuration is complete, and on AlmaLinux and Rocky Linux 9 it is not complete yet at this point. The hash goes into a variable first on purpose: if the command fails, the shell stops there, instead of writing an empty password into the file.

To add another mailbox later, append a line in the same format and tell Postfix about the address (next section). Dovecot rereads the file on every login.

Configure Dovecot

Dovecot’s main file includes /etc/dovecot/local.conf last, so everything this tutorial changes goes there and the distribution’s files stay untouched. The exception is the default login against system accounts, which is switched off so that only mailboxes in /etc/dovecot/users can log in:

Terminal window
sed -i 's/^!include auth-system.conf.ext/#&/' /etc/dovecot/conf.d/10-auth.conf
cat > /etc/dovecot/local.conf <<'EOF'
protocols = imap lmtp
# Mailboxes: virtual users, all stored as vmail.
mail_location = maildir:/var/vmail/%d/%n/Maildir
mail_uid = vmail
mail_gid = vmail
passdb {
driver = passwd-file
args = scheme=SHA512-CRYPT username_format=%u /etc/dovecot/users
}
userdb {
driver = static
args = uid=vmail gid=vmail home=/var/vmail/%d/%n
}
# TLS on every connection, with the Let's Encrypt certificate.
ssl = required
ssl_cert = </etc/letsencrypt/live/server.example.com/fullchain.pem
ssl_key = </etc/letsencrypt/live/server.example.com/privkey.pem
ssl_min_protocol = TLSv1.2
disable_plaintext_auth = yes
auth_mechanisms = plain login
# IMAPS (993) only; plain IMAP on 143 is off.
service imap-login {
inet_listener imap {
port = 0
}
}
# Postfix hands delivered mail to Dovecot over LMTP...
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
# ...and asks Dovecot whether a login is valid (SMTP AUTH).
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
}
EOF
doveconf -n > /dev/null
systemctl enable --now dovecot

doveconf -n parses the whole configuration and fails on any error, before Dovecot tries to start with it.

Configure Postfix

postconf -e edits /etc/postfix/main.cf one setting at a time, so the reasoning stays visible:

Terminal window
postconf -e "myhostname = server.example.com"
postconf -e "mydomain = example.com"
postconf -e "myorigin = \$mydomain"
postconf -e "inet_interfaces = all"
postconf -e "inet_protocols = all"
postconf -e "mydestination = localhost"
postconf -e "mynetworks = 127.0.0.0/8 [::1]/128"
postconf -e "virtual_mailbox_domains = example.com"
postconf -e "virtual_mailbox_maps = texthash:/etc/postfix/vmailbox"
postconf -e "virtual_transport = lmtp:unix:private/dovecot-lmtp"
postconf -e "smtpd_tls_cert_file = /etc/letsencrypt/live/server.example.com/fullchain.pem"
postconf -e "smtpd_tls_key_file = /etc/letsencrypt/live/server.example.com/privkey.pem"
postconf -e "smtpd_tls_security_level = may"
postconf -e "smtp_tls_security_level = may"
postconf -e "smtpd_sasl_type = dovecot"
postconf -e "smtpd_sasl_path = private/auth"
postconf -e "smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
echo "user@example.com OK" > /etc/postfix/vmailbox

The ones that matter most:

  • mydestination = localhost — example.com must not be a local destination. It is a virtual domain, delivered to Dovecot.
  • mynetworks — only the server itself may send without logging in. The default trusts every address the server has, including its public one.
  • virtual_mailbox_maps — the list of addresses that exist. Postfix rejects mail to anyone else during the SMTP conversation, instead of accepting it and bouncing it later to a forged sender. texthash: is a plain text file read at startup, so there is no postmap step to forget. Run postfix reload after editing it.
  • smtpd_relay_restrictions — mail for other domains is accepted only from a logged-in user. Without this you would be an open relay.
  • No TLS protocol list. The system-wide crypto policy on AlmaLinux and Rocky Linux 9 and 10 already refuses anything older than TLS 1.2, for Postfix as for everything else. Be careful with guides that set smtpd_tls_mandatory_protocols = >=TLSv1.2: Postfix 3.5, the version in the 9 series, does not understand that syntax and turns TLS off with nothing but a warning in the log.

Now the two ports your users send from. 587 (submission) starts in plain text and upgrades with STARTTLS. 465 (smtps) is TLS from the first byte, which is what smtpd_tls_wrappermode means. Both require a login:

Terminal window
postconf -M "submission/inet=submission inet n - n - - smtpd"
postconf -P "submission/inet/syslog_name=postfix/submission"
postconf -P "submission/inet/smtpd_tls_security_level=encrypt"
postconf -P "submission/inet/smtpd_sasl_auth_enable=yes"
postconf -P "submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
postconf -P "smtps/inet/syslog_name=postfix/smtps"
postconf -P "smtps/inet/smtpd_tls_wrappermode=yes"
postconf -P "smtps/inet/smtpd_sasl_auth_enable=yes"
postconf -P "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"

Spam filtering and DKIM signing with Rspamd

Rspamd plugs into Postfix as a milter: Postfix shows it every message, incoming and outgoing, and Rspamd answers with a verdict or a signature.

Create a DKIM key for example.com. mail is the selector, the name the public key is published under in DNS:

Terminal window
mkdir -p /var/lib/rspamd/dkim
rspamadm dkim_keygen -d example.com -s mail -b 2048 -k /var/lib/rspamd/dkim/example.com.mail.key > /var/lib/rspamd/dkim/example.com.mail.txt
chown -R _rspamd:_rspamd /var/lib/rspamd/dkim
chmod 600 /var/lib/rspamd/dkim/example.com.mail.key

Tell Rspamd where the keys are, then connect Postfix to it:

Terminal window
cat > /etc/rspamd/local.d/dkim_signing.conf <<'EOF'
selector = "mail";
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
allow_username_mismatch = true;
EOF
systemctl enable --now rspamd
postconf -e "smtpd_milters = inet:127.0.0.1:11332"
postconf -e "non_smtpd_milters = \$smtpd_milters"
postconf -e "milter_default_action = accept"

Rspamd signs mail from logged-in users and from the server itself, and only for domains it holds a key for. milter_default_action = accept means that if Rspamd is ever down, mail still flows, unscanned and unsigned, instead of queueing.

Open the ports and start Postfix

Terminal window
firewall-cmd --permanent --add-service=smtp --add-service=smtp-submission --add-service=smtps --add-service=imaps
firewall-cmd --reload
postfix check
systemctl enable --now postfix
systemctl reload dovecot

Publish the DNS records

These records are what make other servers accept your mail. Add them at your DNS provider:

TypeNameValue
MXexample.com10 server.example.com
TXTexample.comv=spf1 mx -all
TXTmail._domainkey.example.comthe key printed below
TXT_dmarc.example.comv=DMARC1; p=none; rua=mailto:postmaster@example.com

The DKIM record, ready to paste:

Terminal window
cat /var/lib/rspamd/dkim/example.com.mail.txt

Also make sure the reverse DNS (PTR) of the server’s IP is server.example.com, as covered in the initial setup. Start DMARC at p=none, which only collects reports. Move to p=quarantine once the reports show that all your legitimate mail passes.

Check everything

Each check fails loudly if something is wrong. --helo gives swaks the name to introduce itself with; on a minimal install it cannot work one out by itself. First, the server must accept mail from outside for an address that exists:

Terminal window
swaks --helo server.example.com --server 127.0.0.1 --port 25 --from someone@example.net --to user@example.com --header "Subject: inbound check" > /dev/null

It must refuse to relay for anyone who has not logged in. This connects to the public IP, so the server sees the connection as coming from outside:

Terminal window
PUBLIC_IP=$(ip -4 -o route get 1.1.1.1 | sed -n 's/.* src \([0-9.]*\).*/\1/p')
swaks --helo server.example.com --server "$PUBLIC_IP" --port 25 --from someone@example.net --to someone@example.org --quit-after RCPT > /tmp/relay-check.txt 2>&1 || true
grep "Relay access denied" /tmp/relay-check.txt

A logged-in user must be able to send on 587 with STARTTLS and on 465 with TLS from the first byte, and the certificate must verify:

Terminal window
swaks --helo server.example.com --server server.example.com --port 587 --tls --tls-verify --tls-ca-path /etc/pki/tls/certs/ca-bundle.crt --auth-user user@example.com --auth-password "$MAILPASS" --from user@example.com --to user@example.com --header "Subject: submission check" > /dev/null
swaks --helo server.example.com --server server.example.com --port 465 --tls-on-connect --tls-verify --tls-ca-path /etc/pki/tls/certs/ca-bundle.crt --auth-user user@example.com --auth-password "$MAILPASS" --from user@example.com --to user@example.com --header "Subject: smtps check" > /dev/null

All three messages must be in the mailbox, and the two sent by a logged-in user must carry a DKIM signature:

Terminal window
sleep 3
grep -rl "Subject: inbound check" /var/vmail/example.com/user/Maildir/new
grep -rl "Subject: submission check" /var/vmail/example.com/user/Maildir/new | xargs grep -l "^DKIM-Signature: .*d=example.com"
grep -rl "Subject: smtps check" /var/vmail/example.com/user/Maildir/new | xargs grep -l "^DKIM-Signature: .*d=example.com"

Finally, the mailbox must be readable over IMAPS with the same password:

Terminal window
curl -fsS --url "imaps://server.example.com/INBOX" --user "user@example.com:$MAILPASS" --request "EXAMINE INBOX" | grep -E '^\* 3 EXISTS'

That is the whole path: mail arrives, is filtered, stored and read; users log in and send over TLS; outgoing mail is signed; and the server refuses to relay for strangers. Set up the account in a mail client with server.example.com as both the IMAP server (port 993, SSL/TLS) and the SMTP server (port 465, SSL/TLS, or 587, STARTTLS), and user@example.com as the username.

The CorePanel way

CorePanel installs and maintains this same stack — Postfix, Dovecot and Rspamd — and you manage it from the panel instead of from files. Creating a mailbox is a form with an address, a password and a quota. Every domain gets its DKIM key when it is created, and when CorePanel also serves the domain’s DNS, SPF and DKIM are published automatically. For domains on outside DNS, Email authentication shows the exact records to paste and checks what is actually published. Rspamd thresholds and allow/deny lists can be set per server, per domain or per mailbox, and Roundcube webmail is included.

See Mailboxes, Spam filtering and Email deliverability.