dan@dlk25:~$ cat ~/notes/mailserver.md

Running my own mail server

2026-07-03 · email, linux · 6 min read

Postfix, Dovecot and DKIM from scratch, to see how email fits together. Mostly a story about commands that did nothing and said nothing.

I work with email every day, from the template end. I wanted to know what happens at the other end, so I set up a mail server for dlk25.com from scratch on a small rented Linux box, with Claude as a patient tutor. It sends and receives as dan@dlk25.com, and passes SPF, DKIM and DMARC. It took two months, most of them waiting, and the lessons were not the ones I expected.

Two jobs, two programs

Email is not one job. It is two, with a protocol each:

  • Postfix speaks SMTP and moves mail between servers. It takes mail in from strangers, sends it on, queues it and retries when the other end is down. It knows nothing about inboxes, folders or what has been read.
  • Dovecot speaks IMAP and lets a person read what has arrived: folders, read flags, search, several clients on one mailbox. It never sends anything anywhere.

Around them: a TLS certificate from Let's Encrypt that both use, OpenDKIM to sign outgoing mail, and DNS.

MX tells the world which machine takes mail for the domain, and the reverse lookup (PTR) proves the machine may call itself mail.dlk25.com. SPF, DKIM and DMARC are not part of the server at all. They are DNS records the receiving end looks up to decide whether mail that claims to be from dlk25.com really is.

There are three doors. Port 25 is server to server, and has to take mail from anyone without a password, or nobody could send you anything. Port 587 is where my own mail client hands over mail to send, and demands a login. IMAP over TLS is where the client reads. Mix up the first two and you are an open relay: strangers sending spam through you.

One thing I liked: Postfix has no user database for mail, so it asks Dovecot over a socket whether a login is good. One password store, two services.

The interface is a directory

The part that taught me most is that Postfix and Dovecot do not talk to each other about the mail. Postfix writes files into ~/Maildir/, Dovecot reads files from ~/Maildir/. That is the whole integration: a folder on disk, no protocol, no API.

SMTP writes files reads files IMAP other servers Postfix ~/Maildir/ Dovecot my mail client
Mail in: the only thing between Postfix and Dovecot is a folder

It is elegant, and nothing checks that the two agree. For two months Postfix delivered to ~/Maildir/ while Dovecot looked for mbox files somewhere else. Both ran without a single error. Mail arrived, and the inbox was empty.

Commands that did nothing

Four times the symptom was the same: a command that exited cleanly without doing anything.

  1. A setting that never landed. I told Postfix to use Dovecot for logins and spent an evening debugging Dovecot's password setup. The clue in the end was the server's greeting: it offered a long list of login methods that Dovecot does not have. Postfix was still using its built-in default, and the change I thought I had made was not in the config file. Every change on the Dovecot side made no difference, because Postfix was not asking Dovecot at all.
  2. A sed that matched nothing. A newer Dovecot renamed the setting for where mail lives. My sed line searched for the old name, found nothing, changed nothing, and exited 0. That is the empty inbox above.
  3. The same again, for the certificate. Another renamed setting, another silent sed, so Dovecot served a self-signed certificate while Postfix had the real one.
  4. 2>/dev/null hid the one error message that would have said what was wrong.

What I do now: after any config change, check that it is actually in the file before debugging anything further down. If a symptom stays exactly the same whatever you change on one side of an integration, the other side is probably not using it.

And keep two questions apart. Is the config valid, and what will be loaded? postconf and doveconf answer that, from disk. What is the running process doing right now? Only connecting to the port from outside answers that. At one point Dovecot's admin tool counted the messages correctly, because it read the fixed config from disk itself, while the running service had not been restarted and still used the old one.

The subdomain trap

Outgoing mail failed SPF and DKIM, and for a while I had written that off as harmless. It wasn't. The command line mail tool sends as user@<the machine's name>, so the mail went out as dan@mail.dlk25.com. The SPF record is on dlk25.com, and DKIM was set up to sign for dlk25.com, so the subdomain matched neither. Postfix leaves addresses that already have a domain alone.

The fix was to have Postfix rewrite subdomains to the bare domain, both the envelope and the From: header. The header matters: DMARC wants the domain DKIM signs for to match the one the reader sees in From:. A signature can be perfectly valid and still fail DMARC because it signs the wrong domain.

mail.dlk25.com is the name of the machine. dlk25.com is who I send as. One identity is enough.

Failing open, quietly

Postfix hands each outgoing message to OpenDKIM to sign. It is set to send the mail anyway if the signer cannot be reached, so a broken signer does not stop all mail.

a mail, after a login sign this signed the mail, signed no signer? it goes out unsigned, no error my client Postfix OpenDKIM the world
Mail out, and where it failed without a word

Sensible, and it means a broken connection gives no error at all: the mail just goes out unsigned. The only sign was a missing DKIM-Signature header and silence in the signer's log. The cause was writing localhost where an IP address was needed, because part of Postfix runs in a chroot where looking up names can fail.

Things outside my control

The hosting company blocks outgoing port 25 on new accounts, against spam. No setting on my side could change that, and the block could not be lifted until the first month was paid. That was the two months of waiting. Testing port 25 against several hosts, next to ports that work, is what showed it was a block on that port and not my own config.

The server's address also turned up on a blocklist, almost certainly from whoever had it before me. Cloud address ranges are often listed in advance. A delisting request is the only fix.

Checking it

The most useful single tool was port25's authentication verifier: send it a mail and it replies with SPF, DKIM, DMARC and reverse DNS, each with a reason. Read the right part, though. The Authentication-Results header on the reply is my server checking their signature and says pass even when everything of mine is broken. The verdict on my mail is in the body.

Two more things I learned to read properly. A blocklist lookup can answer "refused" in a form that looks like a listing, so the codes matter. And a relay test from the server itself proves nothing, because the server trusts itself on purpose. Test from somewhere else.

It works now. Most days I am still at the template end, but I know a little more about what happens after send.

← all notes

← back to the terminal (q)