Saturday, 6 June 2026

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

#!/usr/bin/env bash
set -euo pipefail

# ============================================================
# Generic Postfix + SASL + OpenDKIM SMTP Server Setup
# Target OS: RHEL / Rocky Linux / AlmaLinux / CentOS Stream
#
# Example:
#   DOMAIN=example.com PUBLIC_IP=1.2.3.4 SMTP_USER=smtpuser bash setup-smtp.sh
#
# Optional:
#   HOSTNAME_FQDN=mail.example.com
#   FROM_ADDRESS=noreply@example.com
#   DKIM_SELECTOR=mail
#   CERT_EMAIL=admin@example.com
#
# DNS placeholders used:
#   Domain: example.com
#   Mail host: mail.example.com
#   Public IP: 1.2.3.4
# ============================================================

DOMAIN="${DOMAIN:-example.com}"
PUBLIC_IP="${PUBLIC_IP:-1.2.3.4}"
HOSTNAME_FQDN="${HOSTNAME_FQDN:-mail.${DOMAIN}}"
SMTP_USER="${SMTP_USER:-smtpuser}"
FROM_ADDRESS="${FROM_ADDRESS:-noreply@${DOMAIN}}"
DKIM_SELECTOR="${DKIM_SELECTOR:-mail}"
CERT_EMAIL="${CERT_EMAIL:-}"
POSTFIX_DIR="/etc/postfix"
OPENDKIM_DIR="/etc/opendkim"
DKIM_KEY_DIR="${OPENDKIM_DIR}/keys/${DOMAIN}"

if [[ "$(id -u)" -ne 0 ]]; then
  echo "Run as root."
  exit 1
fi

echo "============================================================"
echo "SMTP setup starting"
echo "Domain:        ${DOMAIN}"
echo "Hostname:      ${HOSTNAME_FQDN}"
echo "Public IP:     ${PUBLIC_IP}"
echo "SMTP user:     ${SMTP_USER}"
echo "From address:  ${FROM_ADDRESS}"
echo "DKIM selector: ${DKIM_SELECTOR}"
echo "============================================================"

# ------------------------------------------------------------
# 1. Base packages
# ------------------------------------------------------------

dnf install -y \
  postfix \
  cyrus-sasl \
  cyrus-sasl-lib \
  cyrus-sasl-plain \
  cyrus-sasl-md5 \
  opendkim \
  opendkim-tools \
  firewalld \
  bind-utils \
  nmap-ncat \
  openssl \
  perl \
  glibc-langpack-en \
  glibc-langpack-pt \
  certbot || true

# ------------------------------------------------------------
# 2. Locale cleanup
# ------------------------------------------------------------

localectl set-locale LANG=en_US.UTF-8 || true
export LANG=en_US.UTF-8
unset LC_MEASUREMENT LC_PAPER LC_MONETARY LC_NAME LC_ADDRESS LC_NUMERIC LC_TELEPHONE LC_IDENTIFICATION || true

# ------------------------------------------------------------
# 3. Hostname
# ------------------------------------------------------------

hostnamectl set-hostname "${HOSTNAME_FQDN}"

# ------------------------------------------------------------
# 4. TLS certificate
# ------------------------------------------------------------

CERT_PATH="/etc/letsencrypt/live/${HOSTNAME_FQDN}/fullchain.pem"
KEY_PATH="/etc/letsencrypt/live/${HOSTNAME_FQDN}/privkey.pem"

if command -v certbot >/dev/null 2>&1; then
  systemctl stop postfix 2>/dev/null || true

  if [[ -n "${CERT_EMAIL}" ]]; then
    certbot certonly --standalone \
      -d "${HOSTNAME_FQDN}" \
      --agree-tos \
      -m "${CERT_EMAIL}" \
      --non-interactive || true
  else
    certbot certonly --standalone \
      -d "${HOSTNAME_FQDN}" \
      --agree-tos \
      --register-unsafely-without-email \
      --non-interactive || true
  fi
fi

if [[ ! -f "${CERT_PATH}" || ! -f "${KEY_PATH}" ]]; then
  echo "Let's Encrypt certificate not found. Creating temporary self-signed certificate."
  mkdir -p /etc/postfix/tls
  CERT_PATH="/etc/postfix/tls/${HOSTNAME_FQDN}.crt"
  KEY_PATH="/etc/postfix/tls/${HOSTNAME_FQDN}.key"

  openssl req -x509 -newkey rsa:2048 -nodes \
    -keyout "${KEY_PATH}" \
    -out "${CERT_PATH}" \
    -days 365 \
    -subj "/CN=${HOSTNAME_FQDN}"

  chmod 600 "${KEY_PATH}"
fi

# ------------------------------------------------------------
# 5. SASL auth setup
# ------------------------------------------------------------

mkdir -p /etc/sasl2

cat > /etc/sasl2/smtpd.conf <<EOF
pwcheck_method: auxprop
auxprop_plugin: sasldb
mech_list: PLAIN LOGIN
EOF

SMTP_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')"

