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.
| Type | Name | Value |
|---|---|---|
| A | mx.idx.cy | X.X.X.X |
| TXT | mx.idx.cy | v=spf1 a -all |
And for each email domain I have these DNS records:
| Type | Name | Value |
|---|---|---|
| MX | example.com | mx.idx.cy |
| TXT | example.com | v=spf1 mx a -all |
| TXT | key1._domainkey | v=DKIM1; k=rsa; s=email; p=<pubkey> |
| TXT | _dmarc | v=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 :)
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.
Click "Show original" in Gmail to see the raw mail and check that SPF, DKIM, and DMARC pass.
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
- Send and receive mail with Python
- SMTP remote submission
- POP3 and IMAP
- Self-hosted webmail