Modern Mail Server Setup Guide
This runbook builds a single-domain AlmaLinux 9 mail server using Postfix, Dovecot, Rspamd, Redis, nginx, PHP-FPM, Roundcube, SQLite, Sieve, DKIM, and Let's Encrypt.
The design uses local Unix accounts and per-user Maildir storage. It is intended for a small server such as a 1 GB Nanode and does not require Docker, mailcow, or ClamAV.
This page uses MediaWiki's SyntaxHighlight extension for command and configuration blocks. If the extension is unavailable, replace each <syntaxhighlight ...> block with a standard <pre> block.
Before starting: Replace every value enclosed in angle brackets. Keep <Old Host> available and serving mail until the cutover and validation steps are complete. Never copy private keys, passwords, or the Roundcube des_key into documentation.
| Port | Service | Purpose | Exposure |
|---|---|---|---|
| 25/tcp | SMTP | Inbound Internet mail | Public |
| 80/tcp | HTTP | ACME challenge and HTTPS redirect | Public |
| 443/tcp | HTTPS | Roundcube webmail | Public |
| 587/tcp | Submission | Authenticated SMTP with STARTTLS | Public |
| 993/tcp | IMAPS | Encrypted mailbox access | Public |
| 11332/tcp | Rspamd proxy | Postfix milter | Loopback only |
| 11334/tcp | Rspamd controller | Administration/API | Loopback only |
| 6379/tcp | Redis | Rspamd data | Loopback only |
Phase 1: Build the mail system
1. Define the deployment variables
Run the build as root on <New Host>.
<syntaxhighlight lang="bash"> MAIL_HOST="mail.example.com" MAIL_DOMAIN="example.com" MAIL_USER="alice" OLD_IP="<Old Host>" NEW_IP="<New Host>" </syntaxhighlight>
Use the public mail identity in Postfix, Dovecot, TLS, and Roundcube even if the temporary build hostname is mailnew.
<syntaxhighlight lang="bash"> hostnamectl set-hostname mailnew vi /etc/hosts </syntaxhighlight>
Add the public mail identity locally:
<syntaxhighlight lang="text"> 127.0.1.1 mail.example.com mail </syntaxhighlight>
Verify the base system:
<syntaxhighlight lang="bash"> hostname hostname -f timedatectl free -h df -h </syntaxhighlight>
Confirm that the public IPv4 address is static, the provider permits inbound and outbound TCP 25, and the provider can set the PTR record. Add 1.5–2 GB of swap on a 1 GB VM.
2. Install repositories and packages
Update AlmaLinux and enable EPEL:
<syntaxhighlight lang="bash"> dnf -y update dnf -y install epel-release </syntaxhighlight>
Roundcube 1.7 requires PHP 8.1 or newer. Enable PHP 8.3 from Remi:
<syntaxhighlight lang="bash"> dnf -y install https://rpms.remirepo.net/enterprise/remi-release-9.rpm dnf -y module reset php dnf -y module enable php:remi-8.3 </syntaxhighlight>
Add the official Rspamd repository:
<syntaxhighlight lang="bash"> curl -fsSL https://rspamd.com/rpm-stable/centos-9/rspamd.repo \
-o /etc/yum.repos.d/rspamd.repo
rpm --import https://rspamd.com/rpm-stable/gpg.key </syntaxhighlight>
Install the mail, web, security, and administration packages:
<syntaxhighlight lang="bash"> dnf -y install \
postfix dovecot dovecot-pigeonhole rspamd redis nginx \ php php-fpm php-cli php-curl php-gd php-intl php-mbstring \ php-pdo php-sqlite3 php-xml php-zip \ certbot firewalld chrony bind-utils rsync unzip tar wget curl \ vim-enhanced sqlite policycoreutils-python-utils
systemctl enable --now firewalld chronyd redis php-fpm </syntaxhighlight>
3. Create the mailbox account
Check the UID and GID on <Old Host>:
<syntaxhighlight lang="bash"> id "$MAIL_USER" </syntaxhighlight>
Create the account with the same UID and GID on <New Host>. Matching them prevents ownership trouble during migration and recovery.
<syntaxhighlight lang="bash"> groupadd -g <OLD_GID> "$MAIL_USER" useradd -u <OLD_UID> -g <OLD_GID> -m -s /bin/bash "$MAIL_USER" passwd "$MAIL_USER"
install -d -m 700 -o "$MAIL_USER" -g "$MAIL_USER" \
/home/$MAIL_USER/Maildir/{cur,new,tmp}
</syntaxhighlight>
4. Configure Postfix
Back up the packaged configuration:
<syntaxhighlight lang="bash"> cp -a /etc/postfix/main.cf /etc/postfix/main.cf.orig cp -a /etc/postfix/master.cf /etc/postfix/master.cf.orig </syntaxhighlight>
Apply the main settings:
<syntaxhighlight lang="bash"> postconf -e "myhostname = $MAIL_HOST" postconf -e "mydomain = $MAIL_DOMAIN" postconf -e 'myorigin = $mydomain' postconf -e 'inet_interfaces = all' postconf -e 'inet_protocols = ipv4' postconf -e 'mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain' postconf -e 'home_mailbox = Maildir/' postconf -e 'mailbox_transport = lmtp:unix:private/dovecot-lmtp' postconf -e 'smtpd_sasl_type = dovecot' postconf -e 'smtpd_sasl_path = private/auth' postconf -e 'smtpd_sasl_auth_enable = yes' postconf -e 'smtpd_sasl_security_options = noanonymous' postconf -e 'smtpd_milters = inet:127.0.0.1:11332' postconf -e 'non_smtpd_milters = inet:127.0.0.1:11332' postconf -e 'milter_protocol = 6' postconf -e 'milter_default_action = accept' </syntaxhighlight>
Edit the Postfix service definition:
<syntaxhighlight lang="bash"> vi /etc/postfix/master.cf </syntaxhighlight>
Enable authenticated submission on TCP 587:
<syntaxhighlight lang="text"> submission inet n - n - - smtpd
-o syslog_name=postfix/submission -o smtpd_tls_security_level=encrypt -o smtpd_sasl_auth_enable=yes -o smtpd_client_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
</syntaxhighlight>
Do not start Postfix yet if the configured TLS certificate does not exist.
5. Configure Dovecot
Back up the original configuration:
<syntaxhighlight lang="bash"> cp -a /etc/dovecot /etc/dovecot.orig </syntaxhighlight>
Set the enabled protocols in /etc/dovecot/dovecot.conf:
<syntaxhighlight lang="text"> protocols = imap lmtp listen = * </syntaxhighlight>
Set Maildir storage in /etc/dovecot/conf.d/10-mail.conf:
<syntaxhighlight lang="text"> mail_location = maildir:~/Maildir first_valid_uid = 1000 </syntaxhighlight>
Set authentication in /etc/dovecot/conf.d/10-auth.conf:
<syntaxhighlight lang="text"> disable_plaintext_auth = yes auth_mechanisms = plain login auth_username_format = %Ln </syntaxhighlight>
Important: auth_username_format = %Ln converts alice@example.com to the local Unix user alice. Without it, IMAP authentication may work while LMTP delivery fails.
Enable Sieve during LMTP delivery in /etc/dovecot/conf.d/20-lmtp.conf:
<syntaxhighlight lang="text"> protocol lmtp {
mail_plugins = $mail_plugins sieve
} </syntaxhighlight>
Inside service auth in /etc/dovecot/conf.d/10-master.conf, create the Postfix SASL socket:
<syntaxhighlight lang="text"> unix_listener /var/spool/postfix/private/auth {
mode = 0660 user = postfix group = postfix
} </syntaxhighlight>
Configure the LMTP socket in the same file:
<syntaxhighlight lang="text"> service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
} </syntaxhighlight>
6. Configure Redis and Rspamd
Ensure Redis listens only on loopback. The packaged /etc/redis/redis.conf should contain:
<syntaxhighlight lang="text"> bind 127.0.0.1 </syntaxhighlight>
Create /etc/rspamd/local.d/redis.conf:
<syntaxhighlight lang="text"> servers = "127.0.0.1"; </syntaxhighlight>
Create /etc/rspamd/local.d/classifier-bayes.conf:
<syntaxhighlight lang="text"> backend = "redis"; servers = "127.0.0.1"; autolearn = true; </syntaxhighlight>
Create /etc/rspamd/local.d/actions.conf:
<syntaxhighlight lang="text"> reject = 15; add_header = 6; greylist = 4; </syntaxhighlight>
Create /etc/rspamd/local.d/milter_headers.conf:
<syntaxhighlight lang="text"> use = [
"x-rspamd-action", "x-spamd-result", "authentication-results"
];
authenticated_headers = ["authentication-results"]; </syntaxhighlight>
Use Rspamd's proxy self-scan mode in /etc/rspamd/local.d/worker-proxy.inc:
<syntaxhighlight lang="text"> upstream "local" {
self_scan = yes;
} </syntaxhighlight>
Disable unused workers:
<syntaxhighlight lang="text">
- /etc/rspamd/local.d/worker-normal.inc
enabled = false;
- /etc/rspamd/local.d/worker-fuzzy.inc
enabled = false; </syntaxhighlight>
Set a task timeout in /etc/rspamd/local.d/options.inc:
<syntaxhighlight lang="text"> task_timeout = 12s; </syntaxhighlight>
7. Generate and publish the DKIM key
Create the key directory and generate a 2048-bit key:
<syntaxhighlight lang="bash"> install -d -m 750 -o _rspamd -g _rspamd /var/lib/rspamd/dkim
rspamadm dkim_keygen \
-b 2048 \
-s dkim \
-d "$MAIL_DOMAIN" \
-k "/var/lib/rspamd/dkim/${MAIL_DOMAIN}.dkim.key" \
> "/root/${MAIL_DOMAIN}-dkim-dns.txt"
chown _rspamd:_rspamd /var/lib/rspamd/dkim/*.key chmod 600 /var/lib/rspamd/dkim/*.key </syntaxhighlight>
Create /etc/rspamd/local.d/dkim_signing.conf:
<syntaxhighlight lang="text"> selector = "dkim"; path = "/var/lib/rspamd/dkim/$domain.$selector.key"; sign_authenticated = true; sign_local = true; use_domain = "header"; use_esld = true; allow_username_mismatch = true; </syntaxhighlight>
Publish the TXT value from /root/<domain>-dkim-dns.txt at dkim._domainkey.example.com. Never publish the private .key file.
8. Create automatic Junk filing
Create and subscribe the Junk mailbox:
<syntaxhighlight lang="bash"> doveadm mailbox create -u "$MAIL_USER" Junk doveadm mailbox subscribe -u "$MAIL_USER" Junk </syntaxhighlight>
Create the user Sieve directory:
<syntaxhighlight lang="bash"> install -d -m 700 -o "$MAIL_USER" -g "$MAIL_USER" \
/home/$MAIL_USER/sieve
vi /home/$MAIL_USER/sieve/rspamd.sieve </syntaxhighlight>
Add the filtering rule:
<syntaxhighlight lang="text"> require ["fileinto"];
if header :is "X-Rspamd-Action" "add header" {
fileinto "Junk"; stop;
} </syntaxhighlight>
Compile and activate it:
<syntaxhighlight lang="bash"> sievec /home/$MAIL_USER/sieve/rspamd.sieve ln -sfn /home/$MAIL_USER/sieve/rspamd.sieve \
/home/$MAIL_USER/.dovecot.sieve
chown -R "$MAIL_USER:$MAIL_USER" \
/home/$MAIL_USER/sieve \ /home/$MAIL_USER/.dovecot.sieve
</syntaxhighlight>
Phase 2: Publish and validate the services
9. Configure the firewall
Open only the required public services:
<syntaxhighlight lang="bash"> firewall-cmd --permanent --add-service=ssh firewall-cmd --permanent --add-service=http firewall-cmd --permanent --add-service=https firewall-cmd --permanent --add-service=smtp firewall-cmd --permanent --add-service=imaps firewall-cmd --permanent --add-port=587/tcp firewall-cmd --reload firewall-cmd --list-all </syntaxhighlight>
Redis and Rspamd must remain on loopback. They must not be opened in the firewall.
10. Bootstrap nginx and obtain the certificate
Create the ACME webroot:
<syntaxhighlight lang="bash"> install -d -m 755 -o nginx -g nginx \
/var/www/letsencrypt/.well-known/acme-challenge
</syntaxhighlight>
Create /etc/nginx/conf.d/mail.conf with the initial HTTP virtual host:
<syntaxhighlight lang="nginx"> server {
listen 80; server_name mail.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
} </syntaxhighlight>
Validate and start nginx:
<syntaxhighlight lang="bash"> nginx -t systemctl enable --now nginx </syntaxhighlight>
The public A record must reach <New Host> for HTTP-01 validation. If DNS still points to <Old Host>, temporarily move the record, use a supported DNS challenge, or wait until cutover.
Request the certificate:
<syntaxhighlight lang="bash"> certbot certonly \
--webroot \ -w /var/www/letsencrypt \ -d "$MAIL_HOST"
</syntaxhighlight>
11. Configure TLS for Postfix and Dovecot
Configure Postfix:
<syntaxhighlight lang="bash"> postconf -e "smtpd_tls_cert_file = /etc/letsencrypt/live/$MAIL_HOST/fullchain.pem" postconf -e "smtpd_tls_key_file = /etc/letsencrypt/live/$MAIL_HOST/privkey.pem" postconf -e 'smtpd_tls_security_level = may' postconf -e 'smtp_tls_security_level = may' postconf -e 'smtp_tls_CAfile = /etc/pki/tls/certs/ca-bundle.crt' </syntaxhighlight>
Configure /etc/dovecot/conf.d/10-ssl.conf:
<syntaxhighlight lang="text"> ssl = required ssl_cert = </etc/letsencrypt/live/mail.example.com/fullchain.pem ssl_key = </etc/letsencrypt/live/mail.example.com/privkey.pem ssl_cipher_list = PROFILE=SYSTEM </syntaxhighlight>
Create the renewal deploy hook:
<syntaxhighlight lang="bash"> vi /etc/letsencrypt/renewal-hooks/deploy/reload-mail-services.sh </syntaxhighlight>
Use:
<syntaxhighlight lang="bash">
- !/bin/bash
systemctl reload nginx postfix dovecot </syntaxhighlight>
Make it executable:
<syntaxhighlight lang="bash"> chmod 750 /etc/letsencrypt/renewal-hooks/deploy/reload-mail-services.sh </syntaxhighlight>
12. Install Roundcube 1.7.2
Roundcube is installed manually and is not maintained by dnf.
<syntaxhighlight lang="bash"> cd /usr/local/src RC=1.7.2
wget "https://github.com/roundcube/roundcubemail/releases/download/$RC/roundcubemail-$RC-complete.tar.gz" tar xzf "roundcubemail-$RC-complete.tar.gz" mv "roundcubemail-$RC" /var/www/roundcube
chown -R root:root /var/www/roundcube
install -d -m 750 -o nginx -g nginx \
/var/www/roundcube/{temp,logs,db}
runuser -u nginx -- sqlite3 /var/www/roundcube/db/roundcube.db \
< /var/www/roundcube/SQL/sqlite.initial.sql
</syntaxhighlight>
Generate a new encryption secret:
<syntaxhighlight lang="bash"> openssl rand -base64 48 </syntaxhighlight>
Create /var/www/roundcube/config/config.inc.php:
<syntaxhighlight lang="php"> <?php
$config = [];
$config['db_dsnw'] = 'sqlite:////var/www/roundcube/db/roundcube.db';
$config['imap_host'] = 'ssl://127.0.0.1:993'; $config['imap_conn_options'] = [
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'peer_name' => 'mail.example.com',
'cafile' => '/etc/pki/tls/certs/ca-bundle.crt',
],
];
$config['smtp_host'] = 'tls://127.0.0.1:587'; $config['smtp_user'] = '%u'; $config['smtp_pass'] = '%p'; $config['smtp_conn_options'] = [
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'peer_name' => 'mail.example.com',
'cafile' => '/etc/pki/tls/certs/ca-bundle.crt',
],
];
$config['des_key'] = '<PASTE A NEW RANDOM SECRET HERE>'; $config['product_name'] = 'Webmail'; $config['mail_domain'] = 'example.com'; $config['message_id_domain'] = 'example.com'; $config['skin'] = 'elastic'; $config['enable_installer'] = false; $config['plugins'] = ['archive', 'zipdownload', 'markasjunk']; </syntaxhighlight>
Secure the configuration:
<syntaxhighlight lang="bash"> chown root:nginx /var/www/roundcube/config/config.inc.php chmod 640 /var/www/roundcube/config/config.inc.php chmod 000 /var/www/roundcube/installer 2>/dev/null || true </syntaxhighlight>
13. Tune PHP-FPM and enforce SELinux
Edit /etc/php-fpm.d/www.conf:
<syntaxhighlight lang="text"> user = nginx group = nginx listen = /run/php-fpm/www.sock listen.owner = nginx listen.group = nginx listen.mode = 0660
pm = ondemand pm.max_children = 3 pm.process_idle_timeout = 10s pm.max_requests = 300 </syntaxhighlight>
Apply persistent SELinux contexts and permit PHP to connect to the local IMAP and SMTP services:
<syntaxhighlight lang="bash"> semanage fcontext -a -t httpd_sys_content_t \
'/var/www/roundcube(/.*)?'
semanage fcontext -a -t httpd_sys_rw_content_t \
'/var/www/roundcube/(temp|db)(/.*)?'
semanage fcontext -a -t httpd_log_t \
'/var/www/roundcube/logs(/.*)?'
restorecon -Rv /var/www/roundcube setsebool -P httpd_can_network_connect 1 </syntaxhighlight>
14. Add the Roundcube HTTPS virtual host
Append this server block to /etc/nginx/conf.d/mail.conf. Roundcube 1.7 requires the public_html document root. The PHP expression also supports paths such as static.php/....
<syntaxhighlight lang="nginx"> server {
listen 443 ssl http2; server_name mail.example.com;
root /var/www/roundcube/public_html; index index.php;
ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 25M;
location = /installer.php {
return 404;
}
location ^~ /installer/ {
return 404;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ ^(.+\.php)(/.*)?$ {
try_files $1 =404;
include fastcgi_params;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
fastcgi_param SCRIPT_FILENAME $document_root$1;
fastcgi_param PATH_INFO $2;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
location ~ /\. {
deny all;
}
} </syntaxhighlight>
Validate every configuration before starting the complete stack:
<syntaxhighlight lang="bash"> php -l /var/www/roundcube/config/config.inc.php postfix check doveconf -n >/dev/null rspamadm configtest nginx -t
systemctl enable --now postfix dovecot rspamd nginx php-fpm systemctl restart redis rspamd postfix dovecot php-fpm nginx </syntaxhighlight>
15. Run the pre-cutover validation gate
Validate services and configuration:
<syntaxhighlight lang="bash"> systemctl --failed postfix check doveconf -n >/dev/null rspamadm configtest nginx -t redis-cli ping
postconf -n | egrep '^(myhostname|mailbox_transport|smtpd_milters)' doveconf -n | egrep '^(mail_location|ssl|auth_username_format)'
ss -lntp | egrep ':(25|80|443|587|993|11332|11334|6379)\b' </syntaxhighlight>
Expected public listeners are 25, 80, 443, 587, and 993. Redis and Rspamd must remain on loopback.
Test certificates against <New Host> before production DNS is moved:
<syntaxhighlight lang="bash"> openssl s_client \
-connect "$NEW_IP:993" \ -servername "$MAIL_HOST" \ </dev/null
openssl s_client \
-starttls smtp \ -connect "$NEW_IP:587" \ -servername "$MAIL_HOST" \ </dev/null
curl --resolve "$MAIL_HOST:443:$NEW_IP" \
-fsSI "https://$MAIL_HOST/"
</syntaxhighlight>
Test local delivery through Postfix and Dovecot LMTP:
<syntaxhighlight lang="bash"> printf 'Subject: local test\n\nLMTP works.\n' \
| sendmail "$MAIL_USER@$MAIL_DOMAIN"
postqueue -p journalctl -u postfix -u dovecot -u rspamd --since -10m --no-pager doveadm search -u "$MAIL_USER" mailbox INBOX ALL </syntaxhighlight>
Test certificate renewal only after the selected ACME method can reach <New Host>:
<syntaxhighlight lang="bash"> certbot renew --dry-run </syntaxhighlight>
16. Prepare production DNS
Create or verify these records:
<syntaxhighlight lang="text"> mail.example.com. A <New Host> example.com. MX 0 mail.example.com. example.com. TXT "v=spf1 ip4:<New Host> -all" dkim._domainkey.example.com. TXT "<generated DKIM public key>" _dmarc.example.com. TXT "v=DMARC1; p=none" <New Host> PTR mail.example.com. </syntaxhighlight>
Verify them:
<syntaxhighlight lang="bash"> dig +short A "$MAIL_HOST" dig +short MX "$MAIL_DOMAIN" dig +short TXT "$MAIL_DOMAIN" dig +short TXT "dkim._domainkey.$MAIL_DOMAIN" dig +short TXT "_dmarc.$MAIL_DOMAIN" dig +short -x "$NEW_IP" </syntaxhighlight>
Do not cut over with a stale AAAA record, mismatched PTR, failing certificate name, open relay, or unverified DKIM record.
Phase 3: Migrate, cut over, and recover
17. Seed the Maildir while the old server is live
Create a dedicated known-hosts file on <New Host>:
<syntaxhighlight lang="bash"> install -d -m 700 /root/.ssh ssh-keyscan -H "$OLD_IP" > /root/.ssh/known_hosts.oldmail chmod 600 /root/.ssh/known_hosts.oldmail ssh-keygen -lf /root/.ssh/known_hosts.oldmail </syntaxhighlight>
Verify the displayed SSH fingerprint out-of-band before transferring mail.
Perform the initial synchronization while <Old Host> remains live:
<syntaxhighlight lang="bash"> rsync -aHvx \
--numeric-ids \ --chown="$MAIL_USER:$MAIL_USER" \ --exclude='/tmp/***' \ --exclude='/**/tmp/***' \ --exclude='dovecot.index*' \ --exclude='dovecot.list.index*' \ --exclude='dovecot.mailbox.log*' \ -e 'ssh -o UserKnownHostsFile=/root/.ssh/known_hosts.oldmail' \ "root@$OLD_IP:/home/$MAIL_USER/Maildir/" \ "/home/$MAIL_USER/Maildir/"
chown -R "$MAIL_USER:$MAIL_USER" "/home/$MAIL_USER/Maildir" doveadm force-resync -u "$MAIL_USER" '*' du -sh "/home/$MAIL_USER/Maildir" </syntaxhighlight>
Data safety: Do not exclude .Junk unless you deliberately want to discard it. Excluding it is a mailbox-data decision, not a harmless optimization.
18. Freeze mail, perform the final sync, and cut over
If DNS rather than an IP move will be used, lower the relevant TTLs beforehand. Announce a short maintenance window.
Stop mailbox changes on <Old Host>:
<syntaxhighlight lang="bash"> systemctl stop postfix dovecot </syntaxhighlight>
Run the final authoritative synchronization on <New Host>:
<syntaxhighlight lang="bash"> rsync -aHvx \
--delete \ --numeric-ids \ --chown="$MAIL_USER:$MAIL_USER" \ --exclude='/tmp/***' \ --exclude='/**/tmp/***' \ --exclude='dovecot.index*' \ --exclude='dovecot.list.index*' \ --exclude='dovecot.mailbox.log*' \ -e 'ssh -o UserKnownHostsFile=/root/.ssh/known_hosts.oldmail' \ "root@$OLD_IP:/home/$MAIL_USER/Maildir/" \ "/home/$MAIL_USER/Maildir/"
chown -R "$MAIL_USER:$MAIL_USER" "/home/$MAIL_USER/Maildir" doveadm force-resync -u "$MAIL_USER" '*' </syntaxhighlight>
Move the production IP or update A, MX, and PTR records as planned. Then restart the new stack:
<syntaxhighlight lang="bash"> systemctl restart redis rspamd postfix dovecot php-fpm nginx </syntaxhighlight>
19. Prove production end-to-end
Verify production DNS and listeners:
<syntaxhighlight lang="bash"> dig +short A "$MAIL_HOST" dig +short -x "$NEW_IP" ss -lntp | egrep ':(25|80|443|587|993)\b' </syntaxhighlight>
Verify HTTPS, IMAPS, and submission TLS:
<syntaxhighlight lang="bash"> curl -fsSI "https://$MAIL_HOST/"
openssl s_client \
-connect "$MAIL_HOST:993" \ -servername "$MAIL_HOST" \ </dev/null
openssl s_client \
-starttls smtp \ -connect "$MAIL_HOST:587" \ -servername "$MAIL_HOST" \ </dev/null
</syntaxhighlight>
Check the queue and recent logs:
<syntaxhighlight lang="bash"> postqueue -p journalctl -u postfix -u dovecot -u rspamd --since -30m --no-pager </syntaxhighlight>
Complete these manual acceptance tests from outside the local network:
- Log into Roundcube and read migrated messages.
- Send a message to an external provider.
- Reply from the external provider back to the new server.
- Confirm IMAPS and authenticated submission work in a mail client.
- Move a message to Junk and verify the mailbox behavior.
- Inspect received headers for TLS, SPF pass, DKIM pass, DMARC pass, and the expected Rspamd action.
- Confirm Maildir size and message counts are reasonable.
- Confirm the queue is clear and no unexpected units have failed.
Leave <Old Host> intact and powered off—not repurposed—until several days of delivery, login, queue, backup, and renewal checks have passed.
20. Roll back without creating split-brain
Rollback is justified for sustained inbound or outbound failure, mailbox-data trouble, or authentication/TLS failure that cannot be corrected inside the maintenance window. A cosmetic Roundcube problem alone may not justify moving SMTP back.
Freeze <New Host> before changing routing:
<syntaxhighlight lang="bash"> systemctl stop postfix dovecot </syntaxhighlight>
Preserve messages accepted only by <New Host>:
<syntaxhighlight lang="bash"> rsync -aHvx \
"/home/$MAIL_USER/Maildir/" \ "root@$OLD_IP:/home/$MAIL_USER/Maildir.rollback-from-new/"
</syntaxhighlight>
Move the production IP, DNS, and PTR back to <Old Host>. Then start the old services:
<syntaxhighlight lang="bash"> systemctl start dovecot postfix </syntaxhighlight>
Verify A/PTR records, ports, queue state, inbound delivery, outbound delivery, and IMAPS. Reconcile Maildir.rollback-from-new before deleting anything.
Never leave Postfix active on both servers while the same public identity is moving. That is how a clean rollback turns into a mail rodeo.
21. Back up the irreplaceable data
Create a configuration backup:
<syntaxhighlight lang="bash"> BACK="/root/mail-backup-$(date +%F-%H%M)" mkdir -p "$BACK"
tar --xattrs --acls -czf "$BACK/configs.tgz" \
/etc/postfix \ /etc/dovecot \ /etc/rspamd \ /etc/nginx \ /etc/php-fpm.d \ /etc/letsencrypt \ /etc/firewalld \ /var/www/roundcube/config \ /var/www/roundcube/db \ /var/lib/rspamd/dkim
</syntaxhighlight>
Back up Maildir and Rspamd data to storage outside the mail server:
<syntaxhighlight lang="bash"> rsync -aHAX \
"/home/$MAIL_USER/Maildir/" \ "<BACKUP_TARGET>/Maildir/"
cp -a /var/lib/rspamd "<BACKUP_TARGET>/rspamd-data/"
test ! -f /root/mail-admin-check || \
cp -a /root/mail-admin-check "$BACK/"
</syntaxhighlight>
Store backup encryption keys and DKIM private keys off-host. A backup kept only beside the server is merely a second copy waiting for the same shovel.
22. Recover on bare metal
Use this order:
- Install a clean AlmaLinux 9 system.
- Restore the original mailbox UID and GID.
- Install the repositories and packages from this guide.
- Restore Postfix, Dovecot, Rspamd, nginx, PHP-FPM, firewall, and certificate configuration.
- Restore the Roundcube configuration and SQLite database.
- Restore DKIM keys and Rspamd data.
- Restore Maildir.
- Correct ownership and SELinux labels.
- Validate every configuration.
- Start services and repeat the end-to-end tests.
Final recovery commands:
<syntaxhighlight lang="bash"> chown -R "$MAIL_USER:$MAIL_USER" "/home/$MAIL_USER/Maildir" chown _rspamd:_rspamd /var/lib/rspamd/dkim/*.key restorecon -RFv /etc /var/www/roundcube /var/lib/rspamd
postfix check doveconf -n >/dev/null rspamadm configtest nginx -t
systemctl restart redis rspamd postfix dovecot php-fpm nginx </syntaxhighlight>
23. Patch, renew, and monitor
Install the completed health script and run the summary with update checks:
<syntaxhighlight lang="bash"> install -o root -g root -m 750 \
<MAIL_ADMIN_CHECK> \ /root/mail-admin-check
/root/mail-admin-check --summary --check-updates </syntaxhighlight>
Routine administration commands:
<syntaxhighlight lang="bash"> dnf check-update || test $? -eq 100 postqueue -p systemctl --failed certbot renew --dry-run journalctl -p warning --since today --no-pager </syntaxhighlight>
Roundcube is installed manually, so dnf will not update it. Before every Roundcube upgrade:
- Back up
/var/www/roundcubeand the SQLite database. - Download and unpack the current complete release tarball.
- Read the release's
UPGRADINGandINSTALLfiles. - Run the bundled upgrade tool.
- Reapply SELinux contexts and validate nginx and PHP.
<syntaxhighlight lang="bash"> cd /usr/local/src/roundcubemail-<NEW_VERSION> bin/installto.sh /var/www/roundcube
php -l /var/www/roundcube/config/config.inc.php restorecon -RFv /var/www/roundcube nginx -t systemctl reload php-fpm nginx </syntaxhighlight>