Saturday, 6 June 2026

How to Set Up a Secure Postfix SMTP Server with DKIM, SPF, DMARC, TLS, and Auth in One Bash Script on Almalinux 10

#!/usr/bin/env bash
# AlmaLinux 10 outbound-only Postfix submission server with Cyrus SASL and DKIM.
# Review the variables below, DNS requirements, and provider port-25 policy first.
set -Eeuo pipefail
umask 077

# Edit these values for each server. PUBLIC_IP must be the server's real static
# public IPv4 address; ACME_EMAIL must be an address you monitor.
DOMAIN="delrobai.com"
MAIL_HOST="mail.delrobai.com"
PUBLIC_IP="1.2.3.4"
SMTP_USER="smtpuser"
FROM_ADDRESS="noreply@delrobai.com"
DKIM_SELECTOR="mail"
ACME_EMAIL="postmaster@delrobai.com"

die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
info() { printf '\n==> %s\n' "$*"; }

[[ $EUID -eq 0 ]] || die 'Run as root: bash almalinux10-mail-setup.sh'
[[ -r /etc/os-release ]] || die 'Cannot identify the operating system.'
# shellcheck disable=SC1091
source /etc/os-release
[[ ${ID:-} == almalinux && ${VERSION_ID:-} == 10* ]] || die 'This script is for AlmaLinux 10.'
[[ -t 0 ]] || die 'Run this script from an interactive terminal; it will prompt for the SMTP password.'

