/ Self-Hosted Mail Server — A to Z
dkim=pass spf=pass dmarc=pass
Ubuntu 24.04 LTS · Contabo VPS · Cloudflare DNS

Self-Hosted Mail Server
A to Z Guide

A complete, battle-tested guide to setting up SMTP + IMAP on a Linux VPS using Postfix, Dovecot, and OpenDKIM. Every command, every config file, every error we hit, and every fix that worked.

dkim=pass at Gmail spf=pass at Gmail dmarc=pass at Gmail Thunderbird send & receive ProtonMail verified
01

Before You Start — Honest Expectations

Setting up a mail server is deceptively complex. The installation itself takes a few hours; the surrounding work — DNS, IP reputation, deliverability — takes much longer to get right.

What actually makes it hard

  • DNS is unforgiving — SPF, DKIM, DMARC, PTR/rDNS, and MX must all align. Any one being wrong tanks deliverability.
  • Deliverability is a separate beast — Gmail, Outlook, and Yahoo are aggressive. Fresh IPs get soft-rejected for weeks. IP warm-up takes time.
  • Maintenance is real — cert renewals, log monitoring, spam filtering, blacklist tracking, brute-force protection.

When self-hosting makes sense

Use caseSelf-host?
Personal mailbox / hobby project✓ Yes
Internal/transactional mail for an app✓ Yes
Critical client-facing business email✗ No — use Migadu, Fastmail, Zoho
⚠ Business email warning
If you're reading this for a serious business deliverability use case, stop and reconsider. The ROI vs. a paid provider is bad.

Why this stack

Researched and confirmed in 2026:

  • Postfix — the most widely deployed MTA, dominates enterprise Linux deployments
  • Dovecot — the dominant IMAP server, high-performance, battle-tested
  • OpenDKIM — standard DKIM milter

Alternatives considered and rejected: Exim (more complex syntax, no advantage), Haraka (overkill, Node.js-based), Maddy (too immature), Mailcow/Mail-in-a-Box (heavier Docker stack, harder to debug).

02

Architecture Overview

┌─────────────────────────────────────────────────────┐ │ Internet │ └───────────┬──────────────────────────┬──────────────┘ │ │ Port 25 (SMTP in) Port 587 / 465 (Submit) │ │ ┌───────────▼──────────────────────────▼──────────────┐ │ Postfix (master) │ │ ┌───────────────────────────────────────────────┐ │ │ │ smtpd → OpenDKIM milter (inet:localhost:12301) │ │ │ │ │ │ │ │ Dovecot SASL auth socket (private/auth) │ │ │ │ │ │ │ │ virtual → /var/vmail/domain/user/Maildir │ │ │ └───────────────────────────────────────────────┘ │ └───────────────────────┬─────────────────────────────┘Port 993 (IMAPS)┌───────────────────────▼─────────────────────────────┐ │ Dovecot (IMAP) Reads /var/vmail/%d/%n/Maildir Auth via /etc/dovecot/users (passwd-file + static) └─────────────────────────────────────────────────────┘

Key design decisions

DecisionChoiceReasoning
Mailbox storageFlat files (Maildir)No DB backend needed; single-domain, simpler, zero dependencies
Auth backendpasswd-file + static userdbTiny user count, no LDAP/SQL complexity
OpenDKIM socketinet:12301@localhostUnix sockets fail — Postfix runs in chroot at /var/spool/postfix
TLS challengeDNS-01 via Cloudflare APIPorts 80/443 in use by Caddy; DNS-01 doesn't need them
IPv6Disabled (ipv4 only)IPv6 PTR not set → SPF fails on IPv6; simpler to disable
P1

VPS Readiness Audit

Phase 1 — Do Not Skip

A 10-minute audit prevents 4 hours of failed installs. Run this via Claude Code on your VPS before touching anything.

Audit prompt

prompt
You are a Linux sysadmin assistant. Audit this Ubuntu VPS and
determine if it's ready for a self-hosted email server (Postfix
SMTP + Dovecot IMAP). Do not install anything.

Network & IP: public IP, outbound port 25/587/465/993/143 test,
PTR/rDNS lookup.

DNS: hostname -f, /etc/hosts, check MX/SPF/DKIM/DMARC records.

