Turning DMARC on produces a stream of gzipped XML from every receiver that sees your mail, and nothing on my machine could read it. Separately, a product about to have customers needed a status page. Both jobs have paid products behind them, both are things any company ends up buying, and both fit on a box I already pay for.
Here we will explore what I compared and what I ran:
- parsedmarc against dmarc-srg for reading the reports, and what each costs in resident memory
- Atlassian Statuspage, Better Stack and Instatus against Upptime for publishing uptime
- Upptime running twice rather than once, and the uptime monitor I wrote myself that neither replaced
The box is a Hetzner CAX, 4 vCPU and 8 GB, carrying about 31 containers behind Dokploy. Dokploy itself is the largest thing on it at 656 MB resident, which sets the scale: anything under 200 MB is not the problem.
parsedmarc is the better parser
parsedmarc has 1,276 stars against dmarc-srg’s 298, and it is the more capable of the two by a distance. It parses aggregate reports in both the RFC 7489 draft schemas and the new RFC 9990 schema, parses failure reports, parses SMTP TLS reports, and reads its input from IMAP, the Microsoft Graph API or the Gmail API.
What it does not have is a web interface. Output goes to Elasticsearch, OpenSearch, Splunk, PostgreSQL, Kafka, S3, Graylog, syslog, a webhook, or JSON and CSV files, and the viewing is done by whatever you point at that store. The repository ships premade dashboards for OpenSearch, Splunk and Grafana.
The version most guides describe is parsedmarc into OpenSearch with OpenSearch Dashboards on top, and that is the one that does not fit here. OpenSearch is a JVM search engine, which puts a heap plus a dashboards process on the box, and I would be running a search cluster to look at a few hundred XML files a month.
The lighter combination is real and I did not take it: dashboards/grafana/Grafana-DMARC_Reports-PostgreSQL.json exists in the repository, so parsedmarc into Postgres with Grafana reading it is a supported path that needs no search engine. I skipped it because there is no Grafana on this box, and adding one to view DMARC alone is a second service to run and secure.
dmarc-srg is the smaller one with a UI attached
dmarc-srg is PHP against MySQL or MariaDB and it brings its own interface, which is the whole reason it is cheap. It has no aggregation engine because SQL is the aggregation engine.
Measured after it had been running a while:
dmarcsrg-xdkcec-app-1 14.53MiB / 192MiB
dmarcsrg-xdkcec-db-1 89.87MiB / 256MiB
dmarcsrg-xdkcec-fetcher-1 916KiB / 128MiB
About 105 MB for the viewer, the database and the ingestion loop together. The database is 85% of it, and MariaDB idling at 90 MB is the floor for any of these options that keeps history.
That figure is also why I stopped trying to share the MySQL that Bugsink already runs on this box. I had assumed a second database instance would cost 300 to 400 MB and that sharing would save most of it. The Bugsink MySQL container measures 18.6 MB resident. Sharing would have saved almost nothing and cost an edit and a redeploy of the error tracker to get it.
Three things broke while building the image
dmarc-srg publishes no image. There is no Dockerfile in the repository, nothing on Docker Hub under liuch, and every community image I found has zero stars, so I built one from the v2.3 tarball on php:8.3-apache. It comes out around 520 MB on disk. Three failures, in the order I hit them.
The imap extension no longer exists. dmarc-srg’s documented ingestion path is IMAP, and libc-client-dev will not install: Debian dropped the UW IMAP library as unmaintained and PHP removed the extension from core in 8.4. Cloudflare Email Routing gives no mailbox anyway, so I switched to the directories source, which watches a folder. The Apache image carries no cron either, so ingestion is a sidecar off the same image looping utils/fetch_reports.php every 900 seconds.
PDO, not mysqli. I installed mysqli, the container started, and every command returned the same four words:
Message: Error! Database error
Credentials were right and new mysqli('db', ...) connected from inside the container, which made it look like a configuration fault. dmarc-srg catches the exception and prints that string regardless of cause. Calling Core::instance()->database()->state() directly from a scratch file gave the real one:
Liuch\DmarcSrg\Exception\DatabaseException: Database error
prev: PDOException: could not find driver
It connects over PDO. Adding pdo_mysql to the extension list fixed it, and the schema initialised on the next run.
Purging libzip-dev takes libzip with it. I had installed libzip-dev, built the zip extension, then removed the dev package with --auto-remove to keep the image small. That deletes libzip.so, so PHP cannot load an extension it is configured to load, and the fetcher logged Required dependency is missing: ext-zip. dmarc-srg needs it because Google and Microsoft send their reports compressed.
One more, found after the site was live: Apache’s default docroot serves a page reading “This page is obsolete. Please change the path to the html root directory in your web server settings.” The repository keeps classes/, config/ and utils/ alongside a public/ folder, and pointing Apache at the repository root exposes config/conf.php as well as looking broken.
What is not working yet
Nothing delivers reports into the watched directory. rua points at an address that Cloudflare forwards to Gmail, and there is no step between the two, so the fetcher runs every fifteen minutes against an empty folder.
This is the part where parsedmarc would have been the better choice and I picked wrong. It reads IMAP and the Gmail API itself, so the same forwarding setup needs no glue at all. dmarc-srg could have read IMAP too if the PHP extension still existed. Getting reports in now means either a small IMAP sidecar in another language or a Cloudflare Email Worker, both of which are work that parsedmarc would not have needed.
Status pages: what the hosted ones charge
Atlassian Statuspage is the one most companies land on. Public pages run Free at 100 subscribers, 25 components and two team members, then $29 a month for Hobby at 250 subscribers, $99 for Startup at 1,000, $399 for Business at 5,000 and $1,499 for Enterprise. Private pages and audience-specific pages are separate price lists again.
The thing worth understanding before comparing anything to it is that Statuspage does not check whether your site is up. It publishes what you tell it. Monitoring is a separate product and a separate bill.
Better Stack bundles the two. Its free plan includes 10 monitors and one status page, and its paid telemetry bundles start at $30 a month in Europe, $45 in the United States, with responder seats at $34 a month on top. Instatus is the other name that comes up and has a free tier, but its prices render client-side and I did not get numbers out of it, so I am not going to quote any.
For two URLs, Better Stack’s free tier would have done the job. I went with Upptime because I already had it working and because of where it runs.
Upptime runs off the machine it reports on
Upptime, 17,114 stars, is a repository template rather than a service. GitHub Actions runs the checks on a schedule, commits the results, and GitHub Pages serves the page. There is no server, and Actions is free on public repositories.
The property that matters is that none of it is on my box. A status page hosted on the infrastructure it reports on tells you nothing on the day you need it. My own uptime monitor has exactly that flaw: swarm-uptime is a Hono API, a BullMQ worker, Postgres and an Astro frontend, and I wrote it as the realistic multi-service stack for the AWS versus Docker Swarm comparison. It serves status.danieljohnmorris.com from the same machine as everything it watches. Upptime now monitors that stack from outside, and consolidating onto one of the two is still open.
The recipe, having done it twice:
- Create from the
upptime/upptimetemplate, then pollgit ls-remote --headsuntil a ref exists, because the template copy is asynchronous and the repository is briefly empty - The default branch is
master, notmain gh api -X PUT repos/OWNER/REPO/actions/permissions/workflow -f default_workflow_permissions=write, or Setup CI fails with a 403- Set
GH_PAT, push.upptimerc.yml, runsetup.yml, wait for agh-pagesbranch - Point a CNAME at
OWNER.github.iowith proxied off. Proxying stops GitHub issuing the certificate
Two things cost me time. themeConfig.colors.brand is a Docusaurus key, not an Upptime one, so setting it does nothing and the page ships in stock teal. Upptime has no brand colour key at all: the routes are theme, a themeUrl pointing at your own CSS, or an inline css key overriding custom properties. And a page loaded over http before the Pages certificate has issued fails every stylesheet, because the links are absolute https:// URLs, which reads like a separate CSS problem and is not.
Two instances, not one catch-all
The personal instance watches 24 endpoints, including Dokploy, the analytics install, the error tracker and a row of 3D demos. Lightning CV needed a Status link in its footer, and pointing paying customers at that page would show them the entire estate.
So there is a second instance with two monitors and its own domain. A catch-all page with sections would work, but per-product repositories mean per-product incident history and no leaking of unrelated hostnames, and the marginal cost of a second one is a template copy and a CNAME.
The theming carries over with one rule I would keep. Up, down and degraded stay in Upptime’s green, red and amber even on a brand-styled page. Those three colours carry the meaning, and brand blue in their place removes it. Everything else takes the product’s own tokens.