E-mail remains the most widely attacked service on the internet. Phishing and spam use spoofed sender addresses to steal credentials or money. So that receiving mail servers can verify whether an e-mail really comes from the domain it claims, there are three established standards: SPF, DKIM and DMARC.
SPF (Sender Policy Framework) is a DNS-based mechanism that lets the owner of a domain define which servers (IP addresses or hostnames) are allowed to send e-mail on behalf of that domain.
An SPF record lives as a TXT record directly in the DNS zone:
example.org. IN TXT "v=spf1 mx a include:_spf.provider.net -all"
v=spf1 – record versionmx / a – the servers behind the MX and A records may sendinclude: – include third-party infrastructure (e.g. a newsletter provider)-all – everything else is rejected (hard fail), alternatively ~all (soft fail)Important: There is only one SPF record per domain, and it may contain a maximum of 10 DNS lookups.
The receiving mail server compares the IP address of the sending server against the policy in the record. Each mechanism (a, mx, ip4:, ip6:, include: …) yields a match/no-match, and the qualifier decides what follows from it:
+ (Pass) – sender is authorized (default when no qualifier is set)- (Hard Fail) – sender is explicitly not authorized → rejection~ (Soft Fail) – suspicious but tolerated → typically flagged as spam? (Neutral) – no statementIn practice, ~all has proven useful during the rollout phase; for production domains with a well-documented sending infrastructure, -all is the goal.
The 10-lookup limit is the most common pitfall: include:, a, mx, ptr, exists and redirect= each count as one DNS query. Nested include:s of your provider count as well. Once the limit is exceeded, evaluation returns permerror – and many receivers treat that like a fail. The remedy is trimming the record down to services actually in use, or “flattening” (resolving includes into IP lists) – though the latter means manual maintenance whenever provider IPs change.
Common mistakes seen in the wild:
permerrorinclude:s after switching providers → legitimate mail failsFrom: header – closing this gap is exactly what DMARC is forDKIM (DomainKeys Identified Mail) cryptographically signs every outgoing e-mail. The sending server places a signature in the mail header; the recipient fetches the matching public key from DNS and verifies that the mail was not modified in transit.
The public key is published as a TXT record under a so-called selector:
selector._domainkey.example.org. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBg..."
The private key and selector are generated and configured in your own mail system (e.g. Postfix with OpenDKIM, Rspamd or Exchange).
The selector is a freely chosen name via which the recipient finds the matching public key (selector1._domainkey.example.org). Multiple selectors can coexist – e.g. separate keys for newsletters and transactional mail, or rotating keys without interrupting mail flow: publish the new selector, switch over, retire the old one after a transition period.
On key choice:
A practical side effect of DKIM: the signature survives forwarding, whereas SPF fails at the forwarding server. This is why DMARC should be configured so that DKIM alignment alone suffices to consider a message legitimate.
DMARC (Domain-based Message Authentication, Reporting and Conformance) ties SPF and DKIM together and adds two things:
A typical DMARC record:
_dmarc.example.org. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.org; pct=100; adkim=r; aspf=r"
The most important fields:
p=none – monitor only (quarantine = spam folder, reject = refuse)rua= – mailbox where the aggregate reports (XML) arriveadkim / aspf – alignment mode (relaxed or strict)pct – percentage of messages the policy applies toA single SPF or DKIM pass is not enough for DMARC: what matters is alignment, i.e. whether the authenticated domain matches the visible From: header. In relaxed mode (adkim=r / aspf=r, the default), agreement of the organizational domain suffices – a mail with From: newsletter@example.org, sent via bounce.provider.net with SPF on provider.net, does not align. A correct example: DKIM signature from example.org and From: @example.org → alignment present. In strict mode (s) the domain must match exactly, which is often unattainable when using external service providers.
Additional relevant points:
sp= – a separate policy for subdomains, if these should be stricter or more lenient than the main domainrua= delivers aggregate reports (summaries), ruf= the detailed failure reports – many large providers do not support the latter at all, or only partially, for privacy reasonsrua= points to an external domain, the receiver requires a verification record there (example.org._report._dmarc.…) to prevent report abuseReports are delivered as XML attachments by e-mail – with several large providers this quickly adds up to dozens of mails per day. This is exactly where a DMARC analyzer comes in.
Commercial DMARC platforms usually bill per domain and per message volume – expensive if you manage many domains. DmarcAnalyzer (GitHub) is a free alternative (Apache-2.0):
p=none to p=reject
For the next steps we assume a Linux system with a current Docker installation – just like in our Vaultwarden article, with Traefik as reverse proxy managing Let’s Encrypt certificates automatically.
DmarcAnalyzer consists of an app container (ASP.NET Core + React, console and report ingestion in one process) and a PostgreSQL database.
First the domain needs a DMARC record whose rua= points to a mailbox the analyzer can poll later:
_dmarc.example.org. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.org"
In addition, the desired hostname (dmarc.example.org in this example) must have an A/AAAA record pointing to the server so Traefik can obtain the certificate.
In this example we run DmarcAnalyzer behind a reverse proxy named Traefik that manages letsencrypt certificates automatically. The setup mirrors our Vaultwarden stack.
docker-compose.yaml:
services:
traefik:
container_name: traefik
image: traefik:latest
ports:
- 80:8080
- 443:8443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik-data:/letsencrypt
networks:
- default
command:
- --entrypoints.web.address=:8080
- --entrypoints.websecure.address=:8443
- --providers.docker
- --log.level=ERROR
- --certificatesresolvers.leresolver.acme.httpchallenge=true
- --certificatesresolvers.leresolver.acme.email=info@example.org
- --certificatesresolvers.leresolver.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.leresolver.acme.httpchallenge.entrypoint=web
labels:
- "traefik.http.routers.http-catchall.rule=hostregexp(`.+`)"
- "traefik.http.routers.http-catchall.entrypoints=web"
- "traefik.http.routers.http-catchall.middlewares=redirect-to-https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
restart: always
dmarc-analyzer:
container_name: dmarc-analyzer
image: ghcr.io/dmarc-analyzer-net/dmarc-analyzer:latest
restart: unless-stopped
networks:
- default
depends_on:
postgres:
condition: service_healthy
environment:
# Console and report ingestion in one process
APP_MODE=all
ConnectionStrings__Default=Host=postgres;Port=5432;Database=dmarc_analyzer;Username=dmarc;Password=${POSTGRES_PASSWORD}
Database__MigrateOnStartup=true
# Encrypts the report mailbox credentials at rest,
# see .env file below
Security__CredentialEncryptionKey=${DMARC_ENCRYPTION_KEY}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/auth/setup > /dev/null"]
interval: 10s
timeout: 5s
retries: 20
start_period: 150s
labels:
- "traefik.enable=true"
- "traefik.http.routers.dmarc.rule=Host(`dmarc.example.org`)"
- "traefik.http.routers.dmarc.entrypoints=websecure"
- "traefik.http.routers.dmarc.tls=true"
- "traefik.http.routers.dmarc.tls.certresolver=leresolver"
- "traefik.http.routers.dmarc.service=dmarc"
- "traefik.http.services.dmarc.loadbalancer.server.port=8080"
postgres:
container_name: dmarc-postgres
image: postgres:18-alpine
restart: unless-stopped
networks:
- default
environment:
POSTGRES_DB=dmarc_analyzer
POSTGRES_USER=dmarc
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
PGDATA=/var/lib/postgresql/data
volumes:
- ./dmarc-pgdata:/var/lib/postgresql/data
networks:
default:
internal: false
enable_ipv6: true
ipam:
driver: default
config:
- subnet: fd00:1::/64
gateway: fd00:1::1
Notes on the setup:
8080, which is why the load balancer label points there.start_period because database migrations run on first start.Next to the docker-compose.yaml an .env file is required. The key encrypts the mailbox credentials stored in the database (AES-256-GCM). Keep it safe – if it is lost, all configured mailbox credentials have to be entered again:
user@bar:~$ echo "DMARC_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env
user@bar:~$ echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
First, we create a user and appropriate directories:
root@bar:~# adduser -g docker dmarc
root@bar:~# su - dmarc
dmarc@bar:~$ mkdir traefik-data
dmarc@bar:~$ mkdir dmarc-pgdata
dmarc@bar:~$
Now place the above docker-compose.yaml under /home/dmarc/docker-compose.yaml, along with the .env file in the same directory.
Both files should be adjusted according to local conditions (hostnames, ACME e-mail, passwords).
Now you can download the containers and start the application:
dmarc@bar:~$ docker compose pull
dmarc@bar:~$ docker compose up -d
dmarc@bar:~$
After Traefik has fetched the certificate, the console is available at the configured URL. On first visit you create the admin account – registration is locked afterwards.

