A self-hosted email server for business sending is a VPS running Postfix, with OpenDKIM signing every message, SPF and DMARC records in DNS, a PTR record that matches the server's hostname, and authenticated submission on port 587 so your apps and mail clients can send through it. Below is the exact setup we run on Ubuntu 26.04 LTS (Postfix 3.10, OpenDKIM 2.11, Dovecot 2.4), step by step.

Before you start, be honest with yourself about the ongoing work. The build is an afternoon. After that, someone has to apply updates, watch TLS renewals, check blocklists, read DMARC reports and look into bounces, every week, indefinitely. If you would rather own the server without being its sysadmin, that is exactly what our managed email server plan is for. If you are still deciding whether to self-host at all, read is self-hosting email worth it first.

Throughout this guide we use example.com, the hostname mail.example.com and the IP 203.0.113.10. Replace them with yours.

What you need before you start

  • A VPS with a dedicated IPv4 address. 1 GB of RAM is plenty for sending. VPS prices are typically $5–20 a month.
  • A domain where you control DNS.
  • The ability to set a PTR (reverse DNS) record for the IP, usually in your VPS provider's control panel.
  • Outbound port 25 open. This is the one that catches people.

Step 1: Check that outbound port 25 is open

AWS and many other clouds block outbound port 25 by default. Some lift the block on request or after your account has some history. Our OVH VPS allowed it out of the box. Test it from the VPS before anything else:

nc -vz -w 5 gmail-smtp-in.l.google.com 25

succeeded or open means you are fine. A timeout means the provider is blocking it, and you need to ask them to lift the block or pick another provider.

Step 2: Create the DNS records and the PTR

Set the server's hostname first:

sudo hostnamectl set-hostname mail.example.com

Then publish these records. These are the same shapes we use on our own domains:

Type Host Value
A mail 203.0.113.10
MX @ 10 mail.example.com
TXT @ v=spf1 ip4:203.0.113.10 -all
TXT mail v=spf1 a -all
TXT _dmarc v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=s; aspf=s
TXT mail._domainkey (generated in step 6)

The SPF record on the mail host covers bounces and other mail that uses the hostname itself as the sender. Start DMARC at p=none and tighten it once reports look clean; our DMARC p=reject rollout guide covers that. For more on each record, see how to set up an SPF record and how to set up DMARC.

In your VPS panel, set the PTR for 203.0.113.10 to mail.example.com. Forward and reverse DNS must agree; reverse DNS for email explains why.

Step 3: Install the packages

sudo apt update
sudo apt install postfix opendkim opendkim-tools dovecot-core certbot fail2ban swaks

When the Postfix installer asks, choose Internet Site and enter example.com as the mail name.

Step 4: Open the firewall

Port 80 is only needed for Let's Encrypt's http-01 challenge.

sudo ufw allow OpenSSH
sudo ufw allow 25/tcp
sudo ufw allow 587/tcp
sudo ufw allow 80/tcp
sudo ufw enable
sudo ufw status

Step 5: Get a TLS certificate and add a deploy hook

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

Postfix loads the certificate when it starts, so it keeps serving the old one after a renewal until it is reloaded. Add a deploy hook:

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-postfix.sh > /dev/null <<'EOF'
#!/bin/sh
# Postfix caches the cert at start; reload it after a renewal.
case "$RENEWED_LINEAGE" in
  */mail.example.com) systemctl reload postfix ;;
esac
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-postfix.sh
sudo certbot renew --dry-run

Step 6: Configure Postfix main.cf

postconf -e edits /etc/postfix/main.cf safely. Back it up first:

sudo cp /etc/postfix/main.cf /etc/postfix/main.cf.orig
sudo postconf -e \
  'myhostname = mail.example.com' \
  'mydomain = example.com' \
  'myorigin = $mydomain' \
  'mydestination = localhost' \
  'inet_interfaces = all' \
  'inet_protocols = ipv4' \
  'smtpd_banner = $myhostname ESMTP' \
  'smtpd_helo_required = yes' \
  'mynetworks = 127.0.0.0/8' \
  'smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination' \
  '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_mandatory_protocols = >=TLSv1.2' \
  'smtp_tls_security_level = may' \
  'smtp_tls_loglevel = 1' \
  'virtual_alias_domains = example.com' \
  'virtual_alias_maps = hash:/etc/postfix/virtual' \
  'milter_default_action = accept' \
  'milter_protocol = 6' \
  'smtpd_milters = local:opendkim/opendkim.sock' \
  'non_smtpd_milters = $smtpd_milters'

We run IPv4 only (inet_protocols = ipv4) on purpose: if your server has IPv6 but no IPv6 PTR, receivers may reject mail sent over IPv6.

Because example.com is a virtual alias domain, inbound mail is forwarded rather than stored. Create /etc/postfix/virtual:

# Comments go on their own line, never after an entry.
postmaster@example.com  you@your-inbox.example
abuse@example.com       you@your-inbox.example
dmarc@example.com       you@your-inbox.example
hello@example.com       you@your-inbox.example
# Local mail to root (cron, system alerts) is rewritten to root@example.com.
root@example.com        you@your-inbox.example
sudo postmap /etc/postfix/virtual

If you forward to an outside mailbox such as Gmail, also install postsrsd so forwarded mail is SRS-rewritten and does not fail the original sender's SPF. We run postsrsd 2.0 in socketmap mode for this: set srs-domain and domains in /etc/postsrsd.conf, then point Postfix at its socket.

sudo apt install postsrsd
sudo postconf -e \
  'sender_canonical_maps = socketmap:unix:/var/spool/postfix/srs:forward' \
  'sender_canonical_classes = envelope_sender' \
  'recipient_canonical_maps = socketmap:unix:/var/spool/postfix/srs:reverse' \
  'recipient_canonical_classes = envelope_recipient, header_recipient'

Step 7: Set up OpenDKIM

Generate a 2048-bit key with the selector mail, one key per domain:

sudo mkdir -p /etc/opendkim/keys/example.com
sudo opendkim-genkey -b 2048 -d example.com -s mail -D /etc/opendkim/keys/example.com
sudo chown -R opendkim:opendkim /etc/opendkim
sudo chmod 600 /etc/opendkim/keys/example.com/mail.private

Replace /etc/opendkim.conf with:

Syslog                  yes
SyslogSuccess           yes
UMask                   007
Mode                    sv
Canonicalization        relaxed/simple
OversignHeaders         From
SubDomains              yes
AutoRestart             yes
AutoRestartRate         10/1h
KeyTable                file:/etc/opendkim/key.table
SigningTable            refile:/etc/opendkim/signing.table
ExternalIgnoreList      refile:/etc/opendkim/trusted.hosts
InternalHosts           refile:/etc/opendkim/trusted.hosts
Socket                  local:/var/spool/postfix/opendkim/opendkim.sock
PidFile                 /run/opendkim/opendkim.pid
UserID                  opendkim

Create the three tables:

echo 'mail._domainkey.example.com example.com:mail:/etc/opendkim/keys/example.com/mail.private' | sudo tee /etc/opendkim/key.table
echo '*@example.com mail._domainkey.example.com' | sudo tee /etc/opendkim/signing.table
printf '127.0.0.1\n::1\nlocalhost\n' | sudo tee /etc/opendkim/trusted.hosts

Ubuntu 26.04 runs Postfix without a chroot by default, but keeping the socket inside /var/spool/postfix works either way and keeps the local:opendkim/opendkim.sock path short:

sudo mkdir -p /var/spool/postfix/opendkim
sudo chown opendkim:postfix /var/spool/postfix/opendkim
sudo chmod 750 /var/spool/postfix/opendkim
sudo usermod -aG opendkim postfix
sudo systemctl restart opendkim

Publish the contents of /etc/opendkim/keys/example.com/mail.txt as the TXT record mail._domainkey, as one record, then test it with sudo opendkim-testkey -d example.com -s mail -vvv. You want key OK. Details and troubleshooting are in how to set up DKIM.

Step 8: Dovecot 2.4 as an auth-only SASL backend

We use Dovecot only to check passwords for Postfix: no IMAP, no mailboxes. Create a login and a hash:

sudo doveadm pw -s SHA512-CRYPT

Put the result in /etc/dovecot/users, one login per line:

hello@example.com:{SHA512-CRYPT}$6$replace-with-your-hash
sudo chown root:dovecot /etc/dovecot/users
sudo chmod 640 /etc/dovecot/users
sudo cp /etc/dovecot/dovecot.conf /etc/dovecot/dovecot.conf.orig

Replace /etc/dovecot/dovecot.conf with this Dovecot 2.4 configuration:

dovecot_config_version = 2.4.0
dovecot_storage_version = 2.4.0

# Auth backend only: no IMAP, POP3 or LMTP listeners.
protocols =

auth_mechanisms = plain login
auth_allow_cleartext = no

passdb passwd-file {
  passwd_file_path = /etc/dovecot/users
}

userdb static {
  fields {
    uid = nobody
    gid = nogroup
    home = /nonexistent
  }
}

service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}
sudo doveconf -n
sudo systemctl restart dovecot

Now enable submission on port 587 in /etc/postfix/master.cf. Replace the commented-out submission entry with:

submission inet n       -       n       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_security_level=encrypt
  -o smtpd_tls_auth_only=yes
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_sasl_type=dovecot
  -o smtpd_sasl_path=private/auth
  -o smtpd_sasl_security_options=noanonymous
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_helo_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_restrictions=reject_sender_login_mismatch,permit_sasl_authenticated,reject
  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
  -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_login_maps=hash:/etc/postfix/sender_login
  -o milter_macro_daemon_name=ORIGINATING

Step 9: Restrict each login to its own addresses