echo "${SMTP_PASSWORD}" | saslpasswd2 -p -c -u "${DOMAIN}" "${SMTP_USER}"

if [[ -f /etc/sasldb2 ]]; then
  chown root:postfix /etc/sasldb2 || true
  chmod 0640 /etc/sasldb2 || true
fi

# ------------------------------------------------------------
# 6. Postfix sender maps
# ------------------------------------------------------------

cat > "${POSTFIX_DIR}/sender_login_maps" <<EOF
${FROM_ADDRESS} ${SMTP_USER}
EOF

cat > "${POSTFIX_DIR}/sender_access" <<EOF
${FROM_ADDRESS} OK
EOF

chown root:root "${POSTFIX_DIR}/sender_login_maps" "${POSTFIX_DIR}/sender_access"
chmod 0644 "${POSTFIX_DIR}/sender_login_maps" "${POSTFIX_DIR}/sender_access"

postmap hash:"${POSTFIX_DIR}/sender_login_maps"
postmap hash:"${POSTFIX_DIR}/sender_access"

# ------------------------------------------------------------
# 7. Postfix main.cf
# ------------------------------------------------------------

postconf -e "compatibility_level = 2"
postconf -e "myhostname = ${HOSTNAME_FQDN}"
postconf -e "mydomain = ${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 "mynetworks = 127.0.0.0/8"
postconf -e "home_mailbox = Maildir/"
postconf -e "smtpd_banner = \$myhostname ESMTP"
postconf -e "biff = no"
postconf -e "append_dot_mydomain = no"
postconf -e "readme_directory = no"

# TLS
postconf -e "smtpd_tls_cert_file = ${CERT_PATH}"
postconf -e "smtpd_tls_key_file = ${KEY_PATH}"
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"

# SASL
postconf -e "smtpd_sasl_auth_enable = yes"
postconf -e "smtpd_sasl_type = cyrus"
postconf -e "smtpd_sasl_path = smtpd"
postconf -e "smtpd_sasl_local_domain = ${DOMAIN}"
postconf -e "smtpd_sasl_security_options = noanonymous"
postconf -e "broken_sasl_auth_clients = yes"

# Relay safety
postconf -e "smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"

# Sender login control
postconf -e "smtpd_sender_login_maps = hash:${POSTFIX_DIR}/sender_login_maps"
postconf -e "smtpd_sender_restrictions = reject_sender_login_mismatch, check_sender_access hash:${POSTFIX_DIR}/sender_access"

# OpenDKIM milter
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"

# ------------------------------------------------------------
# 8. Postfix master.cf submission service
# ------------------------------------------------------------

cp -a "${POSTFIX_DIR}/master.cf" "${POSTFIX_DIR}/master.cf.bak.$(date +%Y%m%d%H%M%S)"

