No description
  • Nix 85.5%
  • Shell 10.4%
  • Sieve 4.1%
Find a file
2026-07-05 14:55:44 +02:00
modules major(rewrite): removing deadcode and refactoring most of the stack 2026-07-05 14:55:44 +02:00
pkgs/postfix-mta-sts-resolver major(rewrite): removing deadcode and refactoring most of the stack 2026-07-05 14:55:44 +02:00
scripts major(rewrite): removing deadcode and refactoring most of the stack 2026-07-05 14:55:44 +02:00
sieve major(rewrite): removing deadcode and refactoring most of the stack 2026-07-05 14:55:44 +02:00
.gitignore refactor(all): much better structure, and getting closer 2026-04-05 13:35:56 +02:00
flake.lock major(rewrite): removing deadcode and refactoring most of the stack 2026-07-05 14:55:44 +02:00
flake.nix major(rewrite): removing deadcode and refactoring most of the stack 2026-07-05 14:55:44 +02:00
README.md major(rewrite): removing deadcode and refactoring most of the stack 2026-07-05 14:55:44 +02:00

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_ham via imapsieve
  • Dovecot authenticates via LDAP auth_bind directly 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, and resolv.conf pointing 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 with dig +ad TLSA _25._tcp.<some-dane-domain>, the ad flag must be present
  • Traefik running with a letsencrypt resolver 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