reject_sender_login_mismatch only works with a sender login map. Create /etc/postfix/sender_login, mapping each sender address to the login allowed to use it:

hello@example.com     hello@example.com
support@example.com   hello@example.com
noreply@example.com   hello@example.com
sudo postmap /etc/postfix/sender_login
sudo postfix check
sudo systemctl restart postfix

If a password leaks, the attacker can only send as that login's addresses, and the attempt shows up in your logs as a 553 rejection.

Step 10: fail2ban for SSH and SASL brute force

Create /etc/fail2ban/jail.local:

[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled = true

[postfix-sasl]
enabled = true
backend = systemd
sudo systemctl restart fail2ban
sudo fail2ban-client status postfix-sasl

We also set backend = systemd under [postfix-sasl]: the stock filter already matches Postfix's postfix@-.service unit on Ubuntu 26.04, so it reads the journal directly. Check Journal matches in the status output.

Optional: two domains on two IPs

We run two domains on two IPs on one server. Postfix picks the outbound IP by sender domain. In master.cf:

smtp-brand unix  -       -       y       -       -       smtp
  -o smtp_bind_address=203.0.113.10
  -o smtp_helo_name=mail.example.com
  -o syslog_name=postfix/smtp-brand
smtp-outreach unix -     -       y       -       -       smtp
  -o smtp_bind_address=203.0.113.11
  -o smtp_helo_name=mail.example.net
  -o syslog_name=postfix/smtp-outreach

Then map senders to transports and rate-limit the newer stream:

echo '@example.net smtp-outreach:' | sudo tee /etc/postfix/sender_transport
sudo postmap /etc/postfix/sender_transport
sudo postconf -e \
  'sender_dependent_default_transport_maps = hash:/etc/postfix/sender_transport' \
  'default_transport = smtp-brand' \
  'smtp-outreach_destination_concurrency_limit = 1' \
  'smtp-outreach_destination_rate_delay = 30s'
sudo systemctl reload postfix

Each IP needs its own PTR matching its HELO name.

Common mistakes

  • Pasting the DKIM record twice. We did this: a duplicate TXT, or the host name pasted into the value field, gives "multiple DNS replies" and DKIM fails. There must be exactly one v=DKIM1 record at mail._domainkey.
  • Trailing comments in /etc/postfix/virtual. Postfix does not allow # comment at the end of an entry line; the words become extra recipients. Put comments on their own line.
  • Picking a provider that blocks port 25. Test in step 1, not after the build.
  • Misreading blocklist checks. Spamhaus refuses queries from public resolvers and answers with 127.255.255.x, which naive checkers report as "listed". UCEPROTECT level 3 lists whole provider networks; that is not a listing of your IP.
  • No deploy hook. Without it, Postfix serves an expired certificate after renewal until someone restarts it.
  • Sending a newsletter on day one. See warm-up below.

How to verify it worked

Check DNS from outside:

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

Send an authenticated test to port25's verifier, which replies with an SPF, DKIM and iprev report. swaks prompts for the password:

swaks --to check-auth@verifier.port25.com --from hello@example.com \
  --server mail.example.com --port 587 --tls \
  --auth LOGIN --auth-user hello@example.com

On our servers that report shows SPF pass, DKIM pass and iprev pass. Then confirm there is no open relay. From a machine outside your network, this must be refused with a 554:

swaks --to someone@gmail.com --from test@example.org --server mail.example.com --port 25

Finally, send a message to the address mail-tester.com gives you, and to a Gmail account, where you can use "Show original" to check that SPF, DKIM and DMARC all say PASS. Our email checker also checks your DNS records.

Warm up before you send real volume

Passing authentication is not reputation. On our first day, two near-identical test emails from two of our domains went to Gmail: one landed in the Inbox, the other in Spam with "similar to messages identified as spam in the past". Both passed SPF, DKIM and DMARC. New and dormant domains have no history, and gradual, engaged sending is what fixes that. Start with tens of messages a day and build over 2–6 weeks; our IP warm-up guide has a schedule.

What running it involves after setup

This is the part to budget for. On our own server, the recurring jobs are:

  • Updates. sudo apt update && sudo apt upgrade regularly, and read what changed in Postfix, OpenDKIM and Dovecot.
  • Certificates. Renewal is automatic, but confirm it worked; sudo certbot certificates shows the expiry dates.
  • The queue. mailq should be nearly empty. A growing queue means a receiver is deferring you.
  • Logs. Look for 4xx and 5xx responses in /var/log/mail.log, and for SASL brute-force attempts fail2ban is banning.
  • Blocklists and DMARC reports. Weekly at least, keeping the Spamhaus and UCEPROTECT caveats above in mind.
  • DNS drift. Someone editing DNS for the website can break SPF or DKIM without noticing.

Want this built for you, with DNS, DMARC reporting, a warm-up plan and a written runbook? See Server Setup on our pricing page.