Skip to content
· by Femke van der Berg

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.

dmarcparsedmarcelasticsearchkibanaself-hostedemail-authenticationrua-reportsdeliverability-operations

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:

  1. Number of domains protected — pricing for SaaS scales per domain; self-hosted has a fixed infrastructure cost regardless of domain count
  2. Operational engineering capacity — self-hosted requires Linux/Docker familiarity, ongoing maintenance, troubleshooting Elasticsearch
  3. 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.

Annual TCO comparison: SaaS vs self-hosted by domain count (USD)
Annual TCO DMARC reporting SaaS vs self-hosted by domain count
Categoría Dmarcian (annual USD)Valimail (annual USD)EasyDMARC (annual USD)Self-hosted parsedmarc + ES (annual USD)
1 domain 2286001801032
5 domains 114030009001032
25 domains 57001500045001032
100 domains 2280060000180002580

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 message
  • count — number of messages from that source-IP/result combination in the reporting period
  • policy_evaluated/disposition — what the receiver actually did (none, quarantine, reject)
  • policy_evaluated/dkim and policy_evaluated/spf — DMARC alignment outcomes (pass/fail)
  • auth_results/dkim/domain — the domain that signed the message via DKIM (the d= 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:

DMARC aggregate report processing pipeline — self-hosted architecture
Mailbox ReceiversGmail, Yahoo, Microsoft,Apple, Fastmail, etc.IMAP Mailboxdmarc-reports@example.comparsedmarcIMAP IDLE pollingXML parse + GeoIP enrichElasticsearch 8dmarc_aggregate indexdaily shards, TLS authSMTPIMAPHTTPSKibana 8parsedmarc dashboardsport 5601 internalnginx + TLSLet’s Encrypt certbasic auth + rate limitOperator Browserhttps://dmarc.example.comauthenticated sessionquery5601443Common failure point: IMAP polling silently failsparsedmarc keeps running, Elasticsearch keeps responding, Kibana shows old data only.Mitigation: external healthcheck calling Elasticsearch /_cat/indices?h=docs.count daily,alert if no new docs in 48h. Configure with Healthchecks.io or Uptime Kuma.

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:

Typical aggregate report source distribution — mature B2B SaaS sender
Typical aggregate report source distribution for mature B2B SaaS sender
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 pass AND aligned (d= matches header_from) AND SPF pass AND 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 pass but d= domain doesn’t match header_from (alignment failure)
  • OR SPF pass but 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 pass and 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 fail or none
  • SPF fail or none
  • 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=quarantine or p=reject) handles these automatically once configured; track in dashboard but don’t react to individual spoofing attempts

Pattern 6 — Unknown legitimate sender (investigate):

  • DKIM pass or fail — varies
  • SPF pass or fail — 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:

Sender pattern signatures: 6 patterns × 4 indicators
Sender pattern signatures across four diagnostic indicators
Categoría DKIM aligned (1=yes)SPF aligned (1=yes)Volume consistent (1=yes)Known IP/service (1=yes)
Authorised 1111
Misconfigured legit 00.511
Forwarding chain 100.70.8
Mailing list 000.70.7
Spoofing 000.30.1
Unknown legit 0.50.50.80.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:

ItemDmarcian (EUR equiv.)Self-hosted (EUR)Saving
Software/SaaS license2,21002,210
Hosting infrastructure0 (included)0 (existing capacity)0
Engineering setup (one-time)0185-185
Engineering ongoing (annual)~110 (auth integration ops)~555 (3h/month × €46/h)-445
Annual total2,3207401,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.

Annual cost: self-hosted parsedmarc vs SaaS DMARC, by domain count
Annual cost curves: self-hosted parsedmarc vs SaaS alternatives by domain count
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 60019996
5 600996480
10 7001992960
12 80023881152
15 80029851440
20 90039801920
30 90059702880
50 90099504800

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.

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.

Femke van der Berg

Senior Deliverability Engineer · Deliverability & Authentication

Something we should write about? Reply with your topic at [email protected].

Chat with us on WhatsApp