IP Reputation: check against Spamhaus ZEN, SpamCop, Barracuda,
SORBS via MXToolbox.

System: OS, disk, RAM, swap, UFW/iptables, any existing mail
packages (postfix/dovecot/exim/sendmail), local DNS resolver.

SSL/TLS: certbot installed? /etc/letsencrypt/ exists?

Verdict: Ready / Needs fixes / Blocked (list specific blockers).

Critical blockers

  • Port 25 outbound BLOCKED — DigitalOcean, Vultr, Linode block this on new accounts. You literally cannot send mail. Open a support ticket or change providers. Contabo allows it by default.
  • IP on major blacklists — check Spamhaus ZEN, SpamCop, Barracuda, SORBS at mxtoolbox.com/blacklists.aspx
  • Generic PTR record (e.g. vmi3038401.contaboserver.net) — must be changeable to your mail FQDN

Manual port test from outside

bash
telnet mail.yourdomain.com 25
# Expected: 220 mail.yourdomain.com ESMTP Postfix (Ubuntu)
P2

DNS & Network Setup

Phase 2 — Cloudflare DNS

4.1 PTR (reverse DNS)

The single most important record for deliverability. Without a matching PTR, Gmail rejects your mail at EHLO.

  • Open your VPS provider's control panel → find rDNS / PTR settings
  • Set PTR for your IP to mail.yourdomain.com
  • Contabo: self-serve in the VPS panel, no support ticket needed
bash
dig -x YOUR_IP +short
# Expected: mail.yourdomain.com.

4.2 Cloudflare DNS records

⚠ ALL records must be DNS-only (grey cloud)
Cloudflare's orange-cloud proxy passes only HTTP/HTTPS. SMTP and IMAP traffic cannot pass through it. If you have a wildcard * A record that's proxied, create an explicit mail A record — it will override the wildcard.

A record

TypeNameContentProxy
AmailYOUR_VPS_IPDNS only (grey)

MX record

TypeNameMail serverPriorityProxy
MX@mail.yourdomain.com10DNS only

SPF — TXT record

ℹ No dedicated SPF type in Cloudflare
SPF and DMARC are both just TXT records. Cloudflare doesn't have special type fields for them — select TXT for both.
TypeNameContent
TXT@v=spf1 mx -all

DMARC — TXT record

TypeNameContent
TXT_dmarcv=DMARC1; p=none; rua=mailto:[email protected]

Start with p=none (monitor mode). Tighten to p=quarantine or p=reject after a few weeks of clean sends.

DKIM — skip for now

Add this in Phase 7 after OpenDKIM generates the key.

4.3 Verify propagation

bash
dig MX yourdomain.com +short
# Expected: 10 mail.yourdomain.com.

dig A mail.yourdomain.com +short
# Expected: YOUR_IP (NOT 104.x or 172.x Cloudflare IPs)

dig TXT yourdomain.com +short
# Expected: "v=spf1 mx -all"

dig TXT _dmarc.yourdomain.com +short
# Expected: "v=DMARC1; p=none; ..."
P3

System Preparation

Phase 3 — Before Any Mail Software

5.1 Set FQDN

bash
sudo hostnamectl set-hostname mail.yourdomain.com

Edit /etc/hosts — replace the old hostname line:

/etc/hosts
# Replace this:
127.0.1.1   vmi3038401.contaboserver.net vmi3038401

# With this:
127.0.1.1   mail.yourdomain.com mail
bash
hostname -f
# Expected: mail.yourdomain.com
# Note: shell prompt shows user@mail:~$ — that's normal, short hostname only

5.2 Add swap

bash
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h

5.3 UFW firewall

bash
sudo ufw allow 22/tcp     # SSH
sudo ufw allow 25/tcp     # SMTP inbound
sudo ufw allow 465/tcp    # Submissions SSL wrapper
sudo ufw allow 587/tcp    # Submission STARTTLS
sudo ufw allow 143/tcp    # IMAP
sudo ufw allow 993/tcp    # IMAPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
P4

TLS Certificate

Phase 4 — Let's Encrypt via Cloudflare DNS-01

DNS-01 is the right approach when ports 80/443 are used by other services (Caddy, Nginx, etc.). It authenticates via Cloudflare API — no port coordination needed.

