Introduction

Self-hosting an email server is pretty cool and it's useful for automating tasks like mailing lists, newsletters, or email verification APIs.

The elephant in the room is real-world deliverability. With self-hosting you risk not receiving mail or someone missing your mail, but I accepted this, and things work well.

For me the selling point of self-hosting is that it’s practically free. If you’re already self-hosting a website, installing some extra packages on your server and just a bit of your time is all that’s required. Mail uses very little storage and the software is light, so you’re unlikely to significantly change energy consumption or disk usage.

For the longest time, I perceived self-hosting email as too difficult, but after doing it for one of my projects, I can say it took me only a few hours, and it wasn't that bad.

Though I changed my goals a bit to make the setup easier. Self-hosting a multi-user webmail looks heavy and is more involved than I was willing to get into, so I just skipped it. That way, I didn’t have to bother with user accounts, databases, or the web at all, and the task became easy.

To self-host email you just need to open port 25 and install Postfix. Postfix is a complete SMTP server, and is enough for basic mail alone, but in practice you also need to install OpenDKIM to get your mail delivered to popular services like Gmail.

With my config, manually sending and receiving email is possible if you SSH to your mail server and use sendmail or s-nail - or Mutt if you like TUI.

I've been semi-comfortably using mailx for a month already (with its ancient user interface!), and I just switched to s-nail (v14.9.25 feels very 2024 and is a neat 1MB binary), so the setup is enough for now, but I could expand it in the future, and multi-user webmail isn’t completely off the table. Maybe I’ll even write a simple webmail package myself!

Postfix

Here's my Postfix config to show how simple it can be. I left the master.cf file as it was, because I’m always submitting email locally. You should also read the comments in the default alias config file.

/etc/postfix/main.cf
compatibility_level = 3.8
mail_owner = postfix

myhostname = mx.idx.cy
mydomain = idx.cy
# Default sender domain
myorigin = idx.cy
# Receive mail at those domains
mydestination = localhost, idx.cy, maxadamski.com, localchat.cc

# Aliases for receiving mail
alias_maps = hash:/etc/aliases
alias_database = $alias_maps
recipient_delimiter = +

# You can assign addresses to Unix users.
# I don't because I'm the only user on my server, and I want to set the From header freely.
#smtpd_sender_login_maps = hash:/etc/postfix/sender_login_maps
#smtpd_sender_restrictions = reject_authenticated_sender_login_mismatch

# Open to the internet
inet_interfaces = all
inet_protocols = ipv4

# Relay control
mynetworks_style = host
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination

# Obfuscation
smtpd_banner = $myhostname ESMTP
disable_vrfy_command = yes

# OpenDKIM
smtpd_milters = inet:localhost:8891
non_smtpd_milters = inet:localhost:8891
milter_default_action = accept

# TLS (encrypt or bust)
smtp_tls_security_level = encrypt
smtpd_tls_security_level = encrypt
smtpd_tls_key_file = /etc/cert/mx.idx.cy.key
smtpd_tls_cert_file = /etc/cert/mx.idx.cy.crt
/etc/postfix/master.cf
# ==========================================================================
# service type  private unpriv  chroot  wakeup  maxproc command + args
#               (yes)   (yes)   (no)    (never) (100)
# ==========================================================================
smtp      inet  n       -       n       -       -       smtpd
pickup    unix  n       -       n       60      1       pickup
cleanup   unix  n       -       n       -       0       cleanup
qmgr      unix  n       -       n       300     1       qmgr
tlsmgr    unix  -       -       n       1000?   1       tlsmgr
rewrite   unix  -       -       n       -       -       trivial-rewrite
bounce    unix  -       -       n       -       0       bounce
defer     unix  -       -       n       -       0       bounce
trace     unix  -       -       n       -       0       bounce
verify    unix  -       -       n       -       1       verify
flush     unix  n       -       n       1000?   0       flush
proxymap  unix  -       -       n       -       -       proxymap
proxywrite unix -       -       n       -       1       proxymap
smtp      unix  -       -       n       -       -       smtp
relay     unix  -       -       n       -       -       smtp
        -o syslog_name=postfix/$service_name
