How to Install Postfix + Dovecot on Ubuntu 24.04 VPS: Manual Self-Hosted Mail
Running your own mail server teaches you more about the internet than almost any other single project. SMTP, IMAP, TLS, DNS, DKIM, SPF, DMARC, reverse DNS, greylisting, content filtering -- they all intersect the moment you try to deliver a message from your VPS to a Gmail inbox. This guide walks you through a manual installation of Postfix (SMTP) and Dovecot (IMAP/POP3) on an Ubuntu 24.04 VPS, backed by MariaDB for virtual users, OpenDKIM for message signing, Rspamd for spam filtering, and Let's Encrypt for TLS.
Want a managed mail stack instead? If you'd rather skip the manual build, Mailcow and Mailu both run well on our CloudCore Professional VPS. This guide is for operators who want to understand every moving part.
Table of Contents
Why Build a Mail Server Manually?
You can install Mailcow or Mailu in twenty minutes and get a working mail server with a web UI. That's the right choice for production if you don't enjoy debugging SMTP. But manual installation is worth doing at least once because it forces you to understand every component you'll later rely on:
- Deep understanding of the SMTP/IMAP stack -- You learn exactly how a message moves from a user's mail client through Postfix's
smtpd, into Dovecot's LMTP delivery, onto disk as a Maildir file, and back out through Dovecot'simap-loginwhen the user reads it. When something breaks in a managed stack, you know where to look. - Complete control over policy -- Custom rate limits, custom header rewrites, custom reject messages, custom routing per domain. Managed stacks expose a subset of Postfix's knobs through a UI; manual setup gives you all of them.
- Smaller attack surface -- A Mailcow install runs ~15 Docker containers. A manual stack is Postfix, Dovecot, OpenDKIM, Rspamd, MariaDB, and Nginx -- six processes you can actually reason about.
- No hidden cost in RAM -- Docker-based mail stacks frequently need 4-6 GB of RAM. A manual Postfix/Dovecot setup runs comfortably in under 1 GB, leaving room for other workloads on the same VPS.
- Full customization at every layer -- Want to route outbound mail through a smart host for reputation? Want to sign with multiple DKIM selectors per domain? Want to terminate TLS at HAProxy? All straightforward with a manual build.
- Portability -- Once you know the files, you can rebuild the server on any Linux distro in under an hour. Managed stacks tie you to their version lifecycle.
Manual vs. Managed Mail Stack
| Factor | Manual (this guide) | Mailcow / Mailu |
|---|---|---|
| Setup time | 45-60 minutes | 15-20 minutes |
| RAM footprint | ~800 MB idle | 3-6 GB idle |
| Webmail UI | Not included (add Roundcube) | Included |
| Admin UI | Edit config files | Web-based |
| Upgrade path | apt upgrade | Image tag bump |
| Customization ceiling | Unlimited | Limited to exposed options |
| Best for | Learning + custom policy | Fast production deploys |
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to your server
- A registered domain you control the DNS for (we'll use
example.comthroughout) - A fully qualified hostname for the mail server (we'll use
mail.example.com) - Reverse DNS (PTR) set on the VPS IP to match
mail.example.com-- this is done through your VPS provider's control panel, not DNS - Ports 25, 465, 587, 993, and 995 open on the firewall and not blocked by your provider (some providers block port 25 outbound by default -- open a ticket to request unblocking)
- At least 2 GB of RAM and 20 GB of disk space
Recommended Plan: CloudCore Professional>
For a single-domain mail server with Rspamd, ClamAV-free Rspamd statistical filtering, and moderate volume (up to ~50,000 messages/day), we recommend the CloudCore Professional plan:>
- 6 vCPU cores
- 12 GB RAM
- 100 GB NVMe SSD
- Unmetered bandwidth
- Clean IP reputation with rDNS configurable from the panel>
Mail servers are disk-bound on the Maildir side, so NVMe storage makes a measurable difference when users have large mailboxes or slow IMAP clients doing full sync.
Connect to your server via SSH:
ssh root@your-server-ipUpdate the system before you start:
sudo apt update && sudo apt upgrade -yIf the kernel was upgraded, reboot:
sudo rebootStep 1: Set the Hostname and FQDN
Every mail server needs a fully qualified hostname that matches its rDNS and its A record. Receiving mail servers (especially Gmail and Outlook) will reject mail from a host whose HELO name doesn't resolve correctly.
Set the hostname:
sudo hostnamectl set-hostname mail.example.comEdit /etc/hosts so the FQDN resolves locally:
sudo nano /etc/hostsAdd or modify the line for 127.0.1.1:
127.0.0.1 localhost
127.0.1.1 mail.example.com mailVerify the hostname is set correctly:
hostname -fExpected output:
mail.example.comCheck that the system's A record resolves back to your server's public IP:
dig +short mail.example.com AThis should return your VPS IP. If it doesn't, fix your DNS before proceeding.
Step 2: Configure DNS Records (MX, SPF, rDNS, DMARC)
Mail deliverability is 80 percent DNS. Get these records right and Gmail will accept your mail; get them wrong and every message hits spam or bounces.
Create the following records at your DNS provider (Cloudflare, Route 53, your registrar's panel, etc.):
A record for the mail host:
| Type | Name | Value |
|---|---|---|
| A | mail.example.com | YOUR_VPS_IP |
| Type | Name | Value | Priority |
|---|---|---|---|
| MX | example.com | mail.example.com. | 10 |
| Type | Name | Value |
|---|---|---|
| TXT | example.com | "v=spf1 mx ~all" |
| Type | Name | Value |
|---|---|---|
| TXT | _dmarc.example.com | "v=DMARC1; p=none; rua=mailto:[email protected]; pct=100; adkim=s; aspf=s" |
p=none and move to p=quarantine then p=reject once you've reviewed aggregate reports for a week or two.DKIM placeholder -- we'll generate the actual value in Step 9. Leave room for a TXT at mail._domainkey.example.com.
Reverse DNS (PTR) -- go to your VPS provider's control panel and set the PTR record for your server's IP to mail.example.com. This is critical. Many receiving servers reject mail when the sender's IP PTR doesn't match its HELO hostname.
Verify everything once DNS has propagated:
# A record
dig +short mail.example.com AMX record
dig +short example.com MXSPF
dig +short example.com TXTDMARC
dig +short _dmarc.example.com TXTrDNS (replace with your IP)
dig +short -x 203.0.113.10The rDNS lookup should return mail.example.com.. If it doesn't, open a ticket with your VPS provider or set it in their panel before continuing.
For a deeper treatment of each record, see our DNS configuration guide.
Step 3: Install the Mail Server Packages
Install everything in one pass. Postfix's interactive prompt will appear during install -- when it asks for a configuration type, pick "Internet Site" and set the mail name to your FQDN (mail.example.com).
sudo DEBIAN_FRONTEND=noninteractive apt install -y \
postfix postfix-mysql \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
mariadb-server \
opendkim opendkim-tools \
rspamd \
certbotPackage roles:
- postfix + postfix-mysql -- SMTP server plus the MySQL map lookup plugin.
- dovecot-core / imapd / pop3d / lmtpd / mysql -- IMAP and POP3 for mail clients, LMTP for local delivery from Postfix, and MySQL support for the user database.
- mariadb-server -- relational store for virtual domains, users, and aliases.
- opendkim + opendkim-tools -- milter that signs outbound mail with DKIM and verifies inbound.
- rspamd -- spam filter with built-in statistical, Bayesian, and network-based rules.
- certbot -- Let's Encrypt client for TLS certificates.
sudo mysql_secure_installationAnswer: set a root password, remove anonymous users, disallow remote root login, remove the test database, reload privilege tables. See our MariaDB hardening guide for a deeper walkthrough.
Enable and start MariaDB if it isn't already:
sudo systemctl enable --now mariadbStep 4: Create the MariaDB Schema for Virtual Users
Postfix and Dovecot both need to look up domains, users, and aliases. We'll store them in a small MariaDB schema that both services query read-only.
Connect to MariaDB:
sudo mariadbCreate the database, user, and tables:
CREATE DATABASE mailserver CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;CREATE USER 'mailuser'@'127.0.0.1' IDENTIFIED BY 'CHANGE_ME_STRONG_PASSWORD'; GRANT SELECT ON mailserver.* TO 'mailuser'@'127.0.0.1'; FLUSH PRIVILEGES;
USE mailserver;
CREATE TABLE virtual_domains ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(191) NOT NULL UNIQUE, PRIMARY KEY (id) ) ENGINE=InnoDB;
CREATE TABLE virtual_users ( id INT NOT NULL AUTO_INCREMENT, domain_id INT NOT NULL, email VARCHAR(191) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, PRIMARY KEY (id), FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE ) ENGINE=InnoDB;
CREATE TABLE virtual_aliases ( id INT NOT NULL AUTO_INCREMENT, domain_id INT NOT NULL, source VARCHAR(191) NOT NULL, destination VARCHAR(191) NOT NULL, PRIMARY KEY (id), FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE ) ENGINE=InnoDB;
Seed one domain, one user, and one alias. Generate a SHA256-CRYPT hash for the password using Dovecot's helper:
doveadm pw -s SHA256-CRYPT -p 'YourMailboxPassword'Expected output (yours will differ):
{SHA256-CRYPT}$5$rounds=5000$abc123...$xyz789...Copy that full string (including the {SHA256-CRYPT} prefix) and insert it:
INSERT INTO virtual_domains (name) VALUES ('example.com');INSERT INTO virtual_users (domain_id, email, password) VALUES (1, '[email protected]', '{SHA256-CRYPT}$5$rounds=5000$abc123...$xyz789...');
INSERT INTO virtual_aliases (domain_id, source, destination) VALUES (1, '[email protected]', '[email protected]');
EXIT;
The source is the address that receives mail; the destination is where it gets forwarded. Aliases are checked before user mailboxes, so [email protected] will resolve to [email protected] at delivery time.
Step 5: Obtain a Let's Encrypt TLS Certificate
Postfix and Dovecot both need a TLS certificate for encrypted client connections (submission on 587, SMTPS on 465, IMAPS on 993). Let's Encrypt issues free, trusted certificates that renew automatically.
Stop any service binding port 80 temporarily (if Nginx is installed):
sudo systemctl stop nginx 2>/dev/null || trueRequest the certificate using the standalone challenge:
sudo certbot certonly --standalone \
-d mail.example.com \
--agree-tos -m [email protected] --no-eff-emailExpected output:
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/mail.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/mail.example.com/privkey.pemCertbot installs a systemd timer (certbot.timer) that renews certificates automatically. After each renewal, Postfix and Dovecot need to be restarted to load the new certificate. Create a renewal hook:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/mail-reload.sh > /dev/null <<'EOF' #!/bin/bash systemctl reload postfix systemctl reload dovecot EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail-reload.sh
For more on certificate management, see our Let's Encrypt setup guide.
Step 6: Configure Postfix (main.cf and master.cf)
This is the heart of the setup. Postfix's configuration lives in two files: /etc/postfix/main.cf (global settings) and /etc/postfix/master.cf (service daemons).
Back up the original:
sudo cp /etc/postfix/main.cf /etc/postfix/main.cf.bakOpen main.cf:
sudo nano /etc/postfix/main.cfReplace its contents with:
# Basic identity
myhostname = mail.example.com
mydomain = example.com
myorigin = $mydomain
smtpd_banner = $myhostname ESMTP
biff = no
append_dot_mydomain = no
readme_directory = no
compatibility_level = 3.6Network
inet_interfaces = all
inet_protocols = ipv4
mydestination = localhost
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128Size and limits
message_size_limit = 52428800
mailbox_size_limit = 0
recipient_delimiter = +TLS -- inbound (smtpd)
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_loglevel = 1
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1TLS -- outbound (smtp)
smtp_tls_security_level = may
smtp_tls_loglevel = 1
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scacheSASL (authenticate submission clients via Dovecot)
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
smtpd_sasl_security_options = noanonymous
smtpd_sasl_local_domain = $myhostname
broken_sasl_auth_clients = yesRecipient restrictions
smtpd_recipient_restrictions =
permit_mynetworks,
permit_sasl_authenticated,
reject_unauth_destination,
reject_invalid_helo_hostname,
reject_non_fqdn_helo_hostname,
reject_unknown_recipient_domain,
reject_rbl_client zen.spamhaus.orgVirtual delivery via Dovecot LMTP
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = mysql:/etc/postfix/mysql-virtual-mailbox-domains.cf
virtual_mailbox_maps = mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf
virtual_alias_maps = mysql:/etc/postfix/mysql-virtual-alias-maps.cf,
mysql:/etc/postfix/mysql-virtual-email2email.cfMilters (OpenDKIM + Rspamd -- we'll wire these in later steps)
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:127.0.0.1:8891, inet:127.0.0.1:11332
non_smtpd_milters = $smtpd_miltersNow enable submission (587) and smtps (465) in master.cf. These are the authenticated ports used by mail clients like Thunderbird and iPhone Mail.
sudo nano /etc/postfix/master.cfFind the submission and smtps entries (they are commented out by default) and replace them with:
submission inet n - y - - smtpd -o syslog_name=postfix/submission -o smtpd_tls_security_level=encrypt -o smtpd_sasl_auth_enable=yes -o smtpd_reject_unlisted_recipient=no -o smtpd_client_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o milter_macro_daemon_name=ORIGINATING
smtps inet n - y - - smtpd -o syslog_name=postfix/smtps -o smtpd_tls_wrappermode=yes -o smtpd_sasl_auth_enable=yes -o smtpd_reject_unlisted_recipient=no -o smtpd_client_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o milter_macro_daemon_name=ORIGINATING
The difference: 587 (submission) starts plaintext and upgrades to TLS via STARTTLS. 465 (smtps) is TLS from the first byte. Both require authentication. Port 25 remains unauthenticated for server-to-server transfer, but it only accepts mail destined for your own domains.
Step 7: Wire Up the Postfix to MySQL Lookup Files
Postfix needs four small config files that tell it how to query MariaDB. Create them in /etc/postfix/:
/etc/postfix/mysql-virtual-mailbox-domains.cf -- does this domain exist?
user = mailuser
password = CHANGE_ME_STRONG_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT 1 FROM virtual_domains WHERE name='%s'/etc/postfix/mysql-virtual-mailbox-maps.cf -- does this email address have a mailbox?
user = mailuser
password = CHANGE_ME_STRONG_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT 1 FROM virtual_users WHERE email='%s'/etc/postfix/mysql-virtual-alias-maps.cf -- does this address alias to another?
user = mailuser
password = CHANGE_ME_STRONG_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT destination FROM virtual_aliases WHERE source='%s'/etc/postfix/mysql-virtual-email2email.cf -- canonical self-mapping (needed so Postfix doesn't try to deliver to the original address after alias rewrite):
user = mailuser
password = CHANGE_ME_STRONG_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT email FROM virtual_users WHERE email='%s'Lock down permissions -- these files contain database credentials:
sudo chown root:postfix /etc/postfix/mysql-virtual-*.cf
sudo chmod 640 /etc/postfix/mysql-virtual-*.cfTest each lookup with postmap:
sudo postmap -q "example.com" mysql:/etc/postfix/mysql-virtual-mailbox-domains.cf
sudo postmap -q "[email protected]" mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf
sudo postmap -q "[email protected]" mysql:/etc/postfix/mysql-virtual-alias-maps.cfThe first two should return 1. The third should return [email protected]. If any return empty, re-check the SQL rows and the password in the config file.
Reload Postfix to pick up all changes:
sudo postfix check
sudo systemctl reload postfixpostfix check parses configuration and reports syntax errors -- always run it before reloading.
Step 8: Configure Dovecot (Mail, Auth, Master, SSL)
Dovecot's configuration is split across /etc/dovecot/dovecot.conf and /etc/dovecot/conf.d/*.conf. We'll edit five files.
Create the vmail user for mailbox ownership
sudo groupadd -g 5000 vmail
sudo useradd -g vmail -u 5000 vmail -d /var/mail
sudo mkdir -p /var/mail/vhosts/example.com
sudo chown -R vmail:vmail /var/mailAll mailboxes will be owned by this single system user. Per-mailbox ACLs come from the SQL lookup.
/etc/dovecot/conf.d/10-mail.conf
Set Maildir format and location:
sudo nano /etc/dovecot/conf.d/10-mail.confModify these lines (the file has extensive comments -- find and change just these directives):
mail_location = maildir:/var/mail/vhosts/%d/%n
mail_privileged_group = mail
mail_uid = vmail
mail_gid = vmail%d is the domain part, %n is the local part. [email protected] will land in /var/mail/vhosts/example.com/yossef/.
/etc/dovecot/conf.d/10-auth.conf
Enable plaintext auth over TLS and the SQL backend:
disable_plaintext_auth = yes auth_mechanisms = plain login
!include auth-sql.conf.ext
Make sure the !include auth-system.conf.ext line is commented out with a #.
/etc/dovecot/conf.d/auth-sql.conf.ext
passdb { driver = sql args = /etc/dovecot/dovecot-sql.conf.ext }
userdb { driver = static args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n }
/etc/dovecot/dovecot-sql.conf.ext
This file holds the DB credentials (like the Postfix MySQL configs):
driver = mysql
connect = host=127.0.0.1 dbname=mailserver user=mailuser password=CHANGE_ME_STRONG_PASSWORD
default_pass_scheme = SHA256-CRYPT
password_query = SELECT email as user, password FROM virtual_users WHERE email='%u';Lock it down:
sudo chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext
sudo chmod 640 /etc/dovecot/dovecot-sql.conf.ext/etc/dovecot/conf.d/10-master.conf
Expose two Unix sockets: one for Postfix's LMTP delivery, one for Postfix's SASL auth.
Find the service lmtp block and replace with:
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}Find the service auth block and add the Postfix auth listener:
service auth { unix_listener /var/spool/postfix/private/auth { mode = 0660 user = postfix group = postfix } unix_listener auth-userdb { mode = 0600 user = vmail } user = dovecot }
service auth-worker { user = vmail }
/etc/dovecot/conf.d/10-ssl.conf
Point Dovecot at the Let's Encrypt certificate:
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_cipher_list = ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:!aNULL:!eNULL:!MD5:!EXPORT:!DSS:!SEED:!3DES:!RC4
ssl_prefer_server_ciphers = yesRestart Dovecot and confirm it's listening:
sudo systemctl restart dovecot
sudo ss -tlnp | grep dovecotExpected output:
LISTEN 0 100 0.0.0.0:993 ... users:(("dovecot",...))
LISTEN 0 100 0.0.0.0:995 ...
LISTEN 0 100 0.0.0.0:143 ...
LISTEN 0 100 0.0.0.0:110 ...Ports 993 (IMAPS) and 995 (POP3S) are the ones your clients will connect on.
Step 9: Install and Configure OpenDKIM
DKIM signs every outbound message with a cryptographic signature that the receiver verifies against a public key published in your DNS. Without DKIM, Gmail will treat your mail with suspicion -- often marking it as spam even with valid SPF.
Create the directory structure and key:
sudo mkdir -p /etc/opendkim/keys/example.com
cd /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
sudo chmod 600 /etc/opendkim/keys/example.com/mail.privateThis creates two files:
mail.private-- the private key (stays on the server)mail.txt-- the DNS TXT record you need to publish
sudo cat /etc/opendkim/keys/example.com/mail.txtYou'll see something like:
mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."
"...QIDAQAB" )Concatenate the quoted strings into a single line and publish as a TXT record at mail._domainkey.example.com in your DNS panel:
| Type | Name | Value |
|---|---|---|
| TXT | mail._domainkey.example.com | v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...QIDAQAB |
/etc/opendkim.conf:Syslog yes UMask 007 Mode sv Canonicalization relaxed/simple SubDomains no AutoRestart yes AutoRestartRate 10/1h Socket inet:[email protected] PidFile /run/opendkim/opendkim.pid UserID opendkim:opendkim
KeyTable /etc/opendkim/key.table SigningTable refile:/etc/opendkim/signing.table ExternalIgnoreList /etc/opendkim/trusted.hosts InternalHosts /etc/opendkim/trusted.hosts
Create the lookup tables:
sudo tee /etc/opendkim/key.table > /dev/null <<'EOF' mail._domainkey.example.com example.com:mail:/etc/opendkim/keys/example.com/mail.private EOFsudo tee /etc/opendkim/signing.table > /dev/null <<'EOF' *@example.com mail._domainkey.example.com EOF
sudo tee /etc/opendkim/trusted.hosts > /dev/null <<'EOF' 127.0.0.1 localhost mail.example.com example.com EOF
sudo chown -R opendkim:opendkim /etc/opendkim
Start the service:
sudo systemctl enable --now opendkim
sudo systemctl status opendkimPostfix already has smtpd_milters = inet:127.0.0.1:8891, ... configured from Step 6. Verify the signature is being added after you restart Postfix (see Step 11).
Test your DKIM record once DNS propagates:
opendkim-testkey -d example.com -s mail -vvvExpected output:
opendkim-testkey: key OKStep 10: Install Rspamd and Hook It Into Postfix
Rspamd is the modern replacement for SpamAssassin. It runs as a daemon, uses modern statistical classifiers, and integrates with Postfix via the milter protocol on port 11332.
Rspamd is already installed from Step 3. Configure it to act as a milter:
sudo tee /etc/rspamd/local.d/milter_headers.conf > /dev/null <<'EOF' use = ["authentication-results", "x-spam-status", "x-spam-score"]; EOFsudo tee /etc/rspamd/local.d/worker-proxy.inc > /dev/null <<'EOF' milter = yes; timeout = 120s; upstream "local" { default = yes; self_scan = yes; } EOF
sudo tee /etc/rspamd/local.d/actions.conf > /dev/null <<'EOF' reject = 15; add_header = 6; greylist = 4; EOF
Set a password for the Rspamd web UI (accessible at http://localhost:11334):
rspamadm pwCopy the generated hash and put it in /etc/rspamd/local.d/worker-controller.inc:
password = "$2$abc123...";Restart Rspamd:
sudo systemctl restart rspamdPostfix's smtpd_milters from Step 6 already includes inet:127.0.0.1:11332, so Rspamd is already wired into the mail flow. You can confirm by checking the headers of a test message -- they'll include X-Spam-Status and Authentication-Results.
Access the Rspamd dashboard by tunneling through SSH:
ssh -L 11334:localhost:11334 [email protected]Then open http://localhost:11334 in your browser.
Step 11: Test Send and Receive
Now the moment of truth. Restart everything:
sudo systemctl restart postfix dovecot opendkim rspamdCheck the logs in one terminal:
sudo tail -f /var/log/mail.logTest 1: Receive a message from Gmail
From your personal Gmail account, send a message to [email protected]. Watch mail.log. You should see lines like:
postfix/smtpd[...]: connect from mail-sor-f41.google.com[209.85.220.41]
postfix/smtpd[...]: ... client=mail-sor-f41.google.com[209.85.220.41]
postfix/cleanup[...]: ... message-id=<...>
postfix/qmgr[...]: ... from=<[email protected]>, size=..., nrcpt=1
dovecot: lmtp(...): ... msgid=<...>: sieve: stored mail into mailbox 'INBOX'
postfix/lmtp[...]: ... status=sentVerify the message landed:
sudo ls -la /var/mail/vhosts/example.com/yossef/new/You should see a new file with a Maildir-format name.
Test 2: IMAP login from a mail client
Configure Thunderbird, iPhone Mail, or Apple Mail:
- IMAP server:
mail.example.com, port 993, SSL/TLS - SMTP server:
mail.example.com, port 587, STARTTLS - Username:
[email protected](full email address) - Password: the one you hashed into the database
Test 3: Send outbound and check deliverability
Send a test message from your mail client to mail-tester.com. Visit the reported address, send mail to it, then go back to the mail-tester page to see your score.
A correctly configured server on a clean IP scores 9.5-10/10. Common deductions:
- SPF -- missing record on the sending domain.
- DKIM -- signature not present or doesn't validate (DNS not propagated, wrong selector).
- DMARC -- no record at
_dmarc.example.com. - rDNS -- PTR doesn't match HELO.
- IP reputation -- VPS IP previously used for spam. Check against MXToolbox blacklists.
Test 4: Validate DNS from the outside
Run a final full check:
# Message headers from any delivered mail should show:
Authentication-Results: spf=pass; dkim=pass; dmarc=pass
From outside the server, run:
dig +short MX example.com
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short TXT mail._domainkey.example.com
dig +short -x YOUR_VPS_IPAll five should return their expected values. If DKIM doesn't show, DNS hasn't propagated yet -- wait up to an hour.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
SASL authentication failed in client | Wrong username/password, auth socket not wired correctly | Verify smtpd_sasl_path = private/auth in main.cf. Check /var/spool/postfix/private/auth exists and is owned by postfix:postfix. Regenerate the password hash with doveadm pw -s SHA256-CRYPT and update the DB. |
DKIM signature verification failed (bad signature) | DNS TXT record doesn't exactly match the key, or the key was regenerated after publishing | sudo opendkim-testkey -d example.com -s mail -vvv. If it reports "record not found," DNS hasn't propagated. If "key mismatch," re-paste the TXT value -- watch for line breaks and missing characters. |
TLS handshake failed on port 587 | Certificate path wrong or file permissions block the postfix user | Run sudo postfix check. Confirm /etc/letsencrypt/live/mail.example.com/ exists. Ensure the symlink targets in /etc/letsencrypt/archive/ are readable by root -- Postfix reads them as root at startup. |
Mail rejected with Recipient address rejected: User unknown in virtual mailbox table | SQL lookup isn't finding the user | sudo postmap -q "[email protected]" mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf. If empty, the row isn't in virtual_users or the mailuser DB password doesn't match the one in the Postfix config. |
connect to 127.0.0.1:8891: Connection refused in logs | OpenDKIM not running or not listening on the expected port | sudo systemctl status opendkim. Check Socket inet:[email protected] is set in /etc/opendkim.conf. Restart opendkim. |
| Gmail marks all mail as spam | Missing DKIM, bad rDNS, or IP on blocklist | Run the message through mail-tester.com. Check the IP at mxtoolbox.com/blacklists.aspx. If blocklisted, request delisting or move to a clean IP. |
| Port 25 connections time out from outside | VPS provider blocks outbound port 25 | Open a support ticket with your VPS provider asking for port 25 unblocking. Most providers allow it after a brief review. |
Relay access denied when sending from a mail client | Client isn't authenticating, or smtpd_relay_restrictions blocks it | Confirm the client is using port 587 or 465 with auth enabled. Check master.cf submission block includes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject. |
| IMAP shows no messages despite successful delivery | Wrong mail_location, or Maildir permissions wrong | sudo ls -la /var/mail/vhosts/example.com/yossef/new/. Should be owned by vmail:vmail. Fix with sudo chown -R vmail:vmail /var/mail/vhosts. |
| Rspamd rejects legitimate mail | Aggressive default thresholds | Lower the reject action score in /etc/rspamd/local.d/actions.conf. Train the Bayesian classifier: rspamc learn_ham /path/to/known-good.eml and rspamc learn_spam /path/to/known-spam.eml. |
Essential Log Commands
# Live mail log (most useful)
sudo tail -f /var/log/mail.logPostfix queue -- pending and deferred messages
sudo postqueue -pFlush the queue (try to redeliver now)
sudo postqueue -fOpenDKIM logs
sudo journalctl -u opendkim -fRspamd logs
sudo journalctl -u rspamd -fDovecot logs
sudo journalctl -u dovecot -fFAQ
Do I really need DKIM, SPF, and DMARC all three?
Yes, if you want mail delivered. SPF tells receivers which IPs are allowed to send for your domain. DKIM cryptographically proves the message wasn't altered in transit and came from your server. DMARC tells receivers what to do when SPF or DKIM fail and lets you collect aggregate reports about who is sending mail as your domain. Gmail, Outlook, and Yahoo have moved to requiring all three for bulk senders -- without them, your mail goes to spam or gets rejected outright. For details on each, see the specs at datatracker.ietf.org (SPF), rfc6376 (DKIM), and rfc7489 (DMARC).
Why is my outbound mail blocked even with perfect DNS?
IP reputation. A brand-new VPS IP has no sending history; some receivers treat that as suspicious until you've warmed it up. Send small volumes (under 50 messages/day) for the first two weeks. Encourage recipients to reply -- replies are a strong positive signal. Monitor the IP on mxtoolbox.com/blacklists.aspx and request delisting from any blocklist that catches you. If the IP came from a provider known for abuse, your cheapest fix is migrating to a cleaner IP block -- our CloudCore plans ship with reputation-checked IPs.
Can I add a webmail interface?
Yes. The two most common choices are Roundcube (PHP, lightweight, widely packaged) and SnappyMail (PHP, modern UI, faster for large mailboxes). Install Roundcube with sudo apt install roundcube roundcube-mysql, point it at localhost:143 for IMAP and localhost:587 for SMTP, then expose it behind Nginx with TLS. The setup is straightforward and adds a browser-based inbox without touching the mail stack.
How do I add more domains?
Add rows to virtual_domains and virtual_users in MariaDB. You also need to generate a new DKIM key per domain (sudo opendkim-genkey -b 2048 -d newdomain.com -D /etc/opendkim/keys/newdomain.com -s mail), add an entry to /etc/opendkim/key.table and signing.table, and publish the new DKIM TXT record. Postfix doesn't need reconfiguring because the MySQL lookup handles routing automatically. Dovecot also needs no changes because mail_location uses the %d placeholder.
How does manual Postfix compare to Mailcow or Mailu?
Postfix + Dovecot (this guide) -- minimal, fast, deep customization. You maintain each component separately with apt. Best for learners and operators who want control.
Mailcow -- Docker-based stack with SOGo webmail, ClamAV, Rspamd, a polished admin UI, and push sync. Runs in ~15 containers and needs 4-6 GB of RAM. Best for running mail for multiple users without touching config files.
Mailu -- Docker-based but lighter than Mailcow, with Roundcube or SnappyMail webmail. ~8 containers, ~2 GB RAM. Middle ground between manual and Mailcow.
For a single-operator VPS, manual is perfectly sustainable. When you hit 3+ domains with 20+ users each, Mailcow's UI starts paying for itself.
Can I send transactional mail from my apps through this server?
Yes, but you'll usually get better deliverability from a dedicated relay (Amazon SES, Postmark, Mailgun) because they manage IP reputation at scale. If volume is low (under 1000 messages/day) and you maintain good list hygiene, a self-hosted Postfix is fine. Point your app at mail.example.com:587 with SASL credentials and you're done. For higher volumes, configure Postfix as a null-client relay that forwards through SES using the relayhost directive.
Next Steps
Now that Postfix and Dovecot are running, here are high-value follow-ups:
- Add Roundcube webmail -- Install
roundcubeand expose it behind Nginx with a Let's Encrypt cert. Your users get a browser-based inbox athttps://webmail.example.comwithout any client configuration. See our Nginx reverse proxy guide for the vhost setup.
- Harden the firewall with UFW -- Restrict inbound to ports 25, 465, 587, 993, 995, 443, 80, and 22 only. Our UFW configuration guide shows the exact rules and how to rate-limit SSH.
- Monitor with Uptime Kuma -- Deploy Uptime Kuma to check SMTP and IMAP uptime from outside your network. Configure alerts for port 25 outbound blocks and certificate expiry.
- Automate DMARC aggregate report parsing -- Tools like parsedmarc ingest the XML reports that receivers send to your
rua=address and visualize them in Grafana. You'll spot deliverability problems and spoofing attempts long before users complain.
- Train the Bayesian classifier -- Regularly feed Rspamd examples of known ham and known spam with
rspamc learn_hamandrspamc learn_spam. Accuracy improves dramatically after a few hundred samples.
- Read the official docs -- postfix.org/documentation.html and dovecot.org/documentation.html are dense but authoritative. The Postfix
BASIC_CONFIGURATION_READMEand Dovecotwiki2pages are the right places to go when you want to go beyond this guide.
Run Your Mail Server on a Clean-IP VPS>
Mail deliverability is half DNS and half IP reputation. Our CloudCore Professional plans come with reputation-checked IPs, panel-based reverse DNS, and full port 25 access out of the box.>
- 6 vCPU cores and 12 GB RAM -- enough for Postfix, Dovecot, Rspamd, and a webmail UI
- 100 GB NVMe SSD for fast Maildir access
- rDNS configurable from the control panel in two clicks
- Port 25 open by default -- no support ticket needed
- Clean IP blocks with no prior spam history>
Deploy Your Mail VPS Now -- Plans start at EUR 19.99/month.