6.1 Install certbot

bash
sudo apt install certbot python3-certbot-dns-cloudflare -y

6.2 Create Cloudflare API token

  1. Cloudflare Dashboard → My ProfileAPI TokensCreate Token
  2. Use the "Edit zone DNS" template
  3. Scope it to your specific domain only
  4. Copy the token

6.3 Store credentials

bash
sudo mkdir -p /etc/letsencrypt/cloudflare
sudo nano /etc/letsencrypt/cloudflare/yourdomain.ini
ini
dns_cloudflare_api_token = YOUR_TOKEN_HERE
bash
sudo chmod 600 /etc/letsencrypt/cloudflare/yourdomain.ini

6.4 Issue the certificate

bash
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare/yourdomain.ini \
  -d mail.yourdomain.com \
  --email [email protected] \
  --agree-tos \
  --no-eff-email
✓ Expected output
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/mail.yourdomain.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/mail.yourdomain.com/privkey.pem

Certbot sets up automatic renewal via systemd timer. Add a deploy hook in the Hardening section so Postfix/Dovecot reload after renewal.

P5

Postfix (SMTP)

Phase 5 — SMTP Server

7.1 Install

bash
sudo apt update
sudo apt install postfix -y
# Do NOT install postfix-mysql or postfix-pgsql
# Flat files are simpler with zero database dependencies

During the interactive dialog: select Internet Site. Enter yourdomain.com (bare domain, not mail.yourdomain.com) as the system mail name.

7.2 Configure /etc/postfix/main.cf

⚠ Three critical gotchas
  • mydestination must NOT include yourdomain.com — it conflicts with virtual_mailbox_domains. Postfix prefers mydestination and skips virtual delivery entirely.
  • virtual_uid_maps and virtual_gid_maps are mandatory — without them, mail sits deferred with not found in virtual_uid_maps.
  • virtual_mailbox_limit = 0 is required — the default (~51MB) is smaller than message_size_limit, causing a fatal startup error.
/etc/postfix/main.cf
# Debian defaults
smtpd_banner = $myhostname ESMTP $mail_name (Ubuntu)
biff = no
append_dot_mydomain = no
readme_directory = no
compatibility_level = 3.6

# Identity
myhostname = mail.yourdomain.com
mydomain = yourdomain.com
myorigin = $mydomain

# Network — use ipv4 only to avoid IPv6 SPF failures
inet_interfaces = all
inet_protocols = ipv4
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128

# yourdomain.com intentionally OMITTED from mydestination
mydestination = $myhostname, localhost.$mydomain, localhost
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases

# Virtual mailboxes (flat file, no database)
virtual_mailbox_domains = yourdomain.com
virtual_mailbox_base = /var/vmail
virtual_mailbox_maps = hash:/etc/postfix/vmailbox
virtual_alias_maps = hash:/etc/postfix/virtual
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000
virtual_mailbox_limit = 0

# TLS
smtpd_tls_cert_file = /etc/letsencrypt/live/mail.yourdomain.com/fullchain.pem
smtpd_tls_key_file = /etc/letsencrypt/live/mail.yourdomain.com/privkey.pem
smtpd_tls_security_level = may
smtp_tls_CApath = /etc/ssl/certs
smtp_tls_security_level = may
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_loglevel = 1

# SMTP auth via Dovecot SASL
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes

# Restrictions
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, defer_unauth_destination
smtpd_recipient_restrictions = permit_sasl_authenticated, permit_mynetworks, reject_unauth_destination
disable_vrfy_command = yes
smtpd_helo_required = yes

# Limits
mailbox_size_limit = 0
message_size_limit = 52428800
recipient_delimiter = +
relayhost =

# DKIM milter (inet socket — unix socket fails inside chroot)
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:localhost:12301
non_smtpd_milters = inet:localhost:12301

7.3 Create vmail user and storage

bash
sudo groupadd -g 5000 vmail
sudo useradd -g vmail -u 5000 vmail -d /var/vmail -m
sudo mkdir -p /var/vmail
sudo chown vmail:vmail /var/vmail
sudo chmod 700 /var/vmail

7.4 Create virtual mailbox map