showq     unix  n       -       n       -       -       showq
error     unix  -       -       n       -       -       error
retry     unix  -       -       n       -       -       error
discard   unix  -       -       n       -       -       discard
local     unix  -       n       n       -       -       local
virtual   unix  -       n       n       -       -       virtual
lmtp      unix  -       -       n       -       -       lmtp
anvil     unix  -       -       n       -       1       anvil
scache    unix  -       -       n       -       1       scache
postlog   unix-dgram n  -       n       -       1       postlogd

Notice there's no mention of POP3 or IMAP! I wasted some time trying to set them up with Dovecot (it recently changed the config format, so guides became outdated and the new docs were hard to read). I feel comfortable with s-nail, so I skipped Dovecot. One package less in my system, at least for now :)

TLS

You just need one SSL certificate for your machine to encrypt data in transit to other SMTP servers. Create an A record mx.example.com pointing to your email machine’s IP address and get a free certificate for mx.example.com from Let’s Encrypt. Then, point to the certificate files in the Postfix configuration, and you’ve got transport encryption! Remember, only the MX hostname needs a cert for STARTTLS to be used for encryption.

Why no certificates for your actual email domains like example.com? TLS is only for securing the connection between servers, and encryption in transit only cares about the server hostnames. Validating what people write in the From header in mail is a job for SPF and DKIM.

I highly recommend installing acme.sh to generate SSL certificates on your system. AFAIK it's the smallest and simplest tool for the job and it makes automation easy.

I'm able to generate certificates using acme.sh with one small Nginx rule. I already publish /www directories to the web, so they're a great fit for hosting ACME challenges.

# Serve the ACME challenge files over HTTP.
# Redirect everything else to HTTPS.
server {
  listen 80 default_server;
  server_name _;
  location ^~ /.well-known/acme-challenge/ {
    root /www;
    try_files /$host/$uri =404;
  }
  location / {
    return 301 https://$host$request_uri;
  }
}

# I register and set the CA to letsencrypt (default is ZeroSSL - not so nice imo)
acme.sh --set-default-ca --server letsencrypt
acme.sh --register-account -m address@example.com

# Change to your MX host
DOMAIN=mx.idx.cy

# You can issue a multi-domain cert. I did --issue -d idx.cy -d mx.idx.cy for example.
acme.sh --issue -d $DOMAIN -w /www/$DOMAIN
# Copy certs from ~/.acme.sh and reload TLS software.
acme.sh --install-cert -d $DOMAIN --key-file /etc/cert/$DOMAIN.key --fullchain-file /etc/cert/$DOMAIN.crt --reloadcmd "nginx -s reload; postfix reload"

acme.sh automatically configures itself in the ~/.acme.sh directory based on the commands you run. It has a clear structure that you can inspect and edit. It remembers your cert directory and the reload command. To automate, add acme.sh --cron to cron. Run the command manually to see acme.sh check all of your domains.

Overall, running acme.sh while running an HTTP server is the easiest way to get free certs from Let's Encrypt and renew them automatically.

You can do it without a web server with the manual DNS challenge, where you publish a TXT record with the ACME challenge. At first, I did it like that because I was lazy. However, the certs are only valid for 90 days, so the topic will come back to you sooner or later.

DNS, DKIM, SPF, and DMARC

You should prove that your emails actually come from your domain to make your mail trustworthy and to deliver to Gmail and co. That’s what DKIM is for, and fortunately it’s a one-time deal per email domain.

First you generate a key pair for each domain with OpenDKIM, and then you publish the public keys as TXT records in DNS.

The keys don’t expire, so it’s good practice to rotate them periodically. My config uses a naming scheme that allows smooth rotation, but it doesn’t complicate things if you skip it.

There are two more TXT records that you need to publish in DNS: the SPF and DMARC records. You say which hosts are allowed to send from your email domain, and give instructions to other email servers about what to do with mail that fails SPF and DKIM checks.

I told them to reject such mail and send reports to a special address. This could alert me of email spoofing attempts.

Take a look at my OpenDKIM config to see how things come together.

/etc/opendkim.conf
UserID             opendkim:opendkim
Socket             inet:8891@localhost
KeyTable           refile:/etc/opendkim/KeyTable
SigningTable       refile:/etc/opendkim/SigningTable
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
InternalHosts      refile:/etc/opendkim/TrustedHosts