awk '
BEGIN { skip=0 }
/^submission[[:space:]]+inet/ { skip=1; next }
skip==1 && /^[[:space:]]+-o/ { next }
skip==1 && /^[^[:space:]#]/ { skip=0 }
skip==0 { print }
' "${POSTFIX_DIR}/master.cf" > "${POSTFIX_DIR}/master.cf.new"

cat >> "${POSTFIX_DIR}/master.cf.new" <<'EOF'

submission inet n       -       n       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
  -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject_unauth_destination
EOF

mv "${POSTFIX_DIR}/master.cf.new" "${POSTFIX_DIR}/master.cf"

# ------------------------------------------------------------
# 9. OpenDKIM setup
# ------------------------------------------------------------

mkdir -p "${DKIM_KEY_DIR}"

if [[ ! -f "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private" ]]; then
  opendkim-genkey \
    -b 2048 \
    -d "${DOMAIN}" \
    -s "${DKIM_SELECTOR}" \
    -D "${DKIM_KEY_DIR}"
fi

chown -R opendkim:opendkim "${OPENDKIM_DIR}/keys"
chmod 0700 "${DKIM_KEY_DIR}"
chmod 0600 "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private"

cat > "${OPENDKIM_DIR}/KeyTable" <<EOF
${DKIM_SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${DKIM_SELECTOR}:${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private
EOF

cat > "${OPENDKIM_DIR}/SigningTable" <<EOF
*@${DOMAIN} ${DKIM_SELECTOR}._domainkey.${DOMAIN}
EOF

cat > "${OPENDKIM_DIR}/TrustedHosts" <<EOF
127.0.0.1
localhost
${HOSTNAME_FQDN}
${DOMAIN}
${PUBLIC_IP}
EOF

cp -a /etc/opendkim.conf /etc/opendkim.conf.bak.$(date +%Y%m%d%H%M%S) 2>/dev/null || true

cat > /etc/opendkim.conf <<EOF
Syslog                  yes
SyslogSuccess           yes
LogWhy                  yes
UMask                   002
Canonicalization        relaxed/simple
Mode                    sv
SubDomains              no
AutoRestart             yes
AutoRestartRate         10/1h
Background              yes
DNSTimeout              5
SignatureAlgorithm      rsa-sha256
UserID                  opendkim:opendkim
Socket                  inet:8891@127.0.0.1
PidFile                 /run/opendkim/opendkim.pid
KeyTable                refile:${OPENDKIM_DIR}/KeyTable
SigningTable            refile:${OPENDKIM_DIR}/SigningTable
ExternalIgnoreList      refile:${OPENDKIM_DIR}/TrustedHosts
InternalHosts           refile:${OPENDKIM_DIR}/TrustedHosts
EOF

# ------------------------------------------------------------
# 10. Firewall
# ------------------------------------------------------------

systemctl enable --now firewalld

firewall-cmd --permanent --add-port=25/tcp
firewall-cmd --permanent --add-port=587/tcp
firewall-cmd --permanent --add-port=80/tcp
firewall-cmd --reload

# ------------------------------------------------------------
# 11. Enable services
# ------------------------------------------------------------

postfix check

systemctl enable --now opendkim
systemctl restart opendkim

systemctl enable --now postfix
systemctl restart postfix

# ------------------------------------------------------------
# 12. Output DNS records and test commands
# ------------------------------------------------------------

DKIM_TXT_FILE="${DKIM_KEY_DIR}/${DKIM_SELECTOR}.txt"

echo
echo "============================================================"
echo "SETUP COMPLETE"
echo "============================================================"
echo
echo "SMTP credentials:"
echo "SMTP server:   ${HOSTNAME_FQDN}"
echo "SMTP port:     587"
echo "SMTP user:     ${SMTP_USER}"
echo "SMTP password: ${SMTP_PASSWORD}"
echo "From address:  ${FROM_ADDRESS}"
echo
echo "IMPORTANT: save this password now. It is not stored in this script output again."
echo
echo "============================================================"
echo "CLOUDFLARE DNS RECORDS"
echo "============================================================"
echo
echo "A record:"
echo "Type:    A"
echo "Name:    mail"
echo "Content: ${PUBLIC_IP}"
echo "Proxy:   DNS only"
echo
echo "MX record:"
echo "Type:        MX"
echo "Name:        ${DOMAIN}"
echo "Mail server: ${HOSTNAME_FQDN}"
echo "Priority:    10"
echo
echo "SPF TXT record:"
echo "Type:    TXT"
echo "Name:    ${DOMAIN}"
echo "Content: v=spf1 ip4:${PUBLIC_IP} mx ~all"
echo
echo "DMARC TXT record:"
echo "Type:    TXT"
echo "Name:    _dmarc"
echo "Content: v=DMARC1; p=none; rua=mailto:${FROM_ADDRESS}; fo=1"
echo
echo "DKIM TXT record:"
echo "Type: TXT"
echo "Name: ${DKIM_SELECTOR}._domainkey"
echo "Content:"
echo

if [[ -f "${DKIM_TXT_FILE}" ]]; then
  sed -E 's/.*TXT[[:space:]]+\( //' "${DKIM_TXT_FILE}" \
    | tr -d '\n' \
    | sed -E 's/[[:space:]]*"//g; s/"[[:space:]]*//g; s/\)[[:space:]]*;.*$//'
  echo
else
  echo "DKIM TXT file not found at ${DKIM_TXT_FILE}"
fi

echo
echo "============================================================"
echo "VALIDATION COMMANDS"
echo "============================================================"
echo
echo "Check Postfix listens:"
echo "  ss -lntp | grep -E ':25|:587'"
echo
echo "Check supported Postfix map types:"
echo "  postconf -m | grep -E 'hash|lmdb'"
echo
echo "Check active Postfix config:"
echo "  postconf -n | grep -E 'smtpd_relay_restrictions|smtpd_recipient_restrictions|smtpd_sender_restrictions|smtpd_sender_login_maps|smtpd_sasl_auth_enable'"
echo
echo "Check DKIM key:"
echo "  opendkim-testkey -d ${DOMAIN} -s ${DKIM_SELECTOR} -vvv"
echo
echo "Check DNS:"
echo "  dig +short A ${HOSTNAME_FQDN}"
echo "  dig +short MX ${DOMAIN}"
echo "  dig +short TXT ${DOMAIN}"
echo "  dig +short TXT _dmarc.${DOMAIN}"
echo "  dig +short TXT ${DKIM_SELECTOR}._domainkey.${DOMAIN}"
echo
echo "Test SMTP submission:"
echo "  swaks --to recipient@gmail.com \\"
echo "    --from ${FROM_ADDRESS} \\"
echo "    --server ${HOSTNAME_FQDN} \\"
echo "    --port 587 \\"
echo "    --auth LOGIN \\"
echo "    --auth-user ${SMTP_USER} \\"
echo "    --auth-password '${SMTP_PASSWORD}' \\"
echo "    --tls"
echo
echo "Check delivery logs:"
echo "  tail -n 100 /var/log/maillog"
echo
echo "If publishing this script, replace real domains, IPs, usernames, and passwords with placeholders."
echo "============================================================"