[[ $DOMAIN =~ ^([A-Za-z0-9-]+\.)*[A-Za-z0-9-]+$ ]] || die "Invalid DOMAIN: $DOMAIN"
[[ $MAIL_HOST =~ ^([A-Za-z0-9-]+\.)*[A-Za-z0-9-]+$ ]] || die "Invalid MAIL_HOST: $MAIL_HOST"
[[ $MAIL_HOST == *."$DOMAIN" ]] || die 'MAIL_HOST must be a hostname beneath DOMAIN.'
[[ $SMTP_USER =~ ^[A-Za-z0-9._-]+$ ]] || die 'SMTP_USER may contain only letters, numbers, dot, underscore, and hyphen.'
[[ $DKIM_SELECTOR =~ ^[A-Za-z0-9_-]+$ ]] || die 'Invalid DKIM_SELECTOR.'
[[ $FROM_ADDRESS =~ ^[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)*[A-Za-z0-9-]+$ ]] || die "Invalid FROM_ADDRESS: $FROM_ADDRESS"
[[ ${FROM_ADDRESS##*@} == "$DOMAIN" ]] || die 'FROM_ADDRESS must use DOMAIN.'
[[ $ACME_EMAIL =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || die "Set a valid ACME_EMAIL in the script."
[[ $PUBLIC_IP =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "Set PUBLIC_IP to the actual public IPv4 address."
[[ $PUBLIC_IP != 1.2.3.4 ]] || die "Replace the example PUBLIC_IP (1.2.3.4) with the real server public IP."

DKIM_DIR="/etc/opendkim/keys/$DOMAIN"
DKIM_KEY="$DKIM_DIR/$DKIM_SELECTOR.private"
DKIM_TXT="$DKIM_DIR/$DKIM_SELECTOR.txt"
CERT_DIR="/etc/letsencrypt/live/$MAIL_HOST"

info 'Checking DNS before making changes'
command -v dig >/dev/null 2>&1 || { dnf install -y bind-utils; }
if ! dig +short A "$MAIL_HOST" | grep -Fxq "$PUBLIC_IP"; then
  printf 'DNS A lookup for %s returned:\n' "$MAIL_HOST" >&2
  dig +short A "$MAIL_HOST" >&2 || true
  die "Create/update the A record so $MAIL_HOST resolves to $PUBLIC_IP, wait for DNS propagation, then rerun."
fi

info 'Enabling repositories and installing AlmaLinux 10 packages'
dnf install -y epel-release dnf-plugins-core
dnf config-manager --set-enabled crb
dnf clean all
dnf makecache
dnf install -y \
  postfix postfix-lmdb \
  cyrus-sasl cyrus-sasl-lib cyrus-sasl-plain \
  opendkim opendkim-tools \
  certbot firewalld bind-utils policycoreutils-python-utils

postconf -m | grep -Fxq lmdb || die 'Postfix LMDB map support is missing after installing postfix-lmdb.'
command -v opendkim-genkey >/dev/null 2>&1 || die 'opendkim-genkey was not installed.'

info 'Setting the host name and firewall rules'
hostnamectl set-hostname "$MAIL_HOST"
systemctl enable --now firewalld
firewall-cmd --permanent --add-service=submission
firewall-cmd --permanent --add-service=http
firewall-cmd --reload

info "Requesting or reusing the Let's Encrypt certificate"
certbot certonly --standalone --non-interactive --agree-tos \
  --email "$ACME_EMAIL" -d "$MAIL_HOST"
[[ -s $CERT_DIR/fullchain.pem && -s $CERT_DIR/privkey.pem ]] || die 'Certbot did not create the expected certificate files.'

info 'Backing up existing mail configuration'
BACKUP_DIR="/root/mail-setup-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -m 700 -p "$BACKUP_DIR"
for f in /etc/postfix/main.cf /etc/postfix/master.cf /etc/opendkim.conf /etc/sasl2/smtpd.conf; do
  if [[ -e $f ]]; then cp -a --parents "$f" "$BACKUP_DIR"; fi
done
printf 'Backups: %s\n' "$BACKUP_DIR"

info 'Configuring Cyrus SASL authentication through saslauthd'
install -d -m 755 /etc/sasl2
cat > /etc/sasl2/smtpd.conf <<EOF
pwcheck_method: saslauthd
mech_list: plain login
EOF
chmod 644 /etc/sasl2/smtpd.conf
if [[ ! -e /etc/sysconfig/saslauthd ]]; then touch /etc/sysconfig/saslauthd; fi
if grep -q '^MECH=' /etc/sysconfig/saslauthd; then
  sed -i 's/^MECH=.*/MECH=shadow/' /etc/sysconfig/saslauthd
else
  printf '\nMECH=shadow\n' >> /etc/sysconfig/saslauthd
fi
if ! id "$SMTP_USER" >/dev/null 2>&1; then
  useradd --system --no-create-home --shell /sbin/nologin "$SMTP_USER"
fi
printf 'Set or update the SMTP password for system account %s.\n' "$SMTP_USER"
passwd "$SMTP_USER"
getent group sasl >/dev/null || die 'The Cyrus SASL group does not exist.'
usermod -aG sasl postfix
systemctl enable --now saslauthd

info 'Creating DKIM key if it does not already exist'
install -d -o opendkim -g opendkim -m 750 "$DKIM_DIR"
if [[ ! -s $DKIM_KEY ]]; then
  opendkim-genkey -b 2048 -d "$DOMAIN" -D "$DKIM_DIR" -s "$DKIM_SELECTOR"
fi
[[ -s $DKIM_KEY && -s $DKIM_TXT ]] || die 'DKIM key generation failed.'
chown -R opendkim:opendkim /etc/opendkim
chmod 600 "$DKIM_KEY"
chmod 644 "$DKIM_TXT"

cat > /etc/opendkim.conf <<EOF
Syslog                  yes
UMask                   007
Mode                    sv
Canonicalization        relaxed/simple
SubDomains              no
OversignHeaders         From
Socket                  inet:8891@127.0.0.1
PidFile                 /run/opendkim/opendkim.pid
UserID                  opendkim:opendkim
KeyTable                /etc/opendkim/KeyTable
SigningTable            refile:/etc/opendkim/SigningTable
ExternalIgnoreList      /etc/opendkim/TrustedHosts
InternalHosts           /etc/opendkim/TrustedHosts
# Sign authenticated SMTP submissions as well as local mail.
MacroList               auth_type
EOF

cat > /etc/opendkim/KeyTable <<EOF
$DKIM_SELECTOR._domainkey.$DOMAIN $DOMAIN:$DKIM_SELECTOR:$DKIM_KEY
EOF
cat > /etc/opendkim/SigningTable <<EOF
$FROM_ADDRESS $DKIM_SELECTOR._domainkey.$DOMAIN
EOF
cat > /etc/opendkim/TrustedHosts <<EOF
127.0.0.1
::1
localhost
EOF
chown opendkim:opendkim /etc/opendkim.conf /etc/opendkim/KeyTable /etc/opendkim/SigningTable /etc/opendkim/TrustedHosts
chmod 640 /etc/opendkim.conf /etc/opendkim/KeyTable /etc/opendkim/SigningTable /etc/opendkim/TrustedHosts

info 'Configuring Postfix for outbound-only mail and LMDB maps'
postconf -e "myhostname = $MAIL_HOST"
postconf -e "mydomain = $DOMAIN"
postconf -e 'myorigin = $mydomain'
postconf -e 'inet_protocols = ipv4'
postconf -e 'inet_interfaces = all'
postconf -e 'mydestination = localhost.$mydomain, localhost'
postconf -e 'mynetworks = 127.0.0.0/8'
postconf -e 'relay_domains ='
postconf -e 'smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination'
postconf -e "smtpd_tls_cert_file = $CERT_DIR/fullchain.pem"
postconf -e "smtpd_tls_key_file = $CERT_DIR/privkey.pem"
postconf -e 'smtpd_tls_security_level = may'
postconf -e 'smtp_tls_security_level = may'
postconf -e 'smtpd_tls_auth_only = yes'
postconf -e 'smtpd_tls_loglevel = 1'
postconf -e 'smtpd_sasl_auth_enable = no'
postconf -e 'smtpd_sasl_type = cyrus'
postconf -e 'smtpd_sasl_path = smtpd'
postconf -e 'smtpd_sasl_local_domain = $mydomain'
postconf -e 'smtpd_sasl_security_options = noanonymous'
postconf -e 'smtpd_sender_login_maps = lmdb:/etc/postfix/sender_login_maps'
postconf -e 'smtpd_sender_restrictions = reject_sender_login_mismatch'
postconf -e 'smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination'
postconf -e 'local_recipient_maps ='
postconf -e 'unknown_local_recipient_reject_code = 550'
postconf -e 'mailbox_size_limit = 0'
postconf -e 'message_size_limit = 52428800'
postconf -e 'alias_maps = lmdb:/etc/aliases'
postconf -e 'alias_database = lmdb:/etc/aliases'
postconf -e 'milter_default_action = accept'
postconf -e 'milter_protocol = 6'
postconf -e 'smtpd_milters = inet:127.0.0.1:8891'
postconf -e 'non_smtpd_milters = inet:127.0.0.1:8891'
postconf -e 'smtpd_banner = $myhostname ESMTP'
postconf -e 'biff = no'
postconf -e 'append_dot_mydomain = no'
postconf -e 'compatibility_level = 3.6'

# Permit exactly the configured From address for the configured SMTP login.
printf '%s %s\n' "$FROM_ADDRESS" "$SMTP_USER" > /etc/postfix/sender_login_maps
chown root:root /etc/postfix/sender_login_maps
chmod 600 /etc/postfix/sender_login_maps
postmap lmdb:/etc/postfix/sender_login_maps

# Ensure submission is active and forces STARTTLS plus authentication.
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_recipient_restrictions=permit_sasl_authenticated,reject'
postconf -P 'submission/inet/smtpd_sender_restrictions=reject_sender_login_mismatch'

# Build the aliases database using LMDB, never the unsupported hash backend.
touch /etc/aliases
chown root:root /etc/aliases
chmod 644 /etc/aliases
rm -f /etc/aliases.lmdb
postalias lmdb:/etc/aliases
chown root:root /etc/aliases.lmdb
chmod 644 /etc/aliases.lmdb

info 'Configuring automatic Postfix reload after certificate renewal'
install -d -m 755 /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-postfix.sh <<'EOF'
#!/usr/bin/env bash
systemctl reload postfix
EOF
chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-postfix.sh

info 'Starting services and validating configuration'
restorecon -RF /etc/postfix /etc/opendkim /etc/sasl2 /etc/letsencrypt 2>/dev/null || true
systemctl enable --now opendkim
systemctl enable --now postfix
postfix check
systemctl restart opendkim postfix
systemctl reload postfix

if ! ss -ltnH | awk '$4 ~ /:587$/ { found=1 } END { exit !found }'; then
  ss -ltnp >&2
  die 'Postfix is not listening on TCP port 587.'
fi

printf '\nSetup complete. This config is outbound-only; it does not host mailboxes.\n'
printf 'SMTP host:      %s\nSMTP port:      587\nSecurity:       STARTTLS\nSMTP username:  %s\nFrom address:   %s\n' \
  "$MAIL_HOST" "$SMTP_USER" "$FROM_ADDRESS"
printf '\nPublish/check these DNS records before sending production mail:\n'
printf 'A:      %s -> %s\n' "$MAIL_HOST" "$PUBLIC_IP"
printf 'PTR:    ask the VPS provider to map %s -> %s\n' "$PUBLIC_IP" "$MAIL_HOST"
printf 'DKIM:   publish the TXT content from %s\n' "$DKIM_TXT"
printf 'SPF:    merge ip4:%s into the domain\x27s existing SPF record; do not add a second SPF TXT record.\n' "$PUBLIC_IP"
printf 'DMARC:  publish _dmarc.%s with a monitoring policy such as p=none, then review reports.\n' "$DOMAIN"
printf '\nDKIM TXT record file:\n'
cat "$DKIM_TXT"
printf '\nCheck DKIM after publishing DNS: opendkim-testkey -d %q -s %q -vvv\n' "$DOMAIN" "$DKIM_SELECTOR"
printf 'Check logs: journalctl -u postfix -u opendkim -n 100 --no-pager\n'
printf 'Direct delivery requires outbound TCP 25; if your VPS blocks it, configure a relayhost on port 587.\n'

No comments:

Post a Comment