# 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.

Source: https://www.corepanel.net/tutorials/mail-server-postfix-dovecot-rspamd-almalinux-rocky
Author: CorePanel Team
Published: 2026-10-05
Track: Server stack · advanced · ~60 min
Tested on: AlmaLinux 9.8 (2026-10-05)
Tested on: AlmaLinux 10.2 (2026-10-05)
Tested on: Rocky Linux 9.8 (2026-10-05)
Tested on: Rocky Linux 10.2 (2026-10-05)
Before you start: https://www.corepanel.net/tutorials/initial-server-setup-almalinux-rocky
CorePanel edition needed: Personal (free)
Tags: mail, postfix, dovecot, rspamd, dkim, almalinux, rocky-linux
From CorePanel Tutorials — https://www.corepanel.net/tutorials

---

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.

> **Ask your provider about port 25 first**
>
> Many cloud providers block **outbound** port 25 on new accounts, to stop spam from
> compromised servers. Your server can still receive mail, but it cannot deliver to Gmail
> or Outlook until the provider lifts the block, usually on request. Check before you start.
## 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.

```bash
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.

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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"
```

> **Port 465 needs `smtpd_tls_wrappermode=yes`**
>
> Without it, Postfix answers on 465 in plain text. Mail clients configured for "SSL/TLS on
> 465" then fail the TLS handshake and report that they cannot connect. The check at the
> end of this tutorial tests for exactly that.
## 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:

```bash
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:

```bash
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

```bash
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:

| Type | Name | Value |
|---|---|---|
| `MX` | `example.com` | `10 server.example.com` |
| `TXT` | `example.com` | `v=spf1 mx -all` |
| `TXT` | `mail._domainkey.example.com` | the key printed below |
| `TXT` | `_dmarc.example.com` | `v=DMARC1; p=none; rua=mailto:postmaster@example.com` |

The DKIM record, ready to paste:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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](https://www.corepanel.net/docs/email/mailboxes), [Spam filtering](https://www.corepanel.net/docs/email/antispam) and
[Email deliverability](https://www.corepanel.net/docs/email/deliverability).
