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

Wednesday, 20 May 2026

How to Set Up an AlmaLinux 10 VPS as an Outbound-Only Mail Server with Postfix, DKIM, SPF, DMARC, and rDNS

How to Set Up an AlmaLinux 10 VPS as an Outbound-Only Mail Server with Postfix, DKIM, SPF, DMARC, and rDNS

This guide explains how to configure an AlmaLinux 10 VPS as a standalone outbound-only mail server for sending transactional email from addresses such as:

noreply@example.com
contact@example.com

The server will use:

Postfix    - SMTP server
OpenDKIM   - DKIM signing
SPF        - Sender authorization
DMARC      - Domain policy and reporting
rDNS/PTR   - Reverse DNS identity
TLS        - Encrypted SMTP submission

Example values used in this guide:

Domain:      example.com
Hostname:    mail.example.com
Server IP:   1.2.3.4
Senders:     noreply@example.com, contact@example.com
OS:          AlmaLinux 10

Replace these with your actual values.


1. DNS Records

Create the following DNS records.

A Record

mail.example.com.  3600  IN  A  1.2.3.4

MX Record

Even for an outbound-only server, having an MX record is useful for domain hygiene and bounce handling.

example.com.       3600  IN  MX  10 mail.example.com.

SPF Record

example.com.       3600  IN  TXT "v=spf1 ip4:1.2.3.4 a:mail.example.com -all"

DMARC Record

Start with monitoring:

_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=s; aspf=s"

After verifying clean delivery, move to:

_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; adkim=s; aspf=s"

Eventually:

_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; adkim=s; aspf=s"

PTR / rDNS

PTR records are not configured in your normal DNS zone. They must be set by your VPS provider.

Ask the provider to set:

1.2.3.4 -> mail.example.com

Forward and reverse DNS should match:

mail.example.com -> 1.2.3.4
1.2.3.4    -> mail.example.com

This is critical for deliverability.


2. Set the Server Hostname

hostnamectl set-hostname mail.example.com

Update /etc/hosts:

cat > /etc/hosts <<'EOF'
127.0.0.1 localhost
1.2.3.4 mail.example.com mail
EOF

Verify:

hostname -f

Expected:

mail.example.com

3. Install Required Packages

dnf update -y
dnf install -y epel-release dnf-plugins-core
dnf config-manager --set-enabled crb || true

dnf install -y postfix opendkim opendkim-tools firewalld certbot mailx bind-utils policycoreutils-python-utils cyrus-sasl cyrus-sasl-plain

This setup does not require Dovecot unless you want to host inbound mailboxes.


4. Configure the Firewall

For outbound-only authenticated SMTP, open port 587.

systemctl enable --now firewalld

firewall-cmd --permanent --add-service=submission
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload

Used ports:

587  SMTP submission
80   Let's Encrypt HTTP challenge
443  HTTPS / future MTA-STS / optional web services

Port 25 is still required for outbound direct-to-MX delivery. Many VPS providers block outbound port 25, so confirm this later.


5. Get a TLS Certificate

certbot certonly --standalone -d mail.example.com

Certificate paths:

/etc/letsencrypt/live/mail.example.com/fullchain.pem
/etc/letsencrypt/live/mail.example.com/privkey.pem

6. Configure Postfix

Back up the original config:

cp /etc/postfix/main.cf /etc/postfix/main.cf.bak
cp /etc/postfix/master.cf /etc/postfix/master.cf.bak

Replace /etc/postfix/main.cf:

cat > /etc/postfix/main.cf <<'EOF'
# Identity
myhostname = mail.example.com
mydomain = example.com
myorigin = $mydomain

# IPv4 only
inet_protocols = ipv4
inet_interfaces = all

# Outbound-only domain handling
mydestination = localhost.$mydomain, localhost
relay_domains =

# Local trusted network
mynetworks = 127.0.0.0/8

# Do not be an open relay
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination

# TLS
smtpd_tls_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem
smtpd_tls_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem
smtpd_tls_security_level = may
smtp_tls_security_level = may
smtpd_tls_auth_only = yes
smtpd_tls_loglevel = 1
smtp_tls_loglevel = 1

# SASL authentication
smtpd_sasl_auth_enable = yes
smtpd_sasl_type = cyrus
smtpd_sasl_security_options = noanonymous
broken_sasl_auth_clients = yes

# Sender restrictions
smtpd_sender_login_maps = lmdb:/etc/postfix/sender_login_maps
smtpd_sender_restrictions = reject_sender_login_mismatch, check_sender_access lmdb:/etc/postfix/sender_access, reject

# No local mailbox delivery for example.com
local_recipient_maps =
unknown_local_recipient_reject_code = 550

