A small, hardened mail server you can deploy from scratch with a handful of commands. It sends and receives mail for any number of domains, has no web UI, and is configured entirely as code:
- NixOS with simple-nixos-mailserver (Postfix, Dovecot, Rspamd, Redis) for the server
- OpenTofu/Terraform for the server, its reverse DNS and every DNS record, on OVHcloud, netcup, UpCloud or Serverspace (DigitalOcean with a relay) and Cloudflare DNS, or any DNS host by hand
- sops-nix for secrets (password hashes, DKIM keys, relay credentials), encrypted in git
- nixos-anywhere + disko to turn a fresh server into NixOS in one step
You edit one file, settings.nix. Both the server
configuration and the DNS records are generated from it, so they can't drift
apart.
It needs 2 GB of RAM, which costs €3–7 a month depending on the provider. See docs/providers.md to choose one; OVHcloud and netcup are the easiest, because they let you send on port 25 straight away.
| Receiving | SMTP on port 25 with STARTTLS. Rspamd does spam filtering, greylisting, RBLs and SPF/DKIM/DMARC/ARC checks. |
| Sending | SMTP submission on port 465 (implicit TLS), with DKIM signing for every domain. Optionally through a relay. |
| Reading | IMAP on port 993 (implicit TLS) with quotas. Any mail client works: Thunderbird, Apple Mail, K-9/FairEmail, … |
| TLS | Let's Encrypt certificates, TLS 1.2+ with AEAD ciphers only, post-quantum hybrid key exchange. MTA-STS and TLS-RPT are published. Outbound mail validates DANE/MTA-STS. |
| DNS | MX, SPF (-all), DKIM (2048-bit RSA), DMARC (p=reject, strict alignment), MTA-STS, TLS-RPT, CAA, client autodiscovery SRV records, and reverse DNS for IPv4 and IPv6. |
See docs/security.md for the threat model and every hardening measure, including what stops spoofing and what stops the server from being abused to send spam.
- Pick a provider and set
providerinsettings.nix. Have its API credentials ready as environment variables (docs/providers.md lists them per provider). - A domain, with DNS either in Cloudflare (
export CLOUDFLARE_API_TOKEN=...; Terraform creates the records) or anywhere else (dns = "manual"; you enter the records thatjust dnsprints). - Nix with flakes on your machine (Linux or macOS). Everything else comes
from the dev shell: run
nix develop, ordirenv allowif you use direnv.
nix develop
# 1. Describe your setup: provider, domains, mailboxes, your SSH key.
$EDITOR settings.nix
# 2. Create your encryption key, the server's SSH host key and DKIM keys.
just init
# 3. Set a password for each mailbox.
just passwd [email protected]
# 4. Lock dependency versions and commit everything (secrets are encrypted).
nix flake lock
git add -A && git commit -m "Configure mail server"
# 5. Create the server, its reverse DNS and the DNS records. Read the
# "ACTION NEEDED" line at the end, if any (e.g. a support ticket).
just apply
# 6. Install NixOS on the server (wipes it), then commit what it recorded.
just install
git add -A && git commit -m "Install mail server"
# 7. Verify DNS, reverse DNS, TLS and MTA-STS from the outside.
just checkThen send a test mail to mail-tester.com and run internet.nl against your domain.
Back up your age key (~/.config/sops/age/keys.txt, or
~/Library/Application Support/sops/age/keys.txt on macOS). Without it you
can't change secrets or reinstall the server from this repository.
| Server | Port | Security | Username | |
|---|---|---|---|---|
| Incoming (IMAP) | mail.example.com |
993 | SSL/TLS | full e-mail address |
| Outgoing (SMTP) | mail.example.com |
465 | SSL/TLS | full e-mail address |
| Task | How |
|---|---|
| Add a mailbox | Add it under accounts in settings.nix, run just passwd <address>, then just deploy |
| Change a password | just passwd <address> then just deploy |
| Add an alias | Add it to the mailbox's aliases, then just deploy |
| Forward an address elsewhere | Add it to forwards, then just deploy |
| Add a domain | Add it to domains and give it a postmaster@ address, run just add-domain <domain>, then just apply (DNS) and just deploy |
| Rotate a DKIM key | just add-domain <domain> (new date-based selector), then just apply && just deploy. A few days later, delete the old dkim/<domain>/<selector>.txt and its entry in just secrets, then apply and deploy again |
| Send through a relay | Fill in relay in settings.nix and set enable = true, run just relay-password, then just apply && just deploy |
| Security updates | just update && just deploy. A weekly GitHub Action also opens a PR that updates flake.lock; or turn on autoUpgrade |
| Monitoring | Set monitoring.healthcheckUrl (healthchecks.io or similar): an hourly self-check of services, certificate, disk, queue, outbound SMTP and blocklists alerts you on failure and when the server stops reporting. just health runs it now |
| Logs and queue | just logs, or just ssh |
| Rspamd web UI | ssh -L 11334:/run/rspamd/worker-controller.sock [email protected], then open http://localhost:11334 |
just with no arguments lists every command.
settings.nix the only file you normally edit
flake.nix NixOS system + Terraform variable export + dev shell
modules/
machine.nix disk layout (disko), boot, networking (DHCP/static/cloud-init)
hardening.nix SSH, firewall, fail2ban, sysctls, auto-upgrades
mail.nix mail stack, anti-spoofing rules, rate limits, secrets
relay.nix optional outbound smarthost
web.nix Let's Encrypt + MTA-STS policy (nginx)
providers/ per-provider NixOS settings (network method, drivers)
lib/default.nix derives DNS records etc. from settings.nix
lib/providers.nix per-provider facts used by the scripts
dkim/ public DKIM keys (generated, committed)
secrets/ sops-encrypted secrets + the server's public host key
infra/servers/<p>/ Terraform: server + reverse DNS, one directory per provider
infra/dns/cloudflare/ Terraform: all DNS records
infra/*/terraform.tfstate Terraform state, committed, encrypted (passphrase in secrets/)
scripts/ what the `just` commands run
tests/ fixtures for the CI build of an example configuration
machine.json disk and network facts recorded by `just install`
known_hosts the server's pinned SSH host key (written by `just install`)
Provider backups differ: UpCloud's are enabled by server.upcloud.backups;
OVHcloud and netcup sell snapshots/backups as options in their panels;
Serverspace has snapshots in its panel. Mail lives in /var/vmail; for
off-site backups, add e.g. services.restic.backups pointing at an S3 bucket
(Backblaze B2, OVHcloud Object Storage, ...).
Terraform state is committed to git, encrypted with OpenTofu's state
encryption (the passphrase is in secrets/secrets.yaml), so just apply
works from any machine that can decrypt the secrets. Always commit it after
just apply. If the server is installed but its state is missing,
just apply refuses to run rather than create a second server.
To rebuild from scratch, create a new server (just apply, after removing
the old one from the Terraform state), delete machine.json, and run
just install. Everything except the mail itself comes back from this
repository. The IP changes in that case; the DNS records follow automatically
when Terraform manages them.
- No virus scanning. ClamAV needs 1–1.5 GB of RAM on its own. With 4 GB
(e.g. OVHcloud VPS-1) you can set
mailserver.virusScanning = trueinmodules/mail.nix. - No DANE for inbound mail. MTA-STS covers the same downgrade attack. With DNSSEC enabled for your zone (Cloudflare supports it), publishing a TLSA record for the mail host would be a good addition.
- A new IP has no reputation. Expect some mail to land in spam folders at Gmail and Outlook during the first weeks, even with everything set up correctly. Check that your server's IP isn't on a blocklist (MXToolbox) before you rely on it, and register with Google Postmaster Tools and Microsoft SNDS. A relay avoids most of this.
- Forwarding through a relay. Some relays (Postmark, SES) only accept mail
whose
From:is one of your verified domains, soforwardsof external mail may be refused while the relay is enabled. - The first install hasn't been tested on real servers of every
provider. If the server doesn't come back on the network after
just install, boot the provider's rescue system (every provider here has one), comparemachine.jsonwith whatip addrshows there, fix it, and runjust installagain. There is no root password, so the provider's web console won't let you log in to the installed system. - DMARC
p=rejecton your domains means mail that claims to be from your domain but didn't go through this server is rejected everywhere. If a domain also sends from somewhere else (a newsletter tool, a billing system), authorise that sender first or usequarantineuntil you have.