The Final Mile: Self-Hosted Email Server with Dovecot and Postfix
Connecting IMAP and SMTP to AWS SES for a full email stack on a cheap VPS
Summary
This final post connects all three layers — AWS SES, Rspamd, and a self-hosted mail server — into a working email stack you can use with any standard email client.
The missing piece
After Post 1 and Post 2, incoming emails landed in S3 and were screened for spam. But raw email files in a cloud storage bucket aren't something Thunderbird or Apple Mail can open. Two more components were needed:
- IMAP — so email clients can browse and read incoming mail
- SMTP submission — so those same clients can send outgoing mail
Dovecot handles the IMAP side (the most widely deployed open-source IMAP server), and Postfix handles SMTP relay. Both run together in a single Docker container on the VPS, managed by supervisord.
How mail flows
Inbound: AWS SES receives the message and stores it in S3. A cron job running every five minutes downloads new email files from S3, maps each file to the correct recipient address based on the S3 folder it landed in, and delivers it into Dovecot's mailbox structure. The email client then fetches it from Dovecot over an encrypted IMAP connection.
Outbound: the email client submits a message to Postfix over SMTP (port 587, encrypted with STARTTLS). Postfix authenticates the user via Dovecot's user database, then relays the message to SES SMTP. SES handles DKIM signing and final delivery.
TLS everywhere
Email clients expect encrypted connections on both ports. A
Let's Encrypt
certificate for mail.joao-coelho.com covers both IMAP (port 993) and SMTP
submission (port 587). Rather than using an HTTP challenge (which would require a web server on
port 80), the certificate is issued via Cloudflare's DNS API — Certbot proves domain ownership
by temporarily creating a DNS record.
The single hostname mail.joao-coelho.com serves all four domains. Email clients
don't care which From: address you're using; they connect to whatever server you
configure.
Security
Exposing ports 993 and 587 to the internet means brute-force login attempts are a given. Two layers of protection:
- fail2ban runs on the VPS host and monitors container logs; five failed authentication attempts within five minutes triggers a one-hour IP ban
- Application-level rate limits in both Dovecot and Postfix cap connection rates, message rates, and introduce a brief delay between failed login attempts
As with Rspamd in Post 2, dovecot, postfix and
fail2ban have a lot of settings that I don't know about, nor do I want to. AI came to the rescue
again by setting initial sensible values that can be tweaked as needed.
To give you an idea, here are the files used to configure them. Each file has several config
entries that address very specific functionality:
+-- dovecot | +-- conf.d | | +-- 10-auth.conf | | +-- 10-mail.conf | | +-- 10-master.conf | | +-- 10-ssl.conf | | `-- auth-passwdfile.conf.ext | +-- aliases.conf | `-- dovecot.conf +-- fail2ban | +-- filter.d | | +-- dovecot.conf | | `-- postfix-sasl.conf | `-- jail.local +-- postfix | +-- main.cf | +-- master.cf | +-- sasl_passwd | `-- sender_login.regexp `-- supervisord.conf
Connecting an email client
Any standard client (Thunderbird, Apple Mail, Outlook) works with these settings:
| Setting | Value |
|---|---|
| IMAP server | mail.joao-coelho.com, port 993, SSL/TLS |
| SMTP server | mail.joao-coelho.com, port 587, STARTTLS |
| Username | Full email address |
| Password | Dovecot user password |
After setup, outgoing mail appears immediately; incoming mail appears within five minutes (the S3 sync interval).
The complete stack
The full email infrastructure, from DNS to inbox:
| Layer | Technology | Role |
|---|---|---|
| DNS | Cloudflare | MX, SPF, DKIM, DMARC, mail subdomain |
| Inbound gateway | AWS SES | MX reception, receipt rules → S3 |
| Storage | AWS S3 | Versioned email storage per address |
| Spam filtering | Rspamd + Redis (Docker) | Bayesian analysis, RBL checks |
| Mail server | Dovecot + Postfix (Docker) | IMAP serving, SMTP relay |
| Security | fail2ban + rate limits | Brute-force protection |
| TLS | Let's Encrypt (certbot) | Encrypted connections |
| Infrastructure | OpenTofu | All AWS + Cloudflare resources as code |
All of this runs on a single $15/month Hetzner VPS, using about 250MB of RAM for the entire email stack.