/etc/postfix/vmailbox
[email protected]    yourdomain.com/user/
bash
sudo touch /etc/postfix/virtual
sudo postmap /etc/postfix/vmailbox
sudo postmap /etc/postfix/virtual

7.5 Enable submission ports in master.cf

⚠ master.cf vs main.cf format
master.cf uses a column format — never put key = value lines there. That's main.cf syntax. Mixing them causes: file /etc/postfix/master.cf: line N: bad field count

Find and uncomment the submission and submissions blocks, adding the milter override:

/etc/postfix/master.cf
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_milters=inet:localhost:12301
  -o milter_macro_daemon_name=ORIGINATING

submissions     inet  n       -       y       -       -       smtpd
  -o syslog_name=postfix/submissions
  -o smtpd_tls_wrappermode=yes
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_reject_unlisted_recipient=no
  -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
  -o smtpd_milters=inet:localhost:12301
  -o milter_macro_daemon_name=ORIGINATING

7.6 Verify

bash
sudo postfix check          # silence = good
sudo postfix reload
sudo ss -tlnp | grep master
# Expected: ports 25, 465, 587 all LISTEN
P6

Dovecot (IMAP)

Phase 6 — IMAP Server

8.1 Install

bash
sudo apt install dovecot-core dovecot-imapd -y

8.2 dovecot.conf — protocols

/etc/dovecot/dovecot.conf
protocols = imap

8.3 10-mail.conf — mail location

/etc/dovecot/conf.d/10-mail.conf
mail_location = maildir:/var/vmail/%d/%n/
# %d = domain, %n = user
mail_privileged_group = vmail

8.4 10-auth.conf — authentication

/etc/dovecot/conf.d/10-auth.conf
disable_plaintext_auth = yes
auth_mechanisms = plain login

# Comment out system auth, enable passwd-file
#!include auth-system.conf.ext
!include auth-passwdfile.conf.ext

8.5 auth-passwdfile.conf.ext

⚠ userdb must use static driver, not passwd-file
Using passwd-file for userdb fails with user not found from userdb because the file has passwords only, not UID/GID/home info. Use static driver — it assigns fixed values to every authenticated user.
/etc/dovecot/conf.d/auth-passwdfile.conf.ext
passdb {
  driver = passwd-file
  args = scheme=SHA512-CRYPT username_format=%u /etc/dovecot/users
}

userdb {
  driver = static
  args = uid=vmail gid=vmail home=/var/vmail/%d/%n/
}

8.6 10-ssl.conf — TLS

/etc/dovecot/conf.d/10-ssl.conf
ssl = required
ssl_cert = </etc/letsencrypt/live/mail.yourdomain.com/fullchain.pem
ssl_key = </etc/letsencrypt/live/mail.yourdomain.com/privkey.pem
ssl_min_protocol = TLSv1.2

8.7 10-master.conf — Postfix auth socket

/etc/dovecot/conf.d/10-master.conf
service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}

8.8 Create users file and add mailbox

bash
sudo touch /etc/dovecot/users
sudo chmod 640 /etc/dovecot/users
sudo chown root:dovecot /etc/dovecot/users

# Generate password hash
sudo doveadm pw -s SHA512-CRYPT
# Outputs: {SHA512-CRYPT}$6$abc...
/etc/dovecot/users
[email protected]:{SHA512-CRYPT}$6$yourhashhere

8.9 Restart and test auth

bash
sudo systemctl restart dovecot
sudo doveadm auth test [email protected]
# Expected:
# passdb: [email protected] auth succeeded
# userdb: [email protected] auth succeeded
P7

OpenDKIM (DKIM Signing)

Phase 7 — Email Authentication

9.1 Install

bash
sudo apt install opendkim opendkim-tools -y

9.2 Configure /etc/opendkim.conf

⚠ Use inet socket, not Unix socket
The Debian default is Socket local:/run/opendkim/opendkim.sock. This will not work with Postfix. Postfix runs smtpd in a chroot at /var/spool/postfix — the path /run/opendkim/ doesn't exist inside the chroot. Use an inet socket instead.
/etc/opendkim.conf
Syslog                  yes
SyslogSuccess           yes
LogWhy                  yes

Canonicalization        relaxed/simple
Mode                    sv
SubDomains              no
OversignHeaders         From

