- Nix 81.9%
- Shell 12.6%
- Sieve 5.5%
| modules | ||
| pkgs/postfix-mta-sts-resolver | ||
| scripts | ||
| sieve | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
cnixpost
NixOS mail server module. Postfix · Dovecot · Rspamd · ClamAV · Redis · Pigeonhole Sieve · fail2ban
Design:
- Postfix and Dovecot bind to
127.0.0.1/::1. Traefik owns external ports and forwards with PROXY protocol v2 so real client IPs reach postscreen, fail2ban, and Rspamd - DKIM signing, ARC signing, and SPF/DMARC verification handled by Rspamd
- Messages scoring above the add_header threshold are auto-filed into Junk via a
global sieve before-script (
sieve/spam-to-junk.sieve) - Moving mail into/out of Junk triggers
rspamc learn_spam/learn_hamvia imapsieve - Passwords and secrets are agenix-encrypted, injected at runtime. Nothing sensitive in the nix store
- Dovecot authenticates via LDAP
auth_bindagainst lldap. No per-account Dovecot passwords needed. Per-account quotas still apply. - Outbound TLS: DANE (TLSA/DNSSEC) by default, optional MTA-STS resolver for domains that publish MTA-STS but not DANE (Gmail, Outlook, Yahoo, etc.)
- Autoconfig/Autodiscover endpoints for automatic mail client setup (Thunderbird, Outlook, Apple Mail)
Prerequisites
- NixOS 25.11
- Public IPv4 with rDNS → your
fqdn - Port 25 unblocked by your hosting provider
- DNSSEC-signed zone (required for DANE)
- Traefik running with a
letsencryptresolver and wildcard cert for*.domain - lldap running on
127.0.0.1:3890
Importing
In your host flake:
inputs = {
cnixpost.url = "github:cnsta/cnixpost";
cnixpost.inputs.nixpkgs.follows = "nixpkgs";
};
In your host module:
imports = [ inputs.cnixpost.nixosModules.default ];
If using the MTA-STS outbound resolver, also apply the overlay:
nixpkgs.overlays = [ inputs.cnixpost.overlays.default ];
Setup
Everything below is a one-time manual process done before or just after the first deploy.
1. Infrastructure secrets
# Redis password
openssl rand -hex 32 | tr -d '\n' > /tmp/mailRedisPw
agenix -e secrets/mailRedisPw.age # paste, save
# Rspamd controller password (bcrypt hash, not plaintext)
rspamadm pw -p 'choose-a-password' # prints $2$... hash
agenix -e secrets/mailRspamdCtrlPw.age # paste hash, save
# Redis password
openssl rand -hex 32 | tr -d '\n' | save /tmp/mailRedisPw
agenix -e secrets/mailRedisPw.age # paste, save
# Rspamd controller password (bcrypt hash, not plaintext)
rspamadm pw -p 'choose-a-password' # prints $2$... hash
agenix -e secrets/mailRspamdCtrlPw.age # paste hash, save
2. DKIM key
Run on the server (requires openssl and rspamd in the system):
sudo bash /path/to/scripts/generate-dkim.sh yourdomain.com
# Prints the DNS TXT record to publish
# Key is written to /var/lib/rspamd/dkim/yourdomain.com.mail.key
Or generate locally:
openssl genrsa -out mail.key 2048
openssl rsa -in mail.key -pubout -out mail.pub
PUB=$(grep -v "^--" mail.pub | tr -d '\n')
echo "v=DKIM1; k=rsa; p=${PUB}"
sudo install -m 640 -o rspamd -g rspamd mail.key \
/var/lib/rspamd/dkim/yourdomain.com.mail.key
openssl genrsa -out mail.key 2048
openssl rsa -in mail.key -pubout -out mail.pub
let pub = (open mail.pub | lines | where {|l| $l !~ "^--"} | str join)
print $"v=DKIM1; k=rsa; p=($pub)"
sudo install -m 640 -o rspamd -g rspamd mail.key /var/lib/rspamd/dkim/yourdomain.com.mail.key
The generated key files can be deleted after the install step. The private key now lives at the target path and the public key only needs to exist as a DNS TXT record.
3. Deploy
nixos-rebuild switch --flake .#yourhost --target-host root@mail.example.com
nixos-rebuild switch --flake $".#yourhost" --target-host root@mail.example.com
4. DANE TLSA record (after first deploy)
Once Traefik has issued a cert and /run/mail-certs/fullchain.pem exists:
sudo bash /path/to/scripts/generate-tlsa.sh /run/mail-certs/fullchain.pem
# Prints the TLSA record to publish at _25._tcp.mail.yourdomain.com
Configure
cnixpost = {
enable = true;
fqdn = "mail.example.com";
primaryDomain = "example.com";
certificateFile = "/run/mail-certs/fullchain.pem";
keyFile = "/run/mail-certs/privkey.pem";
lldap = {
base = "dc=example,dc=com";
userDnTemplate = "uid=%u,ou=people,dc=example,dc=com";
};
# Accounts define Postfix routing, quotas, and sender permissions.
# Auth goes through lldap. Each user must exist in lldap with a uid
# matching the local-part of their address.
accounts."alice@example.com" = {
quota = "10G";
aliases = [ "postmaster@example.com" "abuse@example.com" ];
};
};
Adding a custom domain (e.g. for a user who owns clientsown.dev):
cnixpost = {
# ...existing config...
extraDomains = [ "clientsown.dev" ];
accounts."bob@clientsown.dev" = {
quota = "5G";
aliases = [ "postmaster@clientsown.dev" ];
};
};
The client must configure their DNS to point to your mail server (see DNS
Records below). Generate a DKIM key for the new domain, the signing config picks
up all domains in allDomains automatically.
DNS Records
| Type | Name | Value |
|---|---|---|
| A | mail.example.com |
<IPv4> |
| AAAA | mail.example.com |
<IPv6> |
| MX | example.com |
10 mail.example.com |
| TXT | example.com |
v=spf1 mx ~all |
| TXT | mail._domainkey.example.com |
(from step 2 above) |
| TXT | _dmarc.example.com |
v=DMARC1; p=quarantine; rua=mailto:postmaster@example.com |
| PTR | <IP> |
mail.example.com |
| TLSA | _25._tcp.mail.example.com |
(from step 4 above) |
| CNAME | autoconfig.example.com |
mail.example.com |
| CNAME | autodiscover.example.com |
mail.example.com |
| SRV | _imaps._tcp.example.com |
0 1 993 mail.example.com |
| SRV | _submission._tcp.example.com |
0 1 465 mail.example.com |
MTA-STS (when enabled):
| Type | Name | Value |
|---|---|---|
| TXT | _mta-sts.example.com |
v=STSv1; id=<policyId> |
| A | mta-sts.example.com |
<server IP> |
| TXT | _smtp._tls.example.com |
v=TLSRPTv1; rua=mailto:tls-reports@example.com |
Configuration Reference
Accounts
| Option | Default | Description |
|---|---|---|
quota |
"2G" |
Mailbox size limit |
aliases |
[] |
Additional addresses delivered to this mailbox |
sendOnly |
false |
Reject inbound mail with 5.1.1 |
Spam thresholds
| Option | Default | Effect |
|---|---|---|
spamScoreAddHeader |
4.0 |
Add X-Spam headers + file to Junk |
spamScoreGreylist |
6.0 |
Greylist (retry after 5 min) |
spamScoreReject |
15.0 |
Reject at SMTP level |
lldap
lldap = {
uri = "ldap://127.0.0.1:3890"; # default
base = "dc=example,dc=com";
userDnTemplate = "uid=%u,ou=people,dc=example,dc=com";
passFilter = "(uid=%u)"; # default
};
Dovecot authenticates via LDAP auth_bind against lldap. Users authenticate
with their lldap credentials. Accounts must still be declared to define routing,
quotas, and sender permissions.
MTA-STS
mtaSts = {
enable = true;
mode = "testing"; # -> "enforce" after a week of clean TLSRPT reports
policyId = "20240601120000";
mxHosts = [ "mail.example.com" ];
# Outbound MTA-STS enforcement (complements DANE for non-DANE destinations)
enableOutboundCheck = true;
};
Other options
| Option | Default | Description |
|---|---|---|
clamav.enable |
true |
Disable on hosts with < 1.5 GiB RAM |
messageSizeLimit |
52428800 |
50 MiB |
recipientDelimiter |
"+" |
Address extensions (alice+tag@), "" to disable |
vmailUid |
5000 |
UID for vmail user. Change if it collides |
vmailGid |
5000 |
GID for vmail group. Change if it collides |
debug |
false |
Verbose logging on all services |
dkimSelector |
"mail" |
Active DKIM selector |
autoconfig.enable |
true |
Serve autoconfig/autodiscover XML endpoints |
DKIM Key Rotation
# 1. Generate new key with new selector
sudo bash scripts/generate-dkim.sh example.com mail2
# 2. Publish mail2._domainkey.example.com in DNS
# 3. Update config
# cnixpost.dkimSelector = "mail2";
# 4. Deploy
nixos-rebuild switch --flake .#yourhost
# 5. Wait 48h for DNS TTL, then remove the old mail._domainkey record
Diagnostics
# Service status
systemctl status postfix dovecot2 rspamd redis-rspamd clamav-daemon
# Postfix queue
postqueue -p # inspect
postqueue -f # flush deferred
# Rspamd web UI: https://rspamd.yourdomain.com
# Protected by authelia + controller password
# Score a message
rspamc -h localhost:11334 symbols < msg.eml
# Force Bayes training
rspamc -h localhost:11334 learn_spam < spam.eml
rspamc -h localhost:11334 learn_ham < ham.eml
rspamc -h localhost:11334 stat | grep -i bayes
# Verify DKIM
rspamadm dkim_sign -d example.com -s mail < test.eml | grep DKIM
# Test SMTP
swaks --to alice@example.com --from postmaster@example.com --server localhost
# Tail logs
journalctl -u postfix -u dovecot2 -u rspamd -u clamav-daemon -f
# fail2ban
fail2ban-client status dovecot
fail2ban-client status postfix-sasl
# MTA-STS resolver (if enabled)
systemctl status postfix-mta-sts-resolver
# Service status
systemctl status postfix dovecot2 rspamd redis-rspamd clamav-daemon
# Postfix queue
postqueue -p # inspect
postqueue -f # flush deferred
# Score a message
open msg.eml | rspamc -h localhost:11334 symbols
# Force Bayes training
open spam.eml | rspamc -h localhost:11334 learn_spam
open ham.eml | rspamc -h localhost:11334 learn_ham
rspamc -h localhost:11334 stat | grep -i bayes
# Verify DKIM
open test.eml | rspamadm dkim_sign -d example.com -s mail | grep DKIM
# Test SMTP
swaks --to alice@example.com --from postmaster@example.com --server localhost
# Tail logs
journalctl -u postfix -u dovecot2 -u rspamd -u clamav-daemon -f
# fail2ban
fail2ban-client status dovecot
fail2ban-client status postfix-sasl
Ports
Traefik binds all external-facing ports and proxies to the loopback services with PROXY protocol v2.
| Port | Protocol | Direction | Purpose |
|---|---|---|---|
| 25 | SMTP | Inbound | Server-to-server mail |
| 465 | SMTPS | Inbound | Client submission (implicit TLS) |
| 587 | Submission | Inbound | Client submission (STARTTLS) |
| 993 | IMAPS | Inbound | Client mailbox access |
| 143 | IMAP | Inbound | Client mailbox access (STARTTLS) |
| 4190 | ManageSieve | Inbound | Sieve filter management |
Minimum required: 25, 465, 993. Add 587 if any clients don't support implicit TLS on 465. Ports 143 and 4190 can be restricted to VPN/Tailscale if all clients support IMAPS and you manage sieve filters locally.
Memory footprint (idle)
| Service | RSS |
|---|---|
| Postfix | ~35 MiB |
| Dovecot | ~65 MiB |
| Rspamd + Redis | ~230 MiB |
| ClamAV | ~700 MiB |
| Total with ClamAV | ~1.0 GiB |
| Total without ClamAV | ~330 MiB |