What You Are Building
A mail server that accepts inbound email, scans it for malware, scores it for spam, and delivers clean mail to local mailboxes — all on a single Ubuntu server. The stack:
- Postfix — the MTA (Mail Transfer Agent) that handles SMTP
- Dovecot — the IMAP/POP3 server that lets clients read mail
- Amavis — the content filter that sits between Postfix and the scanners
- ClamAV — the antivirus engine
- SpamAssassin — the spam classifier
Amavis is the glue. Postfix hands mail to Amavis, which passes it through ClamAV and SpamAssassin, then re-injects clean mail back into Postfix for delivery. This is the standard production architecture — it keeps each component doing one job.
Prerequisites
- Ubuntu 24.04 LTS with root or sudo access
- A domain name with DNS you control (e.g.
example.com) - A valid PTR record for the server's public IP resolving to the mail hostname
- Ports 25, 587 and 993 open in your firewall
DNS Records
Before you start, set these DNS records:
mail.example.com. A 203.0.113.10
example.com. MX 10 mail.example.com.
example.com. TXT "v=spf1 mx -all"
The A record points your mail hostname to the server. The MX record tells the world where to deliver mail for your domain. The SPF record authorises your MX server to send on behalf of the domain. DKIM and DMARC come later.
Step 1: System Preparation
sudo apt update && sudo apt upgrade -y
sudo hostnamectl set-hostname mail.example.com
Edit /etc/hosts to include your FQDN:
127.0.0.1 localhost
203.0.113.10 mail.example.com mail
Verify:
hostname -f
This should return mail.example.com. Postfix relies on the system hostname for its greeting banner and HELO identity.
Step 2: Install Postfix
sudo apt install -y postfix
The installer asks for a configuration type. Select Internet Site and set the system mail name to example.com (your domain, not the hostname).
Core Configuration
Edit /etc/postfix/main.cf. Replace the defaults with a hardened configuration:
# ─── identity ─────────────────────────────────────────────
myhostname = mail.example.com
mydomain = example.com
myorigin = $mydomain
mydestination = $myhostname, $mydomain, localhost.$mydomain, localhost
# ─── network ──────────────────────────────────────────────
inet_interfaces = all
inet_protocols = ipv4
mynetworks = 127.0.0.0/8 [::1]/128
# ─── TLS (inbound) ───────────────────────────────────────
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
smtpd_tls_auth_only = yes
smtpd_tls_protocols = >=TLSv1.2
smtpd_tls_mandatory_protocols = >=TLSv1.2
smtpd_tls_mandatory_ciphers = medium
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
# ─── TLS (outbound) ──────────────────────────────────────
smtp_tls_security_level = may
smtp_tls_protocols = >=TLSv1.2
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_loglevel = 1
# ─── SASL authentication ─────────────────────────────────
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
smtpd_sasl_security_options = noanonymous
smtpd_sasl_local_domain = $myhostname
# ─── restrictions ─────────────────────────────────────────
smtpd_helo_required = yes
smtpd_helo_restrictions =
permit_mynetworks,
reject_invalid_helo_hostname,
reject_non_fqdn_helo_hostname
smtpd_sender_restrictions =
permit_mynetworks,
reject_non_fqdn_sender,
reject_unknown_sender_domain
smtpd_recipient_restrictions =
permit_mynetworks,
permit_sasl_authenticated,
reject_unauth_destination,
reject_non_fqdn_recipient,
reject_unknown_recipient_domain,
reject_rbl_client zen.spamhaus.org,
reject_rbl_client bl.spamcop.net
smtpd_relay_restrictions =
permit_mynetworks,
permit_sasl_authenticated,
defer_unauth_destination
# ─── limits ───────────────────────────────────────────────
message_size_limit = 52428800
mailbox_size_limit = 0
smtpd_client_connection_rate_limit = 30
smtpd_client_message_rate_limit = 60
smtpd_error_sleep_time = 5s
smtpd_soft_error_limit = 3
smtpd_hard_error_limit = 5
# ─── delivery ─────────────────────────────────────────────
home_mailbox = Maildir/
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
# ─── content filter (Amavis) ─────────────────────────────
content_filter = smtp-amavis:[127.0.0.1]:10024
# ─── other hardening ─────────────────────────────────────
disable_vrfy_command = yes
smtpd_banner = $myhostname ESMTP
strict_rfc821_envelopes = yes
Submission Port (587)
Edit /etc/postfix/master.cf. Uncomment and configure the submission service for authenticated clients:
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_auth_only=yes
-o smtpd_reject_unlisted_recipient=no
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o content_filter=smtp-amavis:[127.0.0.1]:10024
This ensures authenticated users send through port 587 with mandatory TLS.
Step 3: Install Dovecot
Dovecot provides IMAP access and handles SASL authentication for Postfix.
sudo apt install -y dovecot-core dovecot-imapd dovecot-lmtpd
Configure Authentication
Edit /etc/dovecot/conf.d/10-auth.conf:
disable_plaintext_auth = yes
auth_mechanisms = plain login
Edit /etc/dovecot/conf.d/10-master.conf to expose the auth socket to Postfix:
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
}
Configure Mail Location
Edit /etc/dovecot/conf.d/10-mail.conf:
mail_location = maildir:~/Maildir
Configure TLS
Edit /etc/dovecot/conf.d/10-ssl.conf:
ssl = required
ssl_cert = </etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_key = </etc/letsencrypt/live/mail.example.com/privkey.pem
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes
Restart Dovecot:
sudo systemctl restart dovecot
sudo systemctl enable dovecot
Step 4: Obtain a TLS Certificate
sudo apt install -y certbot
sudo certbot certonly --standalone -d mail.example.com
Stop any service on port 80 first, or use the webroot method if Apache is running. Set up automatic renewal:
sudo systemctl enable certbot.timer
Create a renewal hook to reload both services. Write /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh:
#!/bin/bash
systemctl reload postfix
systemctl reload dovecot
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh
Step 5: Install ClamAV
sudo apt install -y clamav clamav-daemon
ClamAV's signature updater (freshclam) runs as a service. Let it complete its first update:
sudo systemctl stop clamav-freshclam
sudo freshclam
sudo systemctl start clamav-freshclam
sudo systemctl enable clamav-freshclam
The first freshclam run downloads the full virus database. This takes a few minutes.
Start the ClamAV daemon:
sudo systemctl start clamav-daemon
sudo systemctl enable clamav-daemon
Configure ClamAV
Edit /etc/clamav/clamd.conf. Key settings:
LogFile /var/log/clamav/clamav.log
LogTime yes
LogSyslog yes
MaxFileSize 50M
MaxScanSize 150M
StreamMaxLength 50M
The MaxFileSize should match or exceed your Postfix message_size_limit.
Verify ClamAV Is Working
sudo clamdscan /usr/share/doc/clamav/examples/
If it reports OK or No virus found, the daemon is running correctly.
Test with the EICAR test string:
echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > /tmp/eicar.txt
sudo clamdscan /tmp/eicar.txt
rm /tmp/eicar.txt
ClamAV should detect this as Eicar-Signature.
Step 6: Install SpamAssassin
sudo apt install -y spamassassin spamc
Enable and start the SpamAssassin daemon:
sudo systemctl enable spamassassin
sudo systemctl start spamassassin
Configure SpamAssassin
Edit /etc/spamassassin/local.cf:
# ─── scoring ──────────────────────────────────────────────
required_score 5.0
report_safe 0
rewrite_header Subject [SPAM]
# ─── Bayes ────────────────────────────────────────────────
use_bayes 1
bayes_auto_learn 1
bayes_auto_learn_threshold_nonspam 0.1
bayes_auto_learn_threshold_spam 12.0
# ─── network tests ───────────────────────────────────────
skip_rbl_checks 0
use_razor2 0
use_pyzor 0
# ─── trusted networks ────────────────────────────────────
trusted_networks 127.0.0.0/8
internal_networks 127.0.0.0/8
# ─── shortcircuit ─────────────────────────────────────────
ifplugin Mail::SpamAssassin::Plugin::Shortcircuit
shortcircuit USER_IN_WHITELIST on
shortcircuit USER_IN_DEF_WHITELIST on
shortcircuit USER_IN_ALL_SPAM_TO on
shortcircuit SUBJECT_IN_WHITELIST on
endif
- required_score — messages scoring 5.0 or above are marked as spam. Lower it to 4.0 for more aggressive filtering once you have tuned the system.
- report_safe 0 — modifies the original message headers rather than wrapping it in an attachment. This is what most users expect.
- rewrite_header — prepends
[SPAM]to the subject line of spam. Users can filter on this in their mail client. - bayes_auto_learn — SpamAssassin automatically learns from messages it classifies with high confidence. This improves accuracy over time.
Update SpamAssassin Rules
sudo sa-update
sudo systemctl restart spamassassin
Schedule automatic rule updates:
sudo crontab -e
Add:
30 3 * * * /usr/bin/sa-update && systemctl restart spamassassin
Step 7: Install and Configure Amavis
Amavis is the content-filter daemon that ties Postfix, ClamAV and SpamAssassin together.
sudo apt install -y amavisd-new
Enable Virus and Spam Checking
Edit /etc/amavis/conf.d/15-content_filter_mode:
@bypass_virus_checks_maps = (
\%bypass_virus_checks, \@bypass_virus_checks_acl, \$bypass_virus_checks_re);
@bypass_spam_checks_maps = (
\%bypass_spam_checks, \@bypass_spam_checks_acl, \$bypass_spam_checks_re);
Uncomment both blocks. By default they are commented out, which disables scanning.
Configure Amavis
Edit /etc/amavis/conf.d/50-user:
use strict;
$max_servers = 2;
$sa_tag_level_deflt = -9999; # always add spam headers
$sa_tag2_level_deflt = 5.0; # add 'spam detected' header at this level
$sa_kill_level_deflt = 15.0; # quarantine at this level
$sa_dsn_cutoff_level = 20; # never send DSN above this
$virus_admin = "postmaster\@example.com";
$spam_admin = "postmaster\@example.com";
$final_virus_destiny = D_DISCARD;
$final_spam_destiny = D_PASS;
$final_banned_destiny = D_BOUNCE;
$final_bad_header_destiny = D_PASS;
1;
- $sa_tag_level_deflt = -9999 — this ensures SpamAssassin headers (X-Spam-Status, X-Spam-Score) are added to every message, not just high-scoring ones. Users and downstream filters need these headers.
- $sa_tag2_level_deflt = 5.0 — matches SpamAssassin's
required_score. Messages at or above this get theX-Spam-Flag: YESheader. - $sa_kill_level_deflt = 15.0 — quarantines messages scoring 15 or above. These are almost certainly spam. Set this conservatively to avoid losing legitimate mail.
- $final_virus_destiny = D_DISCARD — silently drops virus-laden messages. Never bounce them — the sender address is almost always forged.
- $final_spam_destiny = D_PASS — delivers spam with modified headers rather than quarantining it. Let users sort it themselves until the system is tuned.
Add the ClamAV User to the Amavis Group
Amavis and ClamAV need to share files:
sudo usermod -aG amavis clamav
sudo usermod -aG clamav amavis
Restart ClamAV to pick up the group change:
sudo systemctl restart clamav-daemon
Step 8: Wire Postfix to Amavis
The content filter line is already in main.cf from Step 2. Now add the Amavis service to Postfix's master.cf.
Edit /etc/postfix/master.cf and add at the end:
# ─── Amavis content filter ───────────────────────────────
smtp-amavis unix - - y - 2 smtp
-o smtp_data_done_timeout=1200
-o smtp_send_xforward_command=yes
-o disable_dns_lookups=yes
-o max_use=20
# ─── Amavis re-injection ─────────────────────────────────
127.0.0.1:10025 inet n - y - - smtpd
-o content_filter=
-o smtpd_delay_reject=no
-o smtpd_client_restrictions=permit_mynetworks,reject
-o smtpd_helo_restrictions=
-o smtpd_sender_restrictions=
-o smtpd_recipient_restrictions=permit_mynetworks,reject
-o smtpd_data_restrictions=reject_unauth_pipelining
-o smtpd_end_of_data_restrictions=
-o smtpd_restriction_classes=
-o mynetworks=127.0.0.0/8
-o smtpd_error_sleep_time=0
-o smtpd_soft_error_limit=1001
-o smtpd_hard_error_limit=1000
-o smtpd_client_connection_count_limit=0
-o smtpd_client_connection_rate_limit=0
-o local_header_rewrite_clients=
-o receive_override_options=no_header_body_checks,no_unknown_recipient_checks,no_milters
The flow: Postfix receives mail on port 25, passes it to Amavis on port 10024, Amavis scans it and re-injects clean mail back to Postfix on port 10025 with content_filter= cleared so it does not loop.
Step 9: Start Everything
sudo systemctl restart clamav-daemon
sudo systemctl restart spamassassin
sudo systemctl restart amavis
sudo systemctl restart postfix
sudo systemctl restart dovecot
Check for errors:
sudo journalctl -u amavis --no-pager -n 50
sudo journalctl -u postfix --no-pager -n 50
Verify Amavis is listening:
ss -tlnp | grep -E '1002[45]'
You should see Amavis on 10024 and Postfix on 10025.
Step 10: DKIM Signing
DKIM signs outbound mail so receiving servers can verify it came from your domain. Install OpenDKIM:
sudo apt install -y opendkim opendkim-tools
Generate Keys
sudo mkdir -p /etc/opendkim/keys/example.com
sudo opendkim-genkey -b 2048 -d example.com -D /etc/opendkim/keys/example.com -s mail -v
sudo chown -R opendkim:opendkim /etc/opendkim
Configure OpenDKIM
Edit /etc/opendkim.conf:
Syslog yes
SyslogSuccess yes
LogWhy yes
Canonicalization relaxed/simple
Mode sv
SubDomains no
AutoRestart yes
AutoRestartRate 10/1M
Background yes
DNSTimeout 5
SignatureAlgorithm rsa-sha256
KeyTable refile:/etc/opendkim/key.table
SigningTable refile:/etc/opendkim/signing.table
InternalHosts refile:/etc/opendkim/trusted.hosts
ExternalIgnoreList refile:/etc/opendkim/trusted.hosts
Create /etc/opendkim/signing.table:
*@example.com mail._domainkey.example.com
Create /etc/opendkim/key.table:
mail._domainkey.example.com example.com:mail:/etc/opendkim/keys/example.com/mail.private
Create /etc/opendkim/trusted.hosts:
127.0.0.1
localhost
mail.example.com
Add the DKIM DNS Record
Display the public key:
sudo cat /etc/opendkim/keys/example.com/mail.txt
Add the output as a TXT record for mail._domainkey.example.com in your DNS.
Connect OpenDKIM to Postfix
Edit /etc/default/opendkim and set the socket:
SOCKET="inet:8891@localhost"
Add to /etc/postfix/main.cf:
# ─── DKIM ─────────────────────────────────────────────────
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:localhost:8891
non_smtpd_milters = $smtpd_milters
Restart both:
sudo systemctl restart opendkim
sudo systemctl restart postfix
Step 11: DMARC Record
Add a DMARC DNS record:
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; ruf=mailto:dmarc@example.com; fo=1; adkim=s; aspf=s"
Start with p=none if you are not yet confident in your SPF and DKIM alignment. Move to p=quarantine once reporting confirms legitimate mail passes, then to p=reject.
Step 12: Create a Mail User
sudo adduser --disabled-login --gecos "Mail User" mailuser
sudo passwd mailuser
Create the Maildir:
sudo -u mailuser mkdir -p /home/mailuser/Maildir/{cur,new,tmp}
Test delivery:
echo "Test message" | mail -s "Test" mailuser@example.com
ls /home/mailuser/Maildir/new/
If a file appears, local delivery is working.
Step 13: Testing the Full Pipeline
Test Inbound Spam Filtering
Send yourself the GTUBE test string (the spam equivalent of EICAR):
echo "Subject: Test spam
XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X" | sendmail mailuser@example.com
Check the delivered message headers:
cat /home/mailuser/Maildir/new/*
Look for X-Spam-Flag: YES and X-Spam-Status: Yes, score= above your threshold.
Test Virus Scanning
Send the EICAR test file as an attachment and check that Amavis discards it:
sudo tail -f /var/log/mail.log
In another terminal:
echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' | mail -s "Virus test" -A /dev/stdin mailuser@example.com
The mail log should show Amavis intercepting and discarding the message as INFECTED.
Test DKIM
Send a message to an external address and check the headers. Gmail shows dkim=pass in the Authentication-Results header. Or use:
echo "DKIM test" | mail -s "DKIM check" check-auth@verifier.port25.com
The bounce-back report shows your SPF, DKIM and DMARC results.
Step 14: Hardening Checklist
Postfix
disable_vrfy_command = yes— prevents address enumerationsmtpd_bannershows only the hostname and ESMTP — no software versionstrict_rfc821_envelopes = yes— rejects malformed envelope commands- Rate limits on client connections and message rates
- RBL checks against Spamhaus and SpamCop
TLS
- Minimum TLS 1.2 for both inbound and outbound
- SASL authentication only permitted over TLS (
smtpd_tls_auth_only = yes) - Let's Encrypt certificate with automatic renewal
ClamAV
- Signature updates via
freshclamrunning as a systemd service - Virus-laden messages silently discarded, never bounced
SpamAssassin
- Bayes auto-learning enabled for self-improving accuracy
- Rule updates scheduled via cron
- Conservative thresholds that pass spam with headers rather than quarantining
System
- Firewall allows only ports 25 (SMTP), 587 (submission) and 993 (IMAPS)
- No open relay —
smtpd_relay_restrictionsrequires authentication for non-local destinations - PTR record matches the HELO hostname
sudo ufw allow 25/tcp
sudo ufw allow 587/tcp
sudo ufw allow 993/tcp
sudo ufw enable
Step 15: Log Monitoring
Postfix logs to /var/log/mail.log. Watch for:
- Rejected connections (relay attempts, RBL hits)
- Amavis virus detections
- Delivery failures
Set up log rotation if not already present:
/var/log/mail.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
postrotate
/usr/lib/rsyslog/rsyslog-rotate
endscript
}
For ongoing monitoring, consider piping Postfix logs into a tool like pflogsumm for daily summaries:
sudo apt install -y pflogsumm
Schedule a daily summary:
0 6 * * * /usr/sbin/pflogsumm -d yesterday /var/log/mail.log | mail -s "Mail stats $(date -d yesterday +\%F)" postmaster@example.com
Summary
The stack is Postfix for SMTP, Dovecot for IMAP and authentication, Amavis as the content filter bridge, ClamAV for virus scanning, SpamAssassin for spam classification, and OpenDKIM for outbound signing. Every message passes through ClamAV and SpamAssassin before delivery. Virus mail is silently discarded. Spam is tagged and delivered so users can train the Bayesian filter by moving misclassified messages. TLS is enforced for authentication, DKIM signs every outbound message, and DMARC tells receiving servers what to do with failures. The weak point, as with every mail server, is ongoing maintenance — keep signatures updated, watch the logs, and tune SpamAssassin's thresholds as the Bayes database matures.