KeyTable                /etc/opendkim/KeyTable
SigningTable            refile:/etc/opendkim/SigningTable
ExternalIgnoreList      /etc/opendkim/TrustedHosts
InternalHosts           /etc/opendkim/TrustedHosts

Socket                  inet:12301@localhost
PidFile                 /run/opendkim/opendkim.pid
UserID                  opendkim
UMask                   007

TrustAnchorFile         /usr/share/dns/root.key
TemporaryDirectory      /var/tmp

9.3 Generate DKIM key

⚠ Key file group ownership matters
If the private key's group has multiple users, OpenDKIM refuses to sign: key data is not secure: key is in group N which has multiple users. Keep group as root (single user), mode 600. Do NOT add postfix to the opendkim group — it's unnecessary with inet socket and triggers this error.
bash
sudo mkdir -p /etc/opendkim/keys/yourdomain.com
sudo opendkim-genkey -b 2048 -d yourdomain.com \
  -D /etc/opendkim/keys/yourdomain.com -s mail -v
sudo chown opendkim:root /etc/opendkim/keys/yourdomain.com/mail.private
sudo chmod 600 /etc/opendkim/keys/yourdomain.com/mail.private

9.4 Configure key tables

/etc/opendkim/KeyTable
mail._domainkey.yourdomain.com    yourdomain.com:mail:/etc/opendkim/keys/yourdomain.com/mail.private
/etc/opendkim/SigningTable
*@yourdomain.com    mail._domainkey.yourdomain.com
/etc/opendkim/TrustedHosts
127.0.0.1
::1
localhost
yourdomain.com
mail.yourdomain.com

9.5 Start OpenDKIM

bash
sudo systemctl enable opendkim
sudo systemctl restart opendkim
sudo ss -tlnp | grep 12301
# Expected: LISTEN on 127.0.0.1:12301

9.6 Add DKIM public key to Cloudflare DNS

bash
sudo cat /etc/opendkim/keys/yourdomain.com/mail.txt
# Outputs multi-line quoted strings — concatenate ALL parts
# into a single string with no spaces between segments
TypeNameContentProxy
TXTmail._domainkeyv=DKIM1; h=sha256; k=rsa; p=MIIB...IDAQAB (one line)DNS only

9.7 Verify DKIM key

bash
sudo opendkim-testkey -d yourdomain.com -s mail -vvv
# "key not secure" = normal (no DNSSEC) — not a problem
# "key OK" = this is what matters
P8

Logging (rsyslog)

Phase 8 — Ubuntu 24.04 Gotcha
⚠ Ubuntu 24.04 doesn't install rsyslog by default
Postfix logs go nowhere visible. /var/log/mail.log doesn't exist. You get no visibility into incoming mail or delivery failures. Install rsyslog before you test anything.
bash
sudo apt install rsyslog -y
sudo systemctl enable rsyslog
sudo systemctl start rsyslog

# If /var/log/mail.log still doesn't exist:
sudo touch /var/log/mail.log
sudo chown syslog:adm /var/log/mail.log
sudo chmod 640 /var/log/mail.log
sudo systemctl restart rsyslog
sudo systemctl restart postfix

Live log monitoring

bash
sudo tail -f /var/log/mail.log

# Or via journalctl:
sudo journalctl -u postfix -f
sudo journalctl -u dovecot -f
sudo journalctl -u opendkim -f
P9

Testing & Verification

Phase 9 — End-to-End Testing

11.1 Install swaks

bash
sudo apt install swaks -y

11.2 Local delivery test

bash
swaks \
  --to [email protected] \
  --from [email protected] \
  --server 127.0.0.1 \
  --port 25
# Check: /var/vmail/yourdomain.com/user/new/

11.3 Authenticated send — port 587 (STARTTLS)

bash
swaks \
  --to [email protected] \
  --from [email protected] \
  --server mail.yourdomain.com \
  --port 587 \
  --tls \
  --auth LOGIN \
  --auth-user [email protected] \
  --auth-password 'your_password'
# Note: the flag is --tls, NOT --starttls

11.4 Authenticated send — port 465 (SSL wrapper)

