- Nix 85.5%
- Shell 10.4%
- Sieve 4.1%
| 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. The Traefik entrypoints/routing and TLS certificate extraction are part of this flake (traefik.enable,certsFromTraefik.enable), the host config only holds dials - 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 - Dovecot authenticates via LDAP
auth_binddirectly as the user (auth_bind_userdn) against lldap, no service/bind account, no bind password. Per-account quotas still apply - Redis runs on a unix socket only (no TCP listener, no password); access is gated by filesystem permissions. The single runtime secret in the whole stack is the Rspamd controller password
- Outbound TLS: DANE (TLSA/DNSSEC) by default, optional MTA-STS resolver for domains that publish MTA-STS but not DANE (Gmail, Outlook, Yahoo, etc.)
- Inbound DANE is guarded: every certificate extraction verifies the cert's SPKI against the published TLSA record and logs a loud warning on mismatch
- 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)
- A local DNSSEC-validating resolver (e.g. unbound on
127.0.0.1, andresolv.confpointing at it). This is load-bearing three times over: Postfix only trusts the DNSSEC AD bit from a loopback resolver (outbound DANE), Spamhaus refuses queries via public resolvers (postscreen), and Rspamd's RBL checks need it too. Verify withdig +ad TLSA _25._tcp.<some-dane-domain>, theadflag must be present - 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 (the module asserts on this at eval time, so a missing overlay fails loudly):
nixpkgs.overlays = [ inputs.cnixpost.overlays.default ];
Setup
Everything below is a one-time manual process done before or just after the first deploy.
1. Controller password (the only secret)
The bcrypt hash, not the plaintext, is what gets stored:
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
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
Traefik reuses the ACME private key across renewals, so the 3 1 1 (SPKI)
record stays valid indefinitely. Until the record is published, every cert
extraction logs a TLSA-mismatch warning, expected during initial setup, a
problem any time after.
Configure
cnixpost = {
enable = true;
fqdn = "mail.example.com";
primaryDomain = "example.com";
# Traefik glue lives in the flake; these are just dials.
certsFromTraefik.enable = true;
traefik = {
enable = true;
rspamdUI = {
enable = true;
middlewares = [ "authelia@file" ];
};
};
rspamdControllerPasswordFile = config.age.secrets.mailRspamdCtrlPw.path;
lldap = {
base = "dc=example,dc=com";
userDnTemplate = "uid=%n,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" ];
};
};
If your host repo wraps modules in its own option tree, keep the wrapper thin:
an enable flag plus a settings attrset forwarded verbatim (see
host-default.nix for the pattern). New flake options then become available on
the host with zero wrapper changes.
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.
Caveat: lldap uids are local-parts, so alice@example.com and
alice@clientsown.dev authenticate as the same lldap user. Fine for a
personal server; keep it in mind before hosting third-party domains.
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=%n,ou=people,dc=example,dc=com";
};
Dovecot binds directly as the user via auth_bind_userdn (%n = local-part,
%u = full login), so no bind/service account exists. Accounts must still be
declared to define routing, quotas, and sender permissions.
Traefik glue
| Option | Default | Description |
|---|---|---|
traefik.enable |
false |
Generate all mail entrypoints + PROXY v2 routing |
traefik.webEntryPoint |
"websecure" |
HTTP(S) entrypoint for the web routers |
traefik.certResolver |
"letsencrypt" |
ACME resolver for the web routers |
traefik.rspamdUI.enable |
false |
Expose the Rspamd web UI |
traefik.rspamdUI.host |
null |
Defaults to rspamd.<primaryDomain> |
traefik.rspamdUI.middlewares |
[] |
e.g. [ "authelia@file" ] (warned if empty) |
certsFromTraefik.enable |
false |
Extract cert/key from Traefik's ACME store |
certsFromTraefik.certJson |
/var/lib/traefik/cert.json |
Traefik ACME store path |
certsFromTraefik.domainMain |
null |
Defaults to *.<primaryDomain> |
certsFromTraefik.outDir |
/run/mail-certs |
tmpfs directory for extracted PEMs |
MTA-STS and autoconfig HTTP routers follow their own feature flags automatically
when traefik.enable is set.
MTA-STS
mtaSts = {
enable = true;
mode = "testing"; # -> "enforce" after a week of clean TLSRPT reports
policyId = "20240601120000"; # bump whenever mode/mxHosts/maxAge change
mxHosts = [ "mail.example.com" ];
# Outbound MTA-STS enforcement (complements DANE for non-DANE destinations)
enableOutboundCheck = true;
};
Note: when enableOutboundCheck is on, a policy-map hit overrides the dane
security level per-destination. For the rare domain publishing both DANE and
MTA-STS this means WebPKI validation instead of DANE, never a downgrade to
cleartext, but a known trade-off of the socketmap approach.
Other options
| Option | Default | Description |
|---|---|---|
rspamdControllerPasswordFile |
null |
bcrypt hash file for the Rspamd web UI |
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
DANE rollover (only needed if the TLS private key ever changes. Traefik
reuses it across renewals): publish the new 3 1 1 record alongside the old
one, wait 2× the record TTL, deploy the new key, then remove the old record. The
cert-extract guard will warn in the journal if the live cert ever stops matching
the published TLSA record.
Operations
Backups. Three things hold state worth keeping: /var/vmail (the mail),
/var/lib/rspamd/dkim (DKIM private keys — losing them means DNS
re-publication), and /var/lib/redis-rspamd (trained Bayes/neural/greylist
state, persisted via RDB snapshots). Include all three in your snapshot/restic
scheme.
Monitoring. At minimum: an SMTP probe on port 25, and a journal check for
mail-cert-extract.*WARNING (the DANE guard). A broken TLSA record or expired
DNSSEC signature fails silently from your side while the world can't mail you.
Spamhaus. Postscreen queries zen.spamhaus.org with a return-code filter
(127.0.0.[2..11]) so blocked/over-quota answers can never count as listings.
Public mirrors only answer low-volume queries via your own recursive resolver;
register for the free Spamhaus DQS if volume grows.
Dovecot 2.4. NixOS 25.11 ships Dovecot 2.3.21; nixos-unstable is already on
2.4, which is a full config-syntax break. Expect a migration when moving to
NixOS 26.05 — the typed services.dovecot2 options will be migrated by nixpkgs,
the raw extraConfig in modules/dovecot.nix will need manual updates.
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
# Redis (unix socket, no password)
redis-cli -s /run/redis-rspamd/redis.sock ping
# Verify DKIM
rspamadm dkim_sign -d example.com -s mail < test.eml | grep DKIM
# Verify DANE end-to-end (see also scripts/generate-tlsa.sh output)
journalctl -u mail-cert-extract | grep -i warning
# 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
fail2ban-client status postfix-postscreen
# MTA-STS resolver (if enabled)
systemctl status postfix-mta-sts-resolver
# 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
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 |