Then set up the following in the web interface:
rua= reports arrive (e.g. dmarc-reports@example.org)

From then on a background process polls the mailbox regularly, unpacks the XML reports and evaluates SPF/DKIM alignment per sending source.
Once the first reports have arrived, you can see per domain which sources send legitimately and where things break. Only once all legitimate sources authenticate cleanly should the policy be tightened step by step:

p=none → p=quarantine → p=reject
With pct= the tightening can additionally be rolled out gradually.
DmarcAnalyzer can authenticate users against any OpenID Connect provider – the project documentation includes guides for Zitadel, Keycloak, Authentik, Google and Microsoft Entra ID. Important to know: OIDC only replaces the login, not authorization. Roles and client assignments remain inside the application; an SSO session is internally identical to a password session.
The feature is enabled via environment variables, with the callback living under /api/v1/auth/oidc/callback:
environment:
Auth__Oidc__Enabled=true
Auth__Oidc__Authority=https://id.example.org
Auth__Oidc__ClientId=dmarc-analyzer
Auth__Oidc__DisplayName=Example ID
Auth__Oidc__AutoProvision=false
Once Auth__Oidc__Enabled is active, the login page additionally shows a “Sign in with …” button. With AutoProvision=false (the recommended production setting), an administrator creates users in advance without a password; on first SSO login the account is then linked via its verified email address.
SPF, DKIM and DMARC are mandatory today for every domain that sends e-mail. With DmarcAnalyzer you get free, self-hosted monitoring without per-domain licensing fees, keeping your data away from cloud services. Combined with Docker and Traefik, operating it is as easy as our other self-hosting stacks.
If you have any questions about SPF, DKIM and DMARC in your IT environment or need support, please contact us.