bash
swaks \
  --to [email protected] \
  --from [email protected] \
  --server mail.yourdomain.com \
  --port 465 \
  --tlsc \
  --auth LOGIN \
  --auth-user [email protected] \
  --auth-password 'your_password'
# --tlsc = TLS-on-connect (wrapper mode), required for 465

11.5 IMAP test

bash
curl -v \
  --url "imaps://mail.yourdomain.com:993/INBOX" \
  --user "[email protected]:your_password"

11.6 Mail client settings

SettingValue
Incoming (IMAP)
Servermail.yourdomain.com
Port993
SecuritySSL/TLS
Auth methodNormal password (not Kerberos, not OAuth)
Username[email protected] (full address)
Outgoing (SMTP)
Servermail.yourdomain.com
Port587
SecuritySTARTTLS
Auth methodNormal password
Username[email protected] (full address)

11.7 Verify DKIM/SPF/DMARC via Gmail

Send to Gmail → open message → three-dot menu → Show original. Look for:

email headers
Authentication-Results: mx.google.com;
       dkim=pass [email protected] header.s=mail
       spf=pass (google.com: domain of [email protected] designates
              YOUR_IP as permitted sender)
       dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=yourdomain.com

Issues We Hit & How We Fixed Them

These are the real-world errors encountered during this setup, in the order they appeared. Save yourself hours by recognizing them early.

Issue 1 master.cf: line N: bad field count
Cause

DKIM key = value lines were appended to master.cf instead of main.cf. master.cf uses a strict column format, not key=value syntax.

Fix

Remove the DKIM milter lines from master.cf and put them in main.cf: milter_default_action, milter_protocol, smtpd_milters, non_smtpd_milters.

Issue 2 virtual_mailbox_limit is smaller than message_size_limit
Cause

virtual_mailbox_limit defaults to ~51MB. If message_size_limit is larger, Postfix refuses to start the virtual delivery agent — all incoming mail sits deferred.

Fix
main.cf
virtual_mailbox_limit = 0
Issue 3 recipient user@domain: not found in virtual_uid_maps
Cause

Postfix doesn't know which system UID/GID to write maildir files as. Mail accepts, enters the queue, then defers indefinitely.

Fix
main.cf
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000
Issue 4 /var/log/mail.log: No such file or directory
Cause

Ubuntu 24.04 does not install rsyslog by default. Postfix logs are silently dropped — you have zero visibility into delivery failures.

Fix
bash
sudo apt install rsyslog -y
sudo systemctl enable rsyslog
sudo systemctl start rsyslog
Issue 5 Dovecot: user not found from userdb
Cause

Password lookup succeeds but userdb lookup fails. passwd-file driver for userdb expects a full passwd-format file with UID/GID/home — but the file only has passwords.

Fix

Switch userdb to static driver — it assigns fixed values without looking up anything.

auth-passwdfile.conf.ext
userdb {
  driver = static
  args = uid=vmail gid=vmail home=/var/vmail/%d/%n/
}
Issue 6 Thunderbird "Relay access denied" / swaks works fine
Cause

Thunderbird auth method was not set to Normal password, or it cached a previous failed session. Postfix sees an unauthenticated client and rejects relay.

Fix
  • Set auth method to Normal password (not Kerberos, not OAuth2)
  • Use full email as username: [email protected], not just user
  • Restart Thunderbird (clears cached state)
Issue 7 OpenDKIM: connect to Milter service unix:/run/opendkim/opendkim.sock: No such file or directory
Cause

Postfix's smtpd runs inside a chroot at /var/spool/postfix. The Unix socket path /run/opendkim/ doesn't exist inside the chroot. The socket is unreachable even if OpenDKIM is running.

Fix

Switch to an inet socket — it bypasses the chroot entirely.

opendkim.conf
Socket  inet:12301@localhost
main.cf + master.cf submission blocks
smtpd_milters = inet:localhost:12301
non_smtpd_milters = inet:localhost:12301
Issue 8 OpenDKIM: key data is not secure — key is in group N which has multiple users
Cause

When debugging Issue 7, postfix was added to the opendkim group. This made the private key's group have multiple users, which OpenDKIM considers insecure. It refuses to sign.

