senn-techsenn-tech
Self-Hosting
Self-Hosting2026-10-21· By Franz Senn

Out with Matomo, in with Umami: six years of web analytics rebuilt in one night

Matomo counted every visit on one of our machines for six years. On 21 October 2026 that ended: the containers are stopped and the port has no listener any more. In its place runs one Umami instance serving two brands of our group. The trigger was not dissatisfaction with the reporting. It was the question of how much attack surface we want to keep reachable from the public internet.

Umami Logo
Umami 3.3.1, image pinned to digest fa32d116. We run the instance ourselves and the data stays in house. (Quelle: Simple Icons, file umami.svg (CC0))

What Matomo measured at the end

We counted the rows before switching anything off, so nobody has to guess later. Final state of the database: 136,672 visits, 266,883 individually recorded actions, 47,981 distinct page titles and addresses, 5,529 conversions. The first visit was dated 25 October 2020, the last one 21 October 2026. Four sites were configured, two of which ever produced useful data.

Rows in the Matomo database on shutdown day, our own countActions per visit266883 · 266,883 rowsVisits136672 · 136,672 rowsTitles and addresses47981 · 47,981 rowsConversions5529 · 5,529 rows0280000
Counted directly in the Matomo database before the containers were stopped, 21 October 2026. Reversed transactions were already excluded there. (Quelle: Our own count, Matomo database, 21 Oct 2026)

The honest punchline in those numbers: one of the four sites never had a tracking tag in its template. Its 4,152 visits were requests from bots and probing tools, traffic. If you inherit an old statistic, check what it actually recorded before you mourn it. We nearly spent another six years treating that site as data that never existed.

Why replace rather than patch

Matomo is licensed under GPL-3.0, 21,966 GitHub stars on the day we measured, a mature product. Its distribution ships an Apache with mod_php, and that combination sat in our public path. On the same day we checked our own fleet against a PHP unauthenticated remote-code-execution advisory, and the answer was unpleasant: the last publicly reachable mod_php installation in the house was this analytics stack. You do not run a tracker on the argument that patches should arrive faster, when the application itself is the risk.

Umami is MIT licensed, 39,513 stars on that same day, a Next.js application with PostgreSQL behind it. The shipped image contains no PHP interpreter. That is not a verdict on Matomo, it is a decision about the operating model. A tracker should be small, boring, and free of runtime services in the public path.

The delivered tracking code is the hardest currency here, so we measured both files. Matomo's matomo.js was 68,253 bytes, Umami's script.js is 4,733 bytes, served uncompressed. The old script wrote cookies and device storage, the new one contains no setItem call at all and reads the browser's Do Not Track preference. That difference is exactly what a privacy statement can carry.

What we did not migrate

Six years of history do not move into a different counting model. The evening ended with a compressed SQL dump of 89,222,354 bytes, 485 table definitions and 391 data blocks, checksum recorded. A second copy sits on another machine and its checksum compared byte for byte identical. We did not want more history, because the models differ: Matomo counts visitors through a raw IP address, Umami keeps sessions without an IP column in the database. An import would have rebuilt a record we had abolished.

What got lost instead are the conversion goals. Matomo had eight of them, five were actually live: the visit to the contact page, a click on the phone number, one on the e-mail address, submitting the contact form, and the product enquiry. Umami knows URL filters and named events, so we have to rebuild those five. The click measurements themselves are already rewritten from Matomo to named events, the reporting layer is still missing.

The 403 that was not one

After the move, two things were broken: only a fraction of visits landed in the statistics, and the collection endpoint /api/send answered some traffic with 403. Two explanations held up for a while and both were wrong. A third claim about a filter list we had to withdraw explicitly, because that variable does not exist in the shipped image.

The real cause sat in a hardening variable we had set ourselves. The mechanism checks the first entry of a Forwarded-for header against a list of addresses it rejects. Traffic from an internal application that hairpins back into the same network through our own firewall shows up there with that firewall's address. We measured it with six requests: foreign address in first position, 200; our own address in first position, 403; no header at all, 403. The control case ran along, otherwise the verdict would be worth nothing.

The fix was a list of internal ranges, cut with care. The provider's /24 became a /27, so a whole ISP network does not accidentally drop out of the counting. The geo fields are useful again: a test request from a foreign machine in a German data centre arrived with country and region in the session table.

One remainder stays standing, and it stays standing in public: the first entry of a Forwarded-for header is writable. Anyone who wants to fake the country assignment can do it. Little of this is personal, because no IP is stored, and the hardening at the edge is prepared. The second restart of that web server, however, is not ours to run.

Retention is a job, not a setting

Our privacy statement now says: raw data, 14 months. That line is a claim as long as nobody deletes, and the shipped image brings no pruning job. So there is now a script with a single cutoff per run that counts each table first and only then deletes, with a lock against overlapping runs. The timestamp is computed once per run as an epoch, so the count and the delete use the identical condition.

Two traps sit inside such a job. The container is Alpine, its scheduler computes in local time and knows no time zone, so the crontab line fires twice a night and an hour gate inside the script makes sure summer and winter time land on the same UTC moment. The second trap was my own test: a process search with an exact name misses this system's scheduler even though it is running. Process list and service status carry that proof, not the one search call.

We finally measured retention with an aged control row we inserted ourselves: one session and one event dated January 2025. After the run both were gone, while the eight young sessions of the same day stayed untouched. The genuinely scheduled night run is still outstanding. It falls on the night of 22 October at 05:15 local time, which is 03:15 UTC.

What is left

Open items are the five conversion goals in the new interface, a second look at the logistics site that carries no tracking code at all, and rewiring a reporting platform that still points at the old Matomo API. Two logins into the interface need their own viewer before two-factor authentication can be required globally, otherwise we lock ourselves out. And we still rotate the database credentials of the retired application, because they sat in plaintext in a configuration file.

What remains is a smaller, more boring stack with measured behaviour: 4,733 bytes of tracking code, no cookies, no PHP in the public path, a database with no IP column, and a job that demonstrably deletes. For an application nobody needs to own, that is a good place to park.

Further Reading

Questions?
Does cookieless analytics still need consent?+

Consent is not the hard part, storage access is. Our consent banner still gates a script slot: someone who declines gets neither the script nor a request to the counter. We rewrote the privacy statement from Matomo to Umami, retention period included.

Why did you not import the old Matomo numbers?+

Because the counting models do not fit together. Matomo tracks visitors through a raw IP address, Umami keeps sessions with no IP column in its database at all. An import would rebuild a dataset we deliberately got rid of. Six years stay available as a verified SQL dump, not as a dashboard.

So is Umami running here without personal reference?+

There is no IP address in the database. Country, region and city are derived at request time and live only in the session table. What we cannot prove is the residual chance of re-identification through session patterns. Retention is capped at 14 months, and the pruning job is measured with an aged control row.