DKIM, SPF and DMARC for Postfix on Debian 13

11 min read

Mail from a new Postfix server lands in spam or gets refused until the domain publishes SPF, signs with DKIM and has a DMARC policy. Most guides for this still target Debian 8/9 or Ubuntu. The one trixie guide I found covers DKIM and nothing else. This tutorial sets up DKIM, SPF and DMARC with Postfix on Debian 13 in order. You check the result after every step, roll DMARC out in stages from p=none to p=reject, and keep a rollback path for when something breaks. It also covers the milter failure modes that can quietly stop or hold mail.

DMARC alignment does more than protect your reputation. It is what stops forged From: headers, and it is the defence the SMTP smuggling post on Postfix defenses relies on. A domain without p=reject is easier to spoof, smuggling or not.

What we build and prerequisites

One Postfix host, mail.example.org (203.0.113.25, 2001:db8::25), sends mail for example.org. opendkim signs outgoing mail and verifies incoming mail through a Unix milter socket that the chrooted Postfix daemons can reach. SPF and DMARC are DNS records only. Swap in your own names and addresses throughout.

ComponentDebian 13 packageVersion (trixie)
MTApostfix3.10.13-0+deb13u1
DKIM milteropendkim2.11.0~beta2-9.1 (binaries +b1)
Key and test toolsopendkim-toolssame source
SMTP test clientswakstrixie

You need a Postfix that already delivers mail and accepts authenticated submission on port 587. If relaying is still refused, fix that first with the Postfix relay access denied guide. You also need write access to the DNS zone, and a test mailbox at a large external provider where you can view the raw headers.

apt update
apt install opendkim opendkim-tools swaks dnsutils
postconf mail_version
postconf -d milter_default_action

Keep the second output in mind. On trixie's Postfix the man page lists shutdown as the current default. Upstream changed this default from tempfail to shutdown in Postfix 3.10.8, according to the release announcement. The upstream MILTER_README still describes the old tempfail default and is out of date on this point. Step 4 covers what that means.

Step 1: SPF record

SPF authorises IP addresses for the envelope sender (MAIL FROM) and the HELO name. Start with ~all (softfail) and tighten to -all once reports confirm nothing legitimate sends from elsewhere. Lower the TTL to 300 during the rollout so you can roll back quickly.

example.org.        300 IN TXT "v=spf1 ip4:203.0.113.25 ip6:2001:db8::25 ~all"
mail.example.org.   300 IN TXT "v=spf1 a -all"

The second record covers the HELO identity, which RFC 7208 recommends checking first. Every include, a, mx, ptr, exists and redirect costs one DNS lookup. More than 10 lookups gives a permerror, and that is easy to hit once you add a newsletter service and a helpdesk.

Verify:

dig +short TXT example.org
dig +short TXT mail.example.org

Step 2: DKIM keys with selectors

A selector names one key. The public key sits at selector._domainkey.example.org, and because a domain can publish several selectors at once, rotation needs no downtime. Use a selector that encodes the date, such as 2026a. Debian ships /etc/dkimkeys with restrictive permissions for this purpose.

install -d -o opendkim -g opendkim -m 0700 /etc/dkimkeys/example.org
opendkim-genkey -b 2048 -h sha256 -r -s 2026a -d example.org -D /etc/dkimkeys/example.org
chown opendkim:opendkim /etc/dkimkeys/example.org/2026a.private
chmod 0600 /etc/dkimkeys/example.org/2026a.private
cat /etc/dkimkeys/example.org/2026a.txt

-r limits the key to email, and -h sha256 limits it to SHA-256. The .txt file holds a ready-made record. A 2048-bit key is longer than 255 characters, so it comes split into several quoted strings. Most DNS providers accept them pasted as-is or joined. Do not insert spaces inside the p= value.

2026a._domainkey.example.org. 300 IN TXT ( "v=DKIM1; h=sha256; k=rsa; s=email; "
    "p=MIIBIjANBgkqh...first-part..."
    "...second-part...IDAQAB" )

Once the record is published, check that DNS matches the private key:

opendkim-testkey -d example.org -s 2026a -k /etc/dkimkeys/example.org/2026a.private -vvv

-k makes the tool compare the published public key with your private key file. It also complains if the key file is readable by other users. Do not continue until it runs without errors.

Step 3: opendkim with a socket inside the Postfix chroot

Debian's Postfix runs most daemons chrooted in /var/spool/postfix. The default opendkim socket at /run/opendkim/opendkim.sock is outside the jail, so a chrooted smtpd cannot see it. Debian's shipped opendkim.conf already includes a commented-out chroot socket line. Use a key table and signing table so that adding domains or rotating keys only means editing one line.

Syslog                  yes
SyslogSuccess           yes
Canonicalization        relaxed/simple
OversignHeaders         From
UserID                  opendkim
UMask                   007
Socket                  local:/var/spool/postfix/opendkim/opendkim.sock
PidFile                 /run/opendkim/opendkim.pid
TrustAnchorFile         /usr/share/dns/root.key
KeyTable                /etc/opendkim/key.table
SigningTable            refile:/etc/opendkim/signing.table
InternalHosts           /etc/opendkim/trusted.hosts
example.org-2026a example.org:2026a:/etc/dkimkeys/example.org/2026a.private
*@example.org example.org-2026a
127.0.0.1
::1

Setting InternalHosts replaces the built-in 127.0.0.1 default, so list localhost yourself. Remote users don't belong in this file. opendkim signs any message whose MTA session has a non-empty {auth_type} macro, which Postfix sets for SASL-authenticated clients. Check that it is in postconf milter_mail_macros.

Create the socket directory, then give Postfix group access. The UMask 007 above makes the socket group-writable:

install -d -o opendkim -g opendkim -m 0750 /var/spool/postfix/opendkim
install -d -m 0755 /etc/opendkim
usermod -aG opendkim postfix
systemctl restart opendkim
ls -l /var/spool/postfix/opendkim/

The listing should show opendkim.sock owned by opendkim:opendkim with mode srwxrwx---. If the socket is missing, run journalctl -u opendkim -n 50. The usual causes are a typo in a table path or key file permissions that RequireSafeKeys rejects.

Step 4: Connect Postfix and choose milter_default_action

When smtpd or cleanup runs chrooted, Postfix reads an absolute milter path relative to the queue directory. So /var/spool/postfix/opendkim/opendkim.sock becomes /opendkim/opendkim.sock. First check that both services are chrooted. The fifth column, chroot, should show y, which is what Debian's shipped master.cf sets. A - means the compiled-in default, and upstream Postfix 3.0 and later builds that as n. Do not treat - as chrooted.

postconf -M smtp/inet
postconf -M submission/inet
postconf -M cleanup/unix

If some are chrooted and others are not, the same path won't work for all of them. In that case switch both sides to TCP: inet:8891@localhost in opendkim, inet:localhost:8891 in Postfix. Note that the two use different syntax.

Warning: the reload below affects live mail. If the socket is wrong, every SMTP session gets the milter_default_action response. Watch the logs while you run it.

postconf -e 'smtpd_milters = unix:/opendkim/opendkim.sock'
postconf -e 'non_smtpd_milters = unix:/opendkim/opendkim.sock'
postconf -e 'milter_default_action = tempfail'
postconf -e 'internal_mail_filter_classes = bounce'
systemctl restart postfix
journalctl --since '5 min ago' | grep -i milter

Postfix must be restarted, not just reloaded, so its processes pick up the new opendkim group membership. non_smtpd_milters also signs mail injected with sendmail (cron, scripts). internal_mail_filter_classes = bounce signs Postfix's own bounces. If you run a content filter that reinjects mail, put the milter settings on individual master.cf services as Debian's opendkim README suggests, so each message passes through opendkim only once.

Which milter_default_action?

ValueWhen opendkim is downRisk
acceptMail goes out unsignedUnder p=reject, unsigned mail that also fails SPF alignment (forwarded mail, for example) is rejected far away, and you only learn of it from reports
tempfail4xx; clients retryA long-lived connection can tempfail every message, even when the outage is brief
shutdown421 and disconnectCurrent default on trixie; clients reconnect and retry
reject5xxLegitimate mail bounces permanently. Avoid
quarantineAccepted, frozen in the hold queueMail piles up without notice until someone runs postqueue -p and postsuper -H ALL

I chose tempfail in the commands above so the behaviour is explicit. Keeping the trixie default shutdown is just as good. Both fail visibly: senders retry and queues grow, so the problem shows up. accept and quarantine are the two options that hide a dead milter.

Step 5: Verify DKIM signing on real test mail

Send through the authenticated submission port to your external mailbox:

swaks --to you@external-provider.example --from alice@example.org --server mail.example.org --port 587 --tls --auth LOGIN --auth-user alice@example.org --h-Subject 'dkim test 2026a'
journalctl -u opendkim --since '5 min ago'

The opendkim log should contain a line like this:

DKIM-Signature field added (s=2026a, d=example.org)

Then open the message source at the receiving mailbox and find the receiver's Authentication-Results header:

Authentication-Results: mx.external-provider.example;
    dkim=pass header.d=example.org header.s=2026a;
    spf=pass smtp.mailfrom=alice@example.org;
    dmarc=none header.from=example.org

(Illustrative. Field order and extra properties differ between providers.) dmarc=none is correct at this stage because no policy is published yet. Also test the local path: echo test | mail -s cron-test you@external-provider.example should be signed too, which shows non_smtpd_milters works. For inbound mail, opendkim adds its own Authentication-Results header with a verification result.

Step 6: Staged DMARC rollout

DMARC passes when SPF or DKIM passes and the passing domain aligns with the From: domain. Alignment is relaxed by default, meaning same organisational domain. RFC 9989 (May 2026) replaced RFC 7489 and dropped the pct tag in favour of a testing flag t=y, which asks receivers to apply one policy level lower. Because the RFC is new, it is unclear how many receivers honour t=y yet. Use it as an extra safety net and don't count on it.

StageRecord at _dmarc.example.orgMove on when
1. Monitorv=DMARC1; p=none; rua=mailto:dmarc@example.orgReports show every legitimate source passing
2. Quarantine, testingv=DMARC1; p=quarantine; t=y; rua=mailto:dmarc@example.orgNo new failing sources
3. Quarantinev=DMARC1; p=quarantine; rua=mailto:dmarc@example.orgClean reports, no user complaints
4. Rejectv=DMARC1; p=reject; rua=mailto:dmarc@example.orgDone; switch SPF to -all

I give each stage at least two weeks so monthly senders such as invoicing systems show up in the reports. Aggregate reports arrive as gzip-compressed XML. If rua points at another domain, that domain has to authorise it with a TXT record example.org._report._dmarc.reports.example.net containing v=DMARC1. Without that record, receivers send nothing.

dig +short TXT _dmarc.example.org

After each change, run the swaks test again and confirm the receiver now shows dmarc=pass. Then send a spoof test from a host outside your SPF record, with your domain in the header (only against your own domain):

swaks --to you@external-provider.example --from test@example.org --h-From 'ceo@example.org' --server mx.external-provider.example

The result should be dmarc=fail and, depending on the stage, the spam folder or a rejection.

Rollback

If legitimate mail starts failing, go back one stage in DNS: reject to quarantine, or quarantine to none. With TTL 300 the change takes effect within minutes. If opendkim itself is broken, remove the milter while you fix it:

postconf -e 'smtpd_milters ='
postconf -e 'non_smtpd_milters ='
postfix reload

Mail sent while the milter is removed goes out unsigned. Under p=reject it then depends on SPF alignment, so drop to p=quarantine first if the fix will take long.

Step 7: Rotating DKIM keys

Warning: restarting opendkim briefly drops the socket. Sessions that hit that window get your milter_default_action response and retry.

opendkim-genkey -b 2048 -h sha256 -r -s 2026b -d example.org -D /etc/dkimkeys/example.org
chown opendkim:opendkim /etc/dkimkeys/example.org/2026b.private
chmod 0600 /etc/dkimkeys/example.org/2026b.private
opendkim-testkey -d example.org -s 2026b -k /etc/dkimkeys/example.org/2026b.private -vvv

Publish 2026b._domainkey, wait out the TTL, and run opendkim-testkey until it is clean. Then change both table lines to example.org-2026b and 2026b, run systemctl restart opendkim, and confirm that new mail shows header.s=2026b. Leave 2026a in DNS for about a week so queued and delayed mail still verifies. After that, publish it with an empty p=, which RFC 6376 defines as revoked, and finally delete it.

Common pitfalls

  • Socket permission denied: the postfix user is not in the opendkim group, Postfix wasn't restarted after usermod, or UMask is still 002/022.
  • Socket not found: the Postfix path is not relative to the chroot, or smtpd and cleanup have different chroot settings.
  • Remote users unsigned: they submit on port 25 without SASL, so {auth_type} is empty. Require authentication on 587.
  • Signatures break in transit: mailing lists rewrite the subject or footer. That's expected, and DMARC reports will show it.
  • Silent holds: milter_default_action = quarantine without monitoring of the hold queue.
  • SPF permerror: more than 10 DNS lookups across nested includes.

Takeaways

  • SPF published with ~all, plus a HELO record. Switch to -all at the reject stage.
  • DKIM key under /etc/dkimkeys, mode 0600, checked with opendkim-testkey -k.
  • Socket inside /var/spool/postfix, postfix in group opendkim, UMask 007.
  • milter_default_action set to tempfail or shutdown, never a silent option.
  • Every step checked with swaks and the receiver's Authentication-Results.
  • DMARC moved from none to quarantine to reject, with rua reports and a low TTL for rollback.
  • Selectors dated, and old keys revoked with an empty p= after rotation.

Sources

Comments

Latest alerts