Fix
bash
sudo chown opendkim:root /etc/opendkim/keys/yourdomain.com/mail.private
sudo chmod 600 /etc/opendkim/keys/yourdomain.com/mail.private
sudo gpasswd -d postfix opendkim
sudo systemctl restart opendkim
Issue 9 Gmail shows spf=fail — does not designate IPv6 as permitted sender
Cause

inet_protocols = all caused Postfix to send over IPv6. The SPF record only covered IPv4 via mx. Cheap VPS IPv6 PTR often points to the provider's generic hostname, not your mail FQDN.

Fix (simplest)
main.cf
inet_protocols = ipv4
Issue 10 Cloudflare proxy breaks SMTP/IMAP silently
Cause

The mail A record was proxied (orange cloud). Cloudflare proxies only HTTP/HTTPS. SMTP and IMAP connections are silently dropped or return wrong IPs.

Fix

Set mail A record to DNS only (grey cloud). Verify: dig A mail.yourdomain.com +short must return your actual VPS IP, not 104.x or 172.x.

Issue 11 mydestination and virtual_mailbox_domains conflict
Cause

The default Postfix install adds yourdomain.com to mydestination. Postfix prefers mydestination over virtual delivery — it tries local delivery, finds no system user user, and bounces with unknown user: "[email protected]".

Fix

Remove yourdomain.com from mydestination entirely. Keep only:

main.cf
mydestination = $myhostname, localhost.$mydomain, localhost
🔒

Hardening

Core setup is operational. These aren't optional for production — prioritise by threat model.

13.1 Certbot renewal hook (do this today)

Let's Encrypt renews in ~60–89 days. Postfix and Dovecot must reload the new cert or TLS breaks.

bash
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh
bash
#!/bin/bash
systemctl reload postfix
systemctl reload dovecot
bash
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh

13.2 Fail2ban (within 24h of going live)

Public mail ports will be probed within hours. The logs already showed scanner IPs hitting port 465 during setup.

bash
sudo apt install fail2ban -y
/etc/fail2ban/jail.local
[postfix]
enabled = true
port = smtp,465,submission
logpath = /var/log/mail.log

[postfix-sasl]
enabled = true
port = smtp,465,submission,imap,imaps,pop3,pop3s
logpath = /var/log/mail.log

[dovecot]
enabled = true
port = pop3,pop3s,imap,imaps,submission,465,sieve
logpath = /var/log/mail.log
bash
sudo systemctl restart fail2ban
sudo fail2ban-client status

13.3 Tighten DMARC policy

After 2–3 weeks of clean sending history, update the _dmarc TXT record:

dns
v=DMARC1; p=quarantine; rua=mailto:[email protected]; pct=100
# Eventually: p=reject

13.4 Spam filtering (optional)

bash
sudo apt install rspamd -y
# Configure as milter alongside OpenDKIM
# Skip for personal use — rspamd is non-trivial to tune

13.5 Monitor blacklists monthly

13.6 Disk space

  • Set disk alerts at 70%, 85%, 95%
  • Consider a dedicated volume mounted at /var/vmail if sharing disk with other services
  • Log rotation is handled by /etc/logrotate.d/rsyslog

Final Verdict

Postfix SMTP ports 25, 465, 587 — sending + receiving
Dovecot IMAP port 993 — working
TLS via Let's Encrypt — auto-renewing
dkim=pass at Gmail, ProtonMail
spf=pass at Gmail
dmarc=pass at Gmail
PTR / rDNS matches FQDN
Thunderbird send + receive working

Time investment

Pre-flight audit + DNS prep30 min
Cert + Postfix + Dovecot install1–2 hours
OpenDKIM + DNS propagation30–60 min
Debugging gotchas (realistic)1–2 hours
Total (first time)3–5 hours

What to do next

  1. Add the certbot deploy hook today (Section 13.1)
  2. Install fail2ban within 24 hours (Section 13.2)
  3. Monitor first 2 weeks — check Gmail "Show original" on every test send
  4. Tighten DMARC from p=none to p=quarantine after clean sends

When to give up and use a paid provider

ℹ Still landing in spam after 2 weeks?
Your IP is likely on a hidden reputation list. Consider a hybrid approach: relay outgoing through Migadu, Mailgun, or AWS SES while keeping your self-hosted IMAP. This is increasingly common in 2026.