Modern Mail Server Setup Guide

From Nocrashwiki
Revision as of 15:12, 14 July 2026 by Chris (talk | contribs) (Created page with "__TOC__ 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, rep...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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

  1. /etc/rspamd/local.d/worker-normal.inc

enabled = false;

  1. /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">

  1. !/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:

  1. Install a clean AlmaLinux 9 system.
  2. Restore the original mailbox UID and GID.
  3. Install the repositories and packages from this guide.
  4. Restore Postfix, Dovecot, Rspamd, nginx, PHP-FPM, firewall, and certificate configuration.
  5. Restore the Roundcube configuration and SQLite database.
  6. Restore DKIM keys and Rspamd data.
  7. Restore Maildir.
  8. Correct ownership and SELinux labels.
  9. Validate every configuration.
  10. 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:

  1. Back up /var/www/roundcube and the SQLite database.
  2. Download and unpack the current complete release tarball.
  3. Read the release's UPGRADING and INSTALL files.
  4. Run the bundled upgrade tool.
  5. 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>

Reference links