# Limits
mailbox_size_limit = 0
message_size_limit = 52428800

# DKIM signing
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:127.0.0.1:8891
non_smtpd_milters = inet:127.0.0.1:8891

# AlmaLinux 10 / Postfix LMDB maps
alias_maps = lmdb:/etc/aliases
alias_database = lmdb:/etc/aliases

# Misc
smtpd_banner = $myhostname ESMTP
biff = no
append_dot_mydomain = no
readme_directory = no
compatibility_level = 3.6
EOF

7. Configure Submission Port 587

Enable SMTP submission:

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'

Important: postconf -P does not accept whitespace inside service override values. Keep complex sender restrictions in main.cf, not in master.cf.


8. Restrict Allowed Sender Addresses

Only allow these sender addresses:

noreply@example.com
contact@example.com

Create /etc/postfix/sender_access:

cat > /etc/postfix/sender_access <<'EOF'
noreply@example.com OK
contact@example.com OK
EOF

postmap lmdb:/etc/postfix/sender_access

Create /etc/postfix/sender_login_maps:

cat > /etc/postfix/sender_login_maps <<'EOF'
noreply@example.com noreply
contact@example.com contact
EOF

postmap lmdb:/etc/postfix/sender_login_maps

This means:

SMTP username noreply -> can send only as noreply@example.com
SMTP username contact -> can send only as contact@example.com

9. Configure SASL Authentication

Enable and start SASL:

systemctl enable --now saslauthd

Create /etc/sasl2/smtpd.conf:

cat > /etc/sasl2/smtpd.conf <<'EOF'
pwcheck_method: saslauthd
mech_list: plain login
EOF

Create system users for SMTP authentication:

useradd -r -s /sbin/nologin noreply
passwd noreply

useradd -r -s /sbin/nologin contact
passwd contact

These are SMTP auth users, not mailbox users.


10. Configure OpenDKIM

Create DKIM key directory:

mkdir -p /etc/opendkim/keys/example.com

Generate DKIM key:

opendkim-genkey -b 2048 -d example.com -D /etc/opendkim/keys/example.com -s mail

Set permissions:

chown -R opendkim:opendkim /etc/opendkim
chmod 600 /etc/opendkim/keys/example.com/mail.private

Configure /etc/opendkim.conf:

cat > /etc/opendkim.conf <<'EOF'
Syslog                  yes
UMask                   002
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
EOF

Create DKIM map files:

cat > /etc/opendkim/KeyTable <<'EOF'
mail._domainkey.example.com example.com:mail:/etc/opendkim/keys/example.com/mail.private
EOF

cat > /etc/opendkim/SigningTable <<'EOF'
noreply@example.com mail._domainkey.example.com
contact@example.com mail._domainkey.example.com
EOF

cat > /etc/opendkim/TrustedHosts <<'EOF'
127.0.0.1
localhost
mail.example.com
example.com
1.2.3.4
EOF

chown -R opendkim:opendkim /etc/opendkim

11. Add the DKIM Record to DNS

Print the generated DKIM public key:

cat /etc/opendkim/keys/example.com/mail.txt

It will look similar to:

mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
  "p=PUBLIC_KEY_HERE" )

For Cloudflare, create a TXT record like this:

Type: TXT
Name: mail._domainkey
Content: v=DKIM1; k=rsa; p=PUBLIC_KEY_HERE
TTL: Auto
Proxy status: DNS only

Do not include:

IN TXT
( )
quotation marks
comments after semicolons

12. Fix Alias Database Permissions

On AlmaLinux 10/Postfix LMDB setups, generate the aliases database cleanly:

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

Then:

newaliases

13. Enable Services

systemctl enable --now postfix
systemctl enable --now opendkim
systemctl enable --now saslauthd

systemctl restart saslauthd postfix opendkim

Validate:

postfix check
postconf -n

Check that port 587 is listening:

ss -tulpn | grep master

Expected:

0.0.0.0:587

14. Test DNS

dig +short A mail.example.com
dig +short MX example.com
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short TXT mail._domainkey.example.com
dig -x 1.2.3.4 +short

Expected:

mail.example.com resolves to 1.2.3.4
PTR resolves back to mail.example.com
SPF exists
DKIM exists
DMARC exists

Test DKIM:

opendkim-testkey -d example.com -s mail -vvv

Expected result:

key OK

15. Test SMTP Authentication

Install Swaks:

dnf install -y swaks

Test sending as noreply@example.com:

swaks --to recipient@gmail.com \
  --from noreply@example.com \
  --server mail.example.com \
  --port 587 \
  --auth LOGIN \
  --auth-user noreply \
  --auth-password 'YOUR_PASSWORD' \
  --tls

Test sending as contact@example.com:

swaks --to recipient@gmail.com \
  --from contact@example.com \
  --server mail.example.com \
  --port 587 \
  --auth LOGIN \
  --auth-user contact \
  --auth-password 'YOUR_PASSWORD' \
  --tls

This should fail because the sender does not match the authenticated user:

swaks --to recipient@gmail.com \
  --from contact@example.com \
  --server mail.example.com \
  --port 587 \
  --auth LOGIN \
  --auth-user noreply \
  --auth-password 'YOUR_PASSWORD' \
  --tls

16. Check Whether Outbound Port 25 Is Blocked

Direct-to-MX delivery requires outbound TCP port 25.

Test:

nc -vz gmail-smtp-in.l.google.com 25
nc -vz alt1.gmail-smtp-in.l.google.com 25

Also test non-SMTP outbound connectivity:

nc -vz smtp.gmail.com 587
nc -vz google.com 443

If 587 and 443 work but 25 times out, the VPS provider is blocking outbound SMTP.

Example blocked result:

Connection to gmail-smtp-in.l.google.com 25 failed: TIMEOUT
Connected to smtp.gmail.com 587
Connected to google.com 443

In that case, Postfix can accept mail locally or through submission, but it cannot deliver directly to Gmail, Outlook, Yahoo, or other MX servers.


17. Ask the VPS Provider to Unblock Port 25

Send this to the provider:

Please unblock outbound TCP port 25 for VPS IP 1.2.3.4.
Hostname: mail.example.com PTR/rDNS requested: 1.2.3.4 -> mail.example.com
This server is used only for legitimate transactional outbound mail from: noreply@example.com contact@example.com SPF, DKIM, DMARC, TLS, and sender restrictions are configured.

After the provider unblocks port 25, flush the Postfix queue:

postqueue -f

Watch logs:

journalctl -u postfix -f

18. Alternative: Use an SMTP Relay on Port 587

If the VPS provider refuses to unblock outbound port 25, use a transactional email relay such as:

Amazon SES
Mailgun
Postmark
SMTP2GO
Brevo
SendGrid

Configure Postfix relayhost:

postconf -e 'relayhost = [smtp-relay.example.net]:587'
postconf -e 'smtp_sasl_auth_enable = yes'
postconf -e 'smtp_sasl_password_maps = lmdb:/etc/postfix/sasl_passwd'
postconf -e 'smtp_sasl_security_options = noanonymous'
postconf -e 'smtp_tls_security_level = encrypt'
postconf -e 'smtp_tls_CAfile = /etc/pki/tls/certs/ca-bundle.crt'

Create credentials:

cat > /etc/postfix/sasl_passwd <<'EOF'
[smtp-relay.example.net]:587 USERNAME:PASSWORD
EOF

postmap lmdb:/etc/postfix/sasl_passwd
chmod 600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.lmdb
systemctl reload postfix

Flush the queue:

postqueue -f

19. Queue Management

Show queued mail:

mailq

Retry delivery:

postqueue -f

Delete all queued test emails:

postsuper -d ALL

20. Useful Logs

Postfix logs:

journalctl -u postfix -f

OpenDKIM logs:

journalctl -u opendkim -f

SASL logs:

journalctl -u saslauthd -f

Recent Postfix logs:

journalctl -u postfix -n 100 --no-pager

21. Application SMTP Settings

Use these settings in your application:

SMTP host:      mail.example.com
SMTP port:      587
Security:       STARTTLS
Username:       noreply
Password:       password for noreply user
From address:   noreply@example.com

Or:

SMTP host:      mail.example.com
SMTP port:      587
Security:       STARTTLS
Username:       contact
Password:       password for contact user
From address:   contact@example.com

Do not allow arbitrary sender addresses from applications.


22. Final Checklist

Before production use, confirm:

A record exists for mail.example.com
MX record points to mail.example.com
PTR/rDNS points server IP back to mail.example.com
SPF includes the server IP
DKIM TXT record exists and validates
DMARC record exists
Postfix is not an open relay
Only approved sender addresses are allowed
SMTP submission on port 587 requires authentication
Outbound port 25 is open, or relayhost is configured
TLS certificate is valid
DKIM signing works

Conclusion

A secure outbound-only mail server does not need full mailbox hosting. For transactional sending, the key pieces are:

Postfix for SMTP
OpenDKIM for signing
SPF, DKIM, and DMARC for domain authentication
rDNS/PTR for server identity
Port 587 for authenticated submission
Port 25 outbound for direct MX delivery

The most common blocker is not Postfix. It is VPS providers blocking outbound TCP port 25.

If outbound port 25 is blocked, direct delivery will fail with timeout errors. Either request that the provider unblock port 25, or configure Postfix to use a reputable SMTP relay over port 587.