Canonicalization   relaxed/relaxed
ReportAddress      postmaster@idx.cy
SendReports        no

LogWhy             yes
Syslog             yes
SyslogSuccess      no
/etc/opendkim/KeyTable
key1._domainkey.idx.cy         idx.cy:key1:/etc/opendkim/keys/idx.cy/key1.private
key1._domainkey.maxadamski.com maxadamski.com:key1:/etc/opendkim/keys/maxadamski.com/key1.private
key1._domainkey.localchat.cc   localchat.cc:key1:/etc/opendkim/keys/localchat.cc/key1.private
/etc/opendkim/SigningTable
*@idx.cy         key1._domainkey.idx.cy
*@maxadamski.com key1._domainkey.maxadamski.com
*@localchat.cc   key1._domainkey.localchat.cc
/etc/opendkim/TrustedHosts
127.0.0.1
localhost

I generate DKIM keys with opendkim-genkey.

opendkim-genkey -D /etc/opendkim/keys/example.com -d example.com -s key1

For my main domain, I have an A record pointing mx.idx.cy to my email machine. I also have one extra SPF record just for mx.idx.cy. Without it, SpamAssassin was giving me SPF_HELO_NONE with a tiny penalty of -0.001 points.

TypeNameValue
Amx.idx.cyX.X.X.X
TXTmx.idx.cyv=spf1 a -all

And for each email domain I have these DNS records:

TypeNameValue
MXexample.commx.idx.cy
TXTexample.comv=spf1 mx a -all
TXTkey1._domainkeyv=DKIM1; k=rsa; s=email; p=<pubkey>
TXT_dmarcv=DMARC1; p=reject; rua=mailto:postmaster@idx.cy

Reverse DNS

Reverse DNS (PTR record) will boost the reputation of your email server. The thing is that your ISP has to set it up. I sent a request to mine and they did it the next day. Great!

My email was delivered to Gmail just fine, even without the PTR record. GMX and Outlook also didn't mark my mail as spam (and I never clicked "mark as not spam" there).

I measured my reputation with mail-tester and got a 10/10 score. At first I got RDNS_NONE (-1.274 points), but this was solved by the ISP adding the PTR record. I'm also not on any blocklists, so my IP really is lucky :)

SpamAssassin score 2.2 - 10/10 according to mail-tester.

Sending to Gmail

To test the setup, I sent a simple mail to Gmail.

sendmail -t <<EOF
From: max@idx.cy
To: myaddress@gmail.com
Subject: DKIM test
Test message from idx.cy!
EOF

I got the mail instantly and Gmail confirmed TLS encryption.

Gmail received the message and shows TLS standard encryption lock icon

Click "Show original" in Gmail to see the raw mail and check that SPF, DKIM, and DMARC pass.

Gmail shows original message with SPF PASS, DKIM PASS, and DMARC PASS status

If you see failures here, double-check your DNS records and your DKIM config, especially check that DKIM keys are readable by the OpenDKIM user.

Receiving mail

I get a notification on the command-line when I receive mail, and I use the print command in s-nail to read mail. I can also reply with the reply command.

You have mail in /var/mail/max
fool ~ | mail
s-nail version v14.9.25.  Type `?' for help
/var/mail/max: 1 message 1 new
▸N  1 Max Adamski        2025-10-06 12:16   63/3716  Test mail   
? print 1
[-- Message  1 -- 63 lines, 3716 bytes --]:
From: Max Adamski <myaddress@gmail.com>
Subject: Test mail
Message-Id: <EAA6733B-B946-4B3B-85F7-D1213BC97F0C@gmail.com>
Date: Mon, 6 Oct 2025 12:16:10 +0200
To: max@idx.cy

Hello from Gmail!

s-nail is an amazing CLI client for interactive mail. It's a modern drop-in replacement to mailx. It prints the mail in a very readable format and supports automatically replying in your favorite $EDITOR.

~/.mailrc
set v15-compat

set from="Max Adamski <max@idx.cy>"
set reply-in-same-charset
set fullnames
set sendwait
set hold
set noquote
set record=sent.mail

# always compose in editor
set editalong
# always print in pager
set crt=0

More ideas