Self-hosted DMARC parsing 2026: parsedmarc + Elasticsearch + Kibana
Operational guide for self-hosted DMARC aggregate report parsing with parsedmarc, Elasticsearch and Kibana. Sender pattern recognition and SaaS migration.
DMARC aggregate reports are the only operational telemetry email senders get from receiving mailbox providers about authentication outcomes. They arrive as compressed XML attachments, daily, from every receiver that has implemented DMARC reporting (Gmail, Yahoo, Microsoft, Apple/iCloud, Fastmail, and dozens of smaller providers). The XML is unreadable without parsing, the volume becomes unmanageable past ~5 domains, and the operational decisions they enable (move from p=none to p=quarantine to p=reject, identify unauthorised senders, track third-party authentication health) are critical to email deliverability in 2026 — particularly under the Gmail/Yahoo/Microsoft bulk sender requirements that we covered in our bulk sender compliance post.
The market for DMARC reporting tools split into two paths years ago. The SaaS path (Dmarcian, Valimail, EasyDMARC, OnDMARC, Agari, ProofPoint) charges per-domain monthly fees, ranging from $19/month for small operators to $20,000+/year for enterprise plans. The self-hosted path centres on parsedmarc — a Python module and CLI maintained by Sean Whalen since 2018 — combined with Elasticsearch and Kibana for visualisation. Both paths produce essentially the same operational outputs: dashboards showing source IPs, alignment rates, unauthorised senders, and policy readiness signals.
What every existing article describes is what aggregate reports are. What no existing article consolidates is the production-grade self-hosted operational guide: a working docker-compose stack with real security (passwords, TLS, reverse proxy auth), the TCO comparison with explicit per-domain monthly numbers vs the four major SaaS competitors, the six sender pattern signatures that distinguish actual spoofing from common misconfiguration vs benign forwarding, and the criteria for when migrating from SaaS to self-hosted is justified. This post is that consolidated guide. Written for operators managing 5-100 domains who have done the basics (DMARC published, RUA tag pointing somewhere, reading XMLs occasionally) and need the operational depth to scale.
The DMARC reporting decision: SaaS or self-hosted
The choice between SaaS and self-hosted DMARC reporting is structural — once data is flowing into a system, switching costs are non-trivial because dashboards, alerting, and stakeholder reporting become integrated with the chosen platform. The decision rationally splits on three dimensions:
- Number of domains protected — pricing for SaaS scales per domain; self-hosted has a fixed infrastructure cost regardless of domain count
- Operational engineering capacity — self-hosted requires Linux/Docker familiarity, ongoing maintenance, troubleshooting Elasticsearch
- Data sensitivity / compliance posture — self-hosted keeps DMARC data on infrastructure you control; SaaS sends it to vendor systems
The TCO crossover happens around 5-8 domains for most teams. Below 5 domains, SaaS is usually cheaper in total cost (cost-of-engineering exceeds cost-of-license). Above 10 domains, self-hosted is usually substantially cheaper. The gap widens as domain count increases — at 25 domains, self-hosted is typically 3-5x cheaper; at 100+ domains, it can be 10-15x cheaper.
| Categoría | Dmarcian (annual USD) | Valimail (annual USD) | EasyDMARC (annual USD) | Self-hosted parsedmarc + ES (annual USD) |
|---|---|---|---|---|
| 1 domain | 228 | 600 | 180 | 1032 |
| 5 domains | 1140 | 3000 | 900 | 1032 |
| 25 domains | 5700 | 15000 | 4500 | 1032 |
| 100 domains | 22800 | 60000 | 18000 | 2580 |
Self-hosted cost based on Hetzner CX31 (4 vCPU / 8GB / 80GB SSD) at $20/month + 0.4 FTE devops engineer time at first month setup, then 2-3 hours/month maintenance. The $1,032 figure is hosting + monitoring tools; the $2,580 at 100 domains adds an upgraded CX41 server for higher throughput. SaaS pricing from public 2026 rate cards: Dmarcian Pro $19/domain/month, EasyDMARC Plus $15/domain/month. Valimail figures approximate post-DigiCert acquisition (2025) — Align tier from $19/month, Enforce custom pricing from ~$2,000/year scaling with domain count; multi-domain enterprise commitments often $30-50/domain/month effective. Numbers approximate; SaaS pricing has discounts at scale not always reflected.
The chart’s most important detail: self-hosted cost is roughly flat from 1 to 100 domains (only doubles at the top end due to server upgrade), while SaaS scales linearly. The economic case for self-hosted becomes overwhelming somewhere between 5 and 25 domains depending on which SaaS provider you’re comparing against.
The non-economic factors complicate the decision. Self-hosted requires:
- Linux server administration competence
- Docker/docker-compose familiarity
- Elasticsearch operational knowledge (heap sizing, index management, shard limits)
- Ability to interpret raw parsedmarc output when dashboards don’t surface a question
- Time investment for initial setup (typically 4-12 hours for an experienced operator) plus 2-3 hours monthly maintenance
For teams without that engineering capacity, even with TCO favouring self-hosted, SaaS can be the right choice because the failure mode of self-hosted (broken parser, unreadable Elasticsearch, lost data) is operationally more disruptive than the cost of SaaS. The structural recommendation: self-hosted for operators with engineering capacity protecting 8+ domains; SaaS for operators below that threshold or without ops capacity.
Two market shifts in 2025-2026 affected this calculus. First, Valimail’s acquisition by DigiCert in 2025 moved Valimail toward enterprise-only positioning with custom pricing on Enforce (starting around $2,000/year on application, scaling to five-figure commitments at multi-domain scale). Some Valimail customers have been re-evaluating since the acquisition, partly due to support model changes and partly due to reduced visibility into roadmap direction. Second, PCI DSS v4.0 mandated DMARC for organisations processing payment card data as of March 2025, joining the Gmail/Yahoo/Microsoft bulk sender requirements (covered in our bulk sender compliance post) and existing CISA BOD 18-01 mandates for US federal domains. These compounding compliance pressures pushed previously DMARC-indifferent organisations to start reporting, which in turn pushed many to evaluate self-hosted to control USD-denominated SaaS costs at the new compliance-driven domain count.
What’s actually in an aggregate report (and what gets missed)
Before the operational setup, briefly anchor on what aggregate reports contain. An aggregate report (the rua URI in your DMARC record receives these) is an XML document with this approximate structure:
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>[email protected]</email>
<report_id>17248456789012345678</report_id>
<date_range><begin>1712880000</begin><end>1712966400</end></date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<p>reject</p>
<sp>reject</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>209.85.220.41</source_ip>
<count>1524</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers><header_from>example.com</header_from></identifiers>
<auth_results>
<dkim><domain>example.com</domain><result>pass</result></dkim>
<spf><domain>example.com</domain><result>pass</result></spf>
</auth_results>
</record>
</feedback>
The structure repeats per source IP per (SPF result × DKIM result × disposition) combination. A typical aggregate report from Google for a moderately-sized sending domain contains 50-500 records. Across multiple receivers per day, multiple domains, you accumulate 2,000-20,000 records daily — far beyond manual XML inspection.
The fields most operational decisions depend on:
source_ip— the IP that sent the messagecount— number of messages from that source-IP/result combination in the reporting periodpolicy_evaluated/disposition— what the receiver actually did (none,quarantine,reject)policy_evaluated/dkimandpolicy_evaluated/spf— DMARC alignment outcomes (pass/fail)auth_results/dkim/domain— the domain that signed the message via DKIM (thed=value)auth_results/spf/domain— the domain SPF authenticated (the envelope sender domain)
The trap most operators fall into: confusing raw SPF/DKIM pass with DMARC alignment. A message can show <spf>pass</spf> in auth_results but still fail DMARC because the SPF-authenticated domain (e.g., bounce.sendgrid.net) doesn’t align with the From header domain (example.com). The policy_evaluated section is what matters for DMARC outcomes; the auth_results section is what was authenticated raw without alignment consideration.
The production-grade docker-compose stack
The full stack: parsedmarc + Elasticsearch 8 + Kibana 8 + nginx reverse proxy + GeoIP database. Tested working as of April 2026. The configuration below is production-grade — not the simplified versions in most blog tutorials.
Directory structure:
/opt/dmarc-stack/
├── docker-compose.yml
├── .env
├── parsedmarc/
│ └── parsedmarc.ini
├── kibana/
│ └── kibana.yml
├── nginx/
│ ├── nginx.conf
│ └── certs/
└── geoip/
.env file (the only place secrets live):
ELASTIC_PASSWORD=GENERATE_LONG_RANDOM_STRING_HERE
KIBANA_PASSWORD=GENERATE_DIFFERENT_LONG_RANDOM_STRING_HERE
DMARC_IMAP_HOST=imap.example.com
DMARC_IMAP_USER=[email protected]
DMARC_IMAP_PASS=imap_password_here
GEOIPUPDATE_ACCOUNT_ID=your_maxmind_account_id
GEOIPUPDATE_LICENSE_KEY=your_maxmind_license_key
STACK_VERSION=8.13.4
NGINX_BASIC_AUTH_USER=dmarc
NGINX_BASIC_AUTH_PASS=GENERATE_NGINX_AUTH_PASSWORD
docker-compose.yml:
version: '3.8'
services:
setup:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
volumes:
- certs:/usr/share/elasticsearch/config/certs
user: "0"
command: >
bash -c '
if [ ! -f config/certs/ca.zip ]; then
echo "Creating CA";
bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip;
unzip config/certs/ca.zip -d config/certs;
fi;
if [ ! -f config/certs/certs.zip ]; then
echo "Creating certs";
echo -ne "instances:\n - name: elasticsearch\n dns:\n - elasticsearch\n - localhost\n ip:\n - 127.0.0.1\n" > config/certs/instances.yml;
bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key;
unzip config/certs/certs.zip -d config/certs;
fi;
chown -R 1000:0 config/certs;
echo "All done!";
'
healthcheck:
test: ["CMD-SHELL", "[ -f config/certs/elasticsearch/elasticsearch.crt ]"]
interval: 1s
timeout: 5s
retries: 120
elasticsearch:
depends_on:
setup:
condition: service_healthy
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
volumes:
- certs:/usr/share/elasticsearch/config/certs
- esdata:/usr/share/elasticsearch/data
environment:
- node.name=elasticsearch
- cluster.name=dmarc-cluster
- discovery.type=single-node
- ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
- bootstrap.memory_lock=true
- xpack.security.enabled=true
- xpack.security.http.ssl.enabled=true
- xpack.security.http.ssl.key=certs/elasticsearch/elasticsearch.key
- xpack.security.http.ssl.certificate=certs/elasticsearch/elasticsearch.crt
- xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.license.self_generated.type=basic
- "ES_JAVA_OPTS=-Xms2g -Xmx2g"
ulimits:
memlock: { soft: -1, hard: -1 }
healthcheck:
test: ["CMD-SHELL", "curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 -u elastic:${ELASTIC_PASSWORD} | grep -q 'cluster_name'"]
interval: 10s
timeout: 10s
retries: 60
kibana:
depends_on:
elasticsearch: { condition: service_healthy }
image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
volumes:
- certs:/usr/share/kibana/config/certs
- kibanadata:/usr/share/kibana/data
environment:
- SERVERNAME=kibana
- ELASTICSEARCH_HOSTS=https://elasticsearch:9200
- ELASTICSEARCH_USERNAME=kibana_system
- ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}
- ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=config/certs/ca/ca.crt
healthcheck:
test: ["CMD-SHELL", "curl -s -I http://localhost:5601 | grep -q 'HTTP/1.1 302 Found'"]
interval: 10s
timeout: 10s
retries: 60
geoipupdate:
image: maxmindinc/geoipupdate:latest
environment:
- GEOIPUPDATE_ACCOUNT_ID=${GEOIPUPDATE_ACCOUNT_ID}
- GEOIPUPDATE_LICENSE_KEY=${GEOIPUPDATE_LICENSE_KEY}
- GEOIPUPDATE_EDITION_IDS=GeoLite2-City GeoLite2-Country GeoLite2-ASN
- GEOIPUPDATE_FREQUENCY=24
volumes:
- geoipdata:/usr/share/GeoIP
parsedmarc:
depends_on:
kibana: { condition: service_healthy }
geoipupdate: { condition: service_started }
image: ghcr.io/domainaware/parsedmarc:latest
volumes:
- certs:/certs
- geoipdata:/usr/share/GeoIP
- ./parsedmarc/parsedmarc.ini:/etc/parsedmarc.ini:ro
environment:
- SSL_CERT_FILE=/certs/ca/ca.crt
command: parsedmarc -c /etc/parsedmarc.ini
nginx:
image: nginx:1.27-alpine
ports:
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
kibana: { condition: service_healthy }
volumes:
certs:
esdata:
kibanadata:
geoipdata:
parsedmarc/parsedmarc.ini:
[general]
save_aggregate = True
save_forensic = False
nameservers = 1.1.1.1 9.9.9.9
batch_size = 50
ip_db_path = /usr/share/GeoIP/GeoLite2-City.mmdb
[mailbox]
watch = True
delete = False
test = False
[imap]
host = ${DMARC_IMAP_HOST}
user = ${DMARC_IMAP_USER}
password = ${DMARC_IMAP_PASS}
port = 993
ssl = True
reports_folder = INBOX
archive_folder = INBOX/Archive
[elasticsearch]
hosts = https://elasticsearch:9200
user = elastic
password = ${ELASTIC_PASSWORD}
ssl = True
cert_path = /certs/ca/ca.crt
Note on parsedmarc INI variable expansion: parsedmarc 9.x does not natively expand ${VAR} syntax in the INI file. The patterns above work in two configurations: (1) using docker-compose variable substitution in the file at container-start time via envsubst (add a parsedmarc-init container that templates the INI), or (2) using parsedmarc CLI flags directly in the docker-compose command line. The cleaner production pattern is option 1:
parsedmarc-init:
image: alpine:3.19
volumes:
- ./parsedmarc/parsedmarc.ini.template:/template.ini:ro
- parsedmarc-config:/config
environment:
- DMARC_IMAP_HOST=${DMARC_IMAP_HOST}
- DMARC_IMAP_USER=${DMARC_IMAP_USER}
- DMARC_IMAP_PASS=${DMARC_IMAP_PASS}
- ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
command: >
sh -c 'apk add --no-cache gettext &&
envsubst < /template.ini > /config/parsedmarc.ini'
Then mount parsedmarc-config:/etc/parsedmarc.ini:ro in the parsedmarc service. This isolates secrets to environment variables and renders the final INI at startup.
nginx/nginx.conf (reverse proxy with basic auth):
events { worker_connections 1024; }
http {
upstream kibana { server kibana:5601; }
server {
listen 443 ssl http2;
server_name dmarc.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
auth_basic "DMARC Dashboard";
auth_basic_user_file /etc/nginx/certs/htpasswd;
location / {
proxy_pass http://kibana;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}
Setup sequence (one-time, after creating the files above):
# 1. Generate htpasswd for nginx
htpasswd -c nginx/certs/htpasswd dmarc
# 2. Place TLS cert + key in nginx/certs/
# (use Let's Encrypt with certbot, or self-signed for testing)
# 3. Bring stack up
cd /opt/dmarc-stack
docker compose up -d
# 4. Wait ~2 minutes for Elasticsearch + Kibana to initialize
# Verify with: docker compose logs -f elasticsearch kibana
# 5. Reset kibana_system password (one-time)
docker compose exec elasticsearch bin/elasticsearch-reset-password --batch --user kibana_system
# Copy the password into .env and restart kibana
docker compose restart kibana
# 6. Import parsedmarc dashboards into Kibana
# Download from: https://github.com/domainaware/parsedmarc/tree/master/parsedmarc/resources
# In Kibana → Stack Management → Saved Objects → Import the .ndjson file
# 7. Configure DMARC RUA tag in DNS
# Update DMARC record: rua=mailto:[email protected]
# Wait 24-48 hours for first reports to arrive
# 8. Monitor parsedmarc logs
docker compose logs -f parsedmarc
The resource footprint at steady state on a Hetzner CX31 (4 vCPU, 8GB RAM, 80GB SSD, ~$20/month):
- Elasticsearch: ~3GB RAM, low CPU (5-15% sustained)
- Kibana: ~500MB RAM
- parsedmarc: ~200MB RAM (spikes during IMAP polling)
- nginx + GeoIP: ~50MB RAM
- Disk usage growth: ~50-200MB/month per protected domain (highly variable based on report volume)
For 10 domains protected, expect 1-2GB of new Elasticsearch index data per month, roughly 20-30GB per year. The default index lifetime in parsedmarc is per-day shards, which Elasticsearch 8 limits to 1000 shards by default — about 3 years of daily data. After 3 years, increase the cluster shard limit or implement index lifecycle management to roll old indices into monthly aggregates.
The architectural data flow
The pipeline from receiver-generated XML to operator-readable dashboard:
Six-component pipeline: receivers → IMAP mailbox → parsedmarc → Elasticsearch → Kibana → nginx → operator. The most operationally fragile point is IMAP polling — failure modes here are silent and require external monitoring.
The most operationally important detail in this architecture is the failure mode highlighted in red: parsedmarc IMAP polling can silently fail (IMAP credentials expire, mailbox server changes settings, network issues to the IMAP server) without any visible signal in Kibana. The dashboard keeps showing data from the last successful ingest, sometimes for days. The mitigation is external monitoring: a daily healthcheck that queries Elasticsearch for new document count and alerts if it hasn’t grown in 48 hours.
Sample healthcheck script (run via cron daily):
#!/bin/bash
# /opt/dmarc-stack/scripts/healthcheck.sh
ES_HOST="https://localhost:9200"
ES_USER="elastic"
ES_PASS="$(grep ELASTIC_PASSWORD /opt/dmarc-stack/.env | cut -d'=' -f2)"
# Get document count from yesterday's index
YESTERDAY=$(date -d 'yesterday' +%Y-%m-%d)
INDEX_PATTERN="dmarc_aggregate-${YESTERDAY}*"
DOCS=$(curl -s -k -u "$ES_USER:$ES_PASS" \
"$ES_HOST/_cat/indices/$INDEX_PATTERN?h=docs.count" | tail -1)
if [ -z "$DOCS" ] || [ "$DOCS" -eq 0 ]; then
# Send alert (Healthchecks.io, Slack webhook, email, etc.)
curl -X POST "https://hooks.slack.com/services/YOUR_WEBHOOK" \
-d '{"text":"DMARC aggregate ingest stopped — no new docs in last 24h"}'
exit 1
fi
echo "OK: $DOCS docs in $INDEX_PATTERN"
exit 0
Run with: 0 9 * * * /opt/dmarc-stack/scripts/healthcheck.sh > /var/log/dmarc-healthcheck.log 2>&1
Reading aggregate reports: the typical source distribution
Once data is flowing, the immediate question is: what does normal look like? The chart below shows the typical source distribution for a moderately-mature B2B SaaS sender with proper authentication setup:
| Categoría | Share of report records |
|---|---|
| Authorised primary domain (Workspace/M365) | 42 |
| Authorised ESPs (transactional) | 28 |
| Authorised ESPs (marketing) | 14 |
| Forwarding chains (legitimate) | 8 |
| Mailing lists (legitimate) | 4 |
| Spoofing attempts | 2 |
| Misconfigured legitimate services | 1.5 |
| Unknown sources | 0.5 |
Distribution from analysis of aggregate reports across 12 client deliverability engagements 2025-2026, B2B SaaS profile, post-DMARC enforcement (p=quarantine or p=reject). Distribution differs substantially for: cold outreach (more spoofing/unknown), e-commerce (more ESP marketing volume), pre-enforcement domains (much more unknown sources). Numbers are typical, not benchmarks; high variance per audience.
The chart’s most important details:
- Authorised sources (rows 1-3): together 80-85% of volume in a properly-configured domain. If you see less than 70% from authorised sources, your authentication coverage has gaps.
- Forwarding chains (row 4): 5-10% is normal in B2B contexts where employees forward to personal accounts. These show as DKIM-pass (signature survives forwarding) but SPF-fail (envelope sender rewritten by forwarder).
- Mailing lists (row 5): 2-5% is typical. Mailman/Listserv-type services modify message body, breaking DKIM, but pass SPF for the list domain.
- Spoofing + misconfigured + unknown (rows 6-8): together should be under 5% in a mature setup. If you see over 10% combined, you have an investigation backlog.
The actionable insight: the unknown source percentage is the most operationally important number in your dashboard. A 0.5% unknown rate means your authorisation list is essentially complete. A 5% unknown rate means you have authorised senders you don’t know about (shadow IT, abandoned services still sending) or active spoofing campaigns. Track unknown source % weekly; treat sustained increases above 1% as a backlog item.
The six sender pattern signatures
The most operationally valuable skill in DMARC report analysis is distinguishing between failure types. A naive read of the data treats all DMARC failures as equivalent threats; the operationally correct read recognises six distinct patterns with different remediation paths.
Pattern 1 — Authorised sender (no action needed):
- DKIM
passAND aligned (d=matches header_from) AND SPFpassAND aligned - High volume from a known IP range (your ESP, your Workspace, your transactional service)
- Geographic distribution matches your sender infrastructure
Pattern 2 — Misconfigured legitimate sender (configuration fix):
- DKIM
passbutd=domain doesn’t match header_from (alignment failure) - OR SPF
passbut envelope-sender domain doesn’t match header_from - Source IP belongs to a known service (ESP, CRM, marketing platform you use)
- Volume consistent with expected business pattern
- Action: enable DKIM signing with your domain at the third-party service, or add their SPF include
Pattern 3 — Forwarding chain (no action needed):
- DKIM
passand aligned (signature survives forwarding) - SPF
fail(forwarder rewrote envelope sender) - Common forwarder IPs (cs.example.com, edu.gateway.com, individual employer mail servers)
- Low volume per IP, distributed across many IPs
- Action: none — DMARC passes via DKIM alignment
Pattern 4 — Mailing list (no action needed):
- DKIM
fail(list software modified body) - SPF
pass(list domain authorised) - But neither aligns with header_from
- Source IP belongs to listserv-type service (mailman.lists.example.org)
- Action: ARC signing if subscribed users complain about deliverability; otherwise none
Pattern 5 — Spoofing attempt (block, no enforcement change):
- DKIM
failornone - SPF
failornone - Source IP from unusual geography for your business
- Often residential ISP ranges, compromised servers, hosting providers known for abuse
- Volume erratic (could be 1 message or 50,000)
- Action: enforcement (
p=quarantineorp=reject) handles these automatically once configured; track in dashboard but don’t react to individual spoofing attempts
Pattern 6 — Unknown legitimate sender (investigate):
- DKIM
passorfail— varies - SPF
passorfail— varies - Source IP belongs to a known cloud provider (AWS, Azure, GCP) or a known ESP
- Volume consistent (not erratic)
- The pattern that requires attention: this is shadow IT, abandoned services, or pending acquisitions sending mail you didn’t know about
- Action: identify the service via reverse DNS + WHOIS, contact the sending team, decide authorise or stop
The matrix below collapses these patterns into the four observable signals operators use for triage:
| Categoría | DKIM aligned (1=yes) | SPF aligned (1=yes) | Volume consistent (1=yes) | Known IP/service (1=yes) |
|---|---|---|---|---|
| Authorised | 1 | 1 | 1 | 1 |
| Misconfigured legit | 0 | 0.5 | 1 | 1 |
| Forwarding chain | 1 | 0 | 0.7 | 0.8 |
| Mailing list | 0 | 0 | 0.7 | 0.7 |
| Spoofing | 0 | 0 | 0.3 | 0.1 |
| Unknown legit | 0.5 | 0.5 | 0.8 | 0.6 |
Each pattern scored 0-1 on four indicators. Authorised senders score ~4 total; spoofing scores ~0.4 total. The diagnostic discriminators: DKIM alignment, SPF alignment, volume consistency, IP-to-known-service mapping. The 'unknown legit' pattern is the operationally hardest — scores around 2.5, requiring active investigation.
The chart’s diagnostic value: a sender pattern with a stacked bar height near 4 (all indicators positive) is operationally trustworthy and can move directly to authorise. A sender with stacked height near 0-0.5 is a clear spoofing pattern and stays handled by enforcement. The tricky operational range is stacked heights between 1.5-2.5 — these require active investigation to determine if the source is shadow IT, an abandoned service, or genuine spoofing using compromised infrastructure.
When to migrate from SaaS to self-hosted
The operational checklist for migration:
Migrate from SaaS to self-hosted if all of these are true:
- You manage 8+ active domains (TCO crossover)
- You have at least 0.3 FTE engineering capacity for ongoing operations (initial setup + monthly maintenance + occasional incident debugging)
- You have a Linux server budget allocated ($25-50/month for a Hetzner / OVH / DigitalOcean instance is sufficient)
- You’re comfortable troubleshooting Elasticsearch (heap pressure, shard limits, index management)
- Your DMARC use case is operational (alerting, dashboards, trend analysis) rather than compliance-presentation (executive summaries with auditor-friendly attestations)
Stay on SaaS if any of these are true:
- You manage fewer than 5 domains (TCO favours SaaS)
- You have no engineering capacity for self-hosted ops (the failure mode of broken self-hosted is operationally worse than the cost of SaaS)
- Your compliance posture requires SOC2-attested DMARC processing infrastructure (vendors like Valimail provide this; self-hosted requires you to attest your own infrastructure)
- You need integrated remediation workflows (some SaaS vendors auto-update SPF/DKIM via vendor connectors)
Hybrid approach (the pragmatic option for some organisations):
- Run self-hosted for primary monitoring (the operational dashboard your team uses daily)
- Maintain a SaaS account for executive reporting (the polished dashboards your CISO shows the board)
- The SaaS account uses a subset of domains (the high-value ones) to keep cost manageable
- The self-hosted instance covers all domains for operational completeness
European case: German financial services organisation with 12 domains
A Frankfurt-based financial services organisation we worked with in 2025 had been on Dmarcian Pro for 14 months, paying $2,388/year (~€2,210/year at €1.08/USD at the time, $199/month) for 12 protected domains. The trigger for re-evaluation was twofold: their procurement team flagged DMARC reporting as one of several USD-denominated SaaS expenses to consolidate during a vendor-rationalisation cycle (USD billing introduces FX volatility on EUR P&L), and their compliance team flagged the PCI DSS v4.0 March 2025 requirement for DMARC on payment-processing domains, which would extend monitoring to 4 additional payment-related subdomains under the Dmarcian per-domain pricing — pushing the renewal quote to $3,180/year ($265/month) for 16 domains. The organisation also fell within DORA scope (effective January 2025) for ICT third-party risk management, which strengthened the case for evaluating self-hosted alternatives. The combination forced an explicit build-vs-buy evaluation that procurement-only pressure hadn’t.
Pre-migration state:
- Dmarcian Pro: $2,388/year USD (~€2,210/year, FX-exposed)
- 12 domains protected (4 active marketing, 6 transactional/operational, 2 legacy)
- Engineering capacity: 1 senior systems engineer with ~10 hours/month available for DMARC work
- Existing infrastructure: Hetzner CX31 (4 vCPU / 8GB / 80GB) running other operational tools, available capacity ~50%, hosted in Hetzner’s Falkenstein region (Germany)
Migration evaluation:
- Self-hosted incremental cost: €0 hosting (existing CX31 had capacity), €0 software (parsedmarc is open source), €185 one-time engineering time for setup,
3 hours/month ongoing maintenance (€46/month at fully-loaded engineer cost in Frankfurt market) - Annual self-hosted cost: ~€555 (engineering time) vs ~€2,210 SaaS
- Annual savings:
€1,655 ($1,790 USD equivalent) - ROI on setup time: ~6 weeks
Post-migration state (6 months later):
- Stack running on existing CX31 in Falkenstein, ~15% additional CPU load, ~2GB additional RAM usage
- Setup completed in 8 hours over 2 days by the senior systems engineer
- Initial week: 2 incidents (IMAP credential rotation broke ingest, healthcheck script revealed the gap within 24 hours)
- Steady state: 2-3 hours/month maintenance (usually Elasticsearch index management and minor Kibana dashboard tweaks)
- Operational improvement: dashboard customised to organisation-specific patterns (European mailbox provider forwarding chains showing distinct geography — Orange.fr SRS rewrites, T-Online.de relay paths, Web.de/GMX intermediary behaviour), unavailable in Dmarcian’s standard dashboards
Annual TCO comparison verified post-migration:
| Item | Dmarcian (EUR equiv.) | Self-hosted (EUR) | Saving |
|---|---|---|---|
| Software/SaaS license | 2,210 | 0 | 2,210 |
| Hosting infrastructure | 0 (included) | 0 (existing capacity) | 0 |
| Engineering setup (one-time) | 0 | 185 | -185 |
| Engineering ongoing (annual) | ~110 (auth integration ops) | ~555 (3h/month × €46/h) | -445 |
| Annual total | 2,320 | 740 | 1,580 |
The 68% cost reduction was the headline number. The non-economic wins:
- Custom Kibana dashboards for European-specific patterns (e.g., Orange.fr mandatory ARC sealing requirements introduced September 2024, T-Online.de forwarding behaviour, GMX/Web.de intermediary chains) that Dmarcian’s US-centric default dashboards don’t surface
- Data sovereignty: DMARC data never left Hetzner’s German infrastructure, simplifying GDPR Article 32 documentation and removing one third-party vendor from DORA ICT risk register
- Unlimited domains added without licensing renegotiation (the organisation was planning 4-6 domain acquisitions through 2026 as part of subsidiary integration)
The migration risk that materialised: the IMAP credential rotation incident in week 2 caused a 36-hour ingest gap that the team only noticed because the healthcheck script we’d implemented (the script earlier in this post) alerted them. Without that healthcheck, they estimated they would have noticed the gap 3-7 days later when a routine dashboard review surfaced suspiciously old data. The healthcheck, in retrospect, was the most valuable single component of the self-hosted setup.
| Categoría | Self-hosted parsedmarc (engineering time only, USD/year) | Dmarcian Pro (~$199/month base scaling per domain after 5) | PowerDMARC entry tier (~$8/domain/month, monitoring-only) |
|---|---|---|---|
| 1 | 600 | 199 | 96 |
| 5 | 600 | 996 | 480 |
| 10 | 700 | 1992 | 960 |
| 12 | 800 | 2388 | 1152 |
| 15 | 800 | 2985 | 1440 |
| 20 | 900 | 3980 | 1920 |
| 30 | 900 | 5970 | 2880 |
| 50 | 900 | 9950 | 4800 |
Self-hosted scaling reflects ~3 hours/month maintenance baseline regardless of domain count, with marginal increases above 30 domains for Elasticsearch index management. Dmarcian Pro pricing approximated from public rate cards as of April 2026 — actual quotes vary with annual commitment, support tier, and account team negotiation; treat the curve as indicative not contractual. PowerDMARC entry-tier figure is the published $8/domain/month tier (monitoring only — does NOT include automated DKIM rotation or many enterprise features). Curves do not include hosting costs (~€18-40/month VPS for self-hosted, included for SaaS), nor engineering time spent on SaaS auth integration ops (~1h/month per domain at scale). Break-even is sensitive to engineer hourly rate: at €120/hour Frankfurt rate, the self-hosted curve shifts up ~50%; at €40/hour nearshore rate it shifts down 30%.
Operational KPIs vendor articles don’t expose
The metrics that should drive DMARC operational decisions, with explicit thresholds:
KPI 1 — Aligned volume percentage: target ≥98.5% sustained over 21 consecutive days before considering enforcement. Below 95% indicates significant authorised-sender alignment work remaining; below 90% means you have unknown legitimate senders.
KPI 2 — Unknown source percentage: target ≤0.5% sustained. Above 1% triggers backlog investigation. Calculated as: (records with no recognisable source IP/domain) / (total records) × 100.
KPI 3 — Time to enforcement: target 90-180 days from initial DMARC publication (p=none) to p=reject. Tracked in 4 stages: discovery (p=none, monitor) → alignment work → testing (p=quarantine with low pct) → enforcement (p=reject 100%). Slower than 6 months indicates organisational bottleneck rather than technical complexity.
KPI 4 — Cost per domain protected: target ≤$50/domain/year for self-hosted at 10+ domains, ≤$200/domain/year for SaaS. Above these thresholds, operational decisions should be questioned — either consolidate domains, upgrade infrastructure, or change tooling.
KPI 5 — Days since last spoofing investigation: target investigation cycle of 30 days max. If your dashboard goes more than 30 days without anyone investigating an unknown source pattern, the operational discipline has lapsed.
These KPIs are not industry-standard benchmarks (no body publishes them) — they’re operational targets we use across deliverability engagements that produce stable post-enforcement DMARC programs. The standard SaaS dashboards present aligned/unaligned percentages but rarely surface unknown-source percentage as a primary metric, which is the operational signal most predictive of compliance gaps.
What we recommend at Blue Spirit
For transparency: we run a self-hosted parsedmarc + Elasticsearch + Kibana stack for our own DMARC monitoring, plus our PowerMTA hosting clients receive DMARC operational guidance as part of standard onboarding. We do not have a relationship with Dmarcian, Valimail, EasyDMARC, or other SaaS DMARC vendors as resellers.
The recommendation framework for 2026:
For 1-4 domains: stay on SaaS. EasyDMARC at $15/domain/month or DMARCReport’s free tier (1 domain, 10K reports) typically gives the best operational outcome relative to cost-of-engineering. The TCO doesn’t justify self-hosted setup time at this scale.
For 5-10 domains with engineering capacity: evaluate self-hosted seriously. The TCO crosses over in this range, and the operational benefits (custom dashboards, data sovereignty, unlimited domains) compound. Plan 8-16 hours initial setup time and ~3 hours/month ongoing.
For 10+ domains regardless of engineering capacity: self-hosted is almost always the right answer. SaaS pricing scales linearly to numbers that quickly exceed the cost of dedicated DMARC operational engineering. If engineering capacity is constrained, the answer is hire/contract a DMARC specialist, not pay $20K+/year to a SaaS vendor.
For organisations with compliance-presentation requirements (regulated industries, public sector, audit-driven environments): hybrid is often correct. Self-hosted for operational use; SaaS subscription on a subset of high-visibility domains for executive reporting and auditor-friendly summaries. The combined cost is usually still well below pure SaaS at scale.
If you want help evaluating which approach fits your organisation, designing the self-hosted stack architecture, or migrating from SaaS to self-hosted while preserving historical data — that’s part of our deliverability audit engagement. Most clients we audit running 8+ domains on SaaS DMARC reporting are spending substantially more than necessary, often without operational benefit beyond what self-hosted would provide.
Honest summary
DMARC aggregate report parsing is one of the email operational disciplines where the open-source self-hosted path produces equivalent or better operational outcomes at substantially lower cost than commercial SaaS alternatives, for organisations with the engineering capacity to operate it. parsedmarc + Elasticsearch + Kibana is a mature, production-grade stack that has been in use since 2018 and continues active development in 2026. The economic case is overwhelming above 8 domains; the operational case (custom dashboards, data sovereignty, unlimited domains) is overwhelming above 25 domains. The case for SaaS remains strong only for organisations below 5 domains, or those without engineering capacity, or with specific compliance-presentation requirements.
The most common reason organisations stay on SaaS past the rational TCO crossover is operational inertia — the system is running, dashboards are populated, switching feels risky. The honest reality is that the migration is technically straightforward (8-16 hours for an experienced operator with the docker-compose stack in this post), data sovereignty is preserved, custom dashboards are usually a step up from generic SaaS dashboards, and the cost savings compound year-over-year. Operators who have made the migration rarely regret it; operators who have stayed on SaaS at 25+ domains are usually paying $10-50K+/year for outcomes they could replicate self-hosted for $1-3K/year.
The operational discipline that determines DMARC success is not the tooling choice — both paths produce equivalent core capabilities. It’s the consistency of the operator workflow: weekly dashboard review, monthly unknown-source investigation, quarterly KPI reporting, treating unknown senders as a backlog item rather than ignoring them. Whether that workflow runs on Dmarcian or on a self-hosted Kibana doesn’t change the discipline required. The tooling decision should be driven by economics and operational fit; the discipline decision is what actually determines whether your DMARC program reaches enforcement and stays there.
A consideration worth surfacing for 2026 specifically: the compounding compliance pressure (PCI DSS v4.0 in March 2025, Gmail/Yahoo/Microsoft bulk sender requirements throughout 2024, CISA BOD 18-01 federal enforcement) has produced a wave of organisations starting DMARC programs simultaneously. Many of these organisations went directly to SaaS DMARC vendors during the compliance scramble because deployment time was the binding constraint. Now, 12-18 months into operations, many are evaluating whether the SaaS bill is sustainable as their domain count grew during M&A activity, brand consolidation, or subdomain expansion. The framework in this post is specifically calibrated for organisations in that position — operationally mature DMARC users now hitting the SaaS pricing wall — and the migration playbook (preserve historical data, run parallel for 30 days, cut over with rollback option) lets them switch without operational risk to their compliance posture. Self-hosted DMARC parsing in 2026 is no longer a fringe operator choice; it’s the default for any organisation past 8 sender domains where engineering capacity exists.
Related reading
The DMARC enforcement journey from p=none to p=reject is covered in the DMARC enforcement 2026 survival guide. For the broader unified observability stack that DMARC parsing slots into see unified email observability with Grafana, parsedmarc, Postmaster, and SNDS. The authentication baseline that DMARC enforces alignment over is in our email authentication 2026 guide. For SPF flattening to support DMARC alignment without paid services see the SPF 10-lookup limit fix. DKIM key rotation that produces DMARC signal continuity is detailed in DKIM key rotation on PowerMTA + OpenDKIM.
A final caveat: not every organisation should self-host. If your team has zero existing Linux/Docker experience, no on-call rotation for the parsedmarc service, and no monitoring infrastructure already in place, the operational overhead of self-hosting will likely exceed your SaaS savings even at 15-20 domains. The break-even calculation we use during deliverability audits accounts for engineering hours at fully-loaded cost ($120-180/hour for senior engineers in US, London, Zurich and the Nordics; $80-130/hour in Continental EU markets like Germany, Netherlands, France and Spain; $40-80/hour in nearshore engineering markets such as Eastern Europe and Iberia outside the major capitals), and at those rates, self-hosting only wins economically when the SaaS bill exceeds approximately $200/month. Below that threshold, SaaS is the operationally rational choice regardless of domain count or compliance posture.
Something we should write about? Reply with your topic at [email protected].