A three-part, hands-on series on moving a large Matomo analytics instance from Matomo Cloud to a self-hosted, containerized install. This first part explains what the migration actually is, the three invariants that silently break it, and the landmines that aren’t in any dump. Parts 2 and 3 cover the full procedure and the cutover.
All figures below (≈13 M visits, 23 sites, ≈22 GB dump, ≈430 tables, ≈1h45 import) come from a real migration but are given only as orders of magnitude. Client-specific names have been replaced by generic placeholders (
analytics.example.com,mariadb,matomo). Re-measure everything on your dump.
What « migrating off Matomo Cloud » really means
The instinct is to picture a clean export/import of a running application. It isn’t. The single most important fact to internalize:
Matomo Cloud support gives you a SQL dump of the database only. The Cloud
config.ini.phpis never included. You regenerate it on the self-hosted side.
Everything else follows from that. The database carries your history, your sites, your users (including hashed passwords and 2FA secrets), and most in-database settings. But the application wiring — database connection, security salt, trusted hosts, proxy headers, the list of enabled plugins, geolocation database, SMTP — is not in the dump. You rebuild it.
So the migration is really four moves:
- Import the Cloud dump into a fresh database.
- Point a self-hosted Matomo at that database (write
config.ini.php). - Run
core:updateto align the schema to your (≥ Cloud) Matomo version. - Verify, then reconfigure everything the dump didn’t carry.
Target architecture
The setup this series targets is two containers on an internal network:
mariadb— MariaDB 10.11 LTS, with a persistent volume for/var/lib/mysql.matomo— a Matomo image (version pinned, see below), persistent volume for/var/www/html, sitting behind a reverse proxy that terminates HTTPS.
MariaDB — not MySQL — is deliberate, and it’s the source of the nastiest invariant below.
The three invariants that break the migration if you get them wrong
These three are not « best practices ». Each one, set wrong, produces a broken or misleading result — sometimes silently. Check all three before you import.
Invariant 1 — The table prefix must match the dump exactly
Matomo prefixes every table (matomo_log_visit, matomo_option, …). The prefix is a config value (tables_prefix in config.ini.php). If it doesn’t match the prefix baked into the dump, Matomo sees an empty database — even though every row is present.
The prefix is often matomo_, but it can be empty. Read it from the first CREATE TABLE in the dump; don’t assume:
gunzip -c "$DUMP" | grep -m1 -i 'CREATE TABLE'
# matomo_access -> prefix is "matomo_"
# access -> prefix is EMPTY -> tables_prefix = ""
The empty-prefix trap. When the prefix is empty, some tables collide with SQL reserved words (
option,site). Every SQL statement must backtick them:`option`,`site`. Worse, automated config generation (e.g. the official Matomo Docker image driven by env vars) can silently omittables_prefixwhen the value is an empty string — the empty variable simply isn’t written. Matomo then falls back to its internal defaultmatomo_and fails withTable 'matomo.matomo_option' doesn't existbehind a misleading « Matomo is already installed » page. Always re-read the writtenconfig.ini.phpand confirm the literal linetables_prefix = ""is present before touching the UI.
Invariant 2 — Your Matomo version must be ≥ the Cloud version
core:update migrates the schema forward. If your self-hosted Matomo is older than the version that produced the dump, it refuses: « database created by a more recent version ». The Cloud version is stored in the dump as the version_core option.
Here’s the catch that surprises everyone: Matomo Cloud permanently runs on the alpha channel, roughly one minor version ahead of the latest stable. Waiting for a stable release that is ≥ Cloud never converges — the moment a stable catches up, Cloud has moved on.
The consequence:
- You must run the same alpha build as Cloud on the self-hosted side.
- No alpha image is published to the container registry — a check of the official
matomoimage found hundreds of tags, zero containing « alpha », only stable versions. The alpha exists only as an archive onbuilds.matomo.org. - So you build your own image: start from the official image and swap the sources for the pinned alpha archive. Pin the exact build (nightlies change daily), and keep a copy of the archive —
builds.matomo.orgpurges old nightlies.
If the go-live slips and Cloud advances past your dump, the image must be re-qualified against the new dump’s version_core.
Invariant 3 — MySQL 8 collation doesn’t exist in MariaDB
Matomo Cloud runs on MySQL 8. Its dumps often carry the collation utf8mb4_0900_ai_ci, which does not exist in MariaDB. Import aborts immediately with:
Unknown collation 'utf8mb4_0900_ai_ci'
The fix is a single global substitution to utf8mb4_general_ci, done in-stream during the import so you don’t write a second 20 GB file to disk:
gunzip -c "$DUMP" | sed 's/utf8mb4_0900_ai_ci/utf8mb4_general_ci/g' | ...import...
The coupling you must never break.
utf8mb4_general_cihas to be the same value in three places: the server default, theCREATE DATABASE, and the target of thesed. Why it matters beyond cosmetics: imported tables carry their collation explicitly, but Matomo creates new tables constantly — a fresharchive_numeric_YYYY_MM/archive_blob_YYYY_MMpair every month — and those inherit the database default, not the imported tables’ collation. If the two values diverge, the database slowly becomes mixed and joins between old and new tables fail withIllegal mix of collations. The symptom appears weeks later, at the first month rollover — nearly impossible to trace back to the migration. After import, confirm a single collation across all tables:SELECT table_collation, COUNT(*) FROM information_schema.tables WHERE table_schema='matomo' GROUP BY table_collation;One row = healthy. Multiple rows = mixed estate, fix before cutover.
The landmines that aren’t in the dump
The three invariants above are the ones that abort the import. The following are worse in a way: they let the migration appear to succeed, then break tracking or reporting later. None of them are in the dump.
Tag Manager: the #1 risk to live tracking
If your sites are tracked via Matomo Tag Manager, this is the single most dangerous step. Two independent problems:
- Tag Manager is present in the image but not enabled. It’s a free bundled plugin, but the list of enabled plugins lived in the Cloud
config.ini.php— which you don’t have. So it defaults to inactive. - The container JS files are not in the dump. Files like
container_XXXX.jsare generated on the filesystem when a release is published. The dump only holds the database. So the releases exist in the DB, but nocontainer_*.jsexists on disk →https://analytics.example.com/js/container_XXXX.jsreturns 404 and every site using that container stops being tracked.
The fix (Part 2 details it): enable the plugin, regenerate all released containers, and verify a container actually serves a 200 — not a 404. And these files live in /var/www/html/js/, which must be on a persistent volume or they vanish on the next container recreation.
Reverse-proxy headers: get them wrong and every visit is falsified
Behind a reverse proxy, Matomo sees the proxy’s IP on every request unless you tell it which forwarded-for header to trust:
[General]
proxy_client_headers[] = "HTTP_X_FORWARDED_FOR"
proxy_host_headers[] = "HTTP_X_FORWARDED_HOST"
Miss this and 100% of visits carry the proxy IP from the very first minute — data that is falsified and unrecoverable. The header name must match what your proxy actually sends.
Persistent volumes: the silent config wipe
config.ini.php, installed plugins, the GeoIP database, and the generated js/container_*.js files must all live on persistent volumes. If any of them is written to the container’s ephemeral layer, it disappears on the first restart — and Matomo either reruns its installer or serves 404 containers, silently. The only reliable check is to podman restart before cutover and confirm everything survives.
First archiving must run the day before go-live
On a large history (millions of visits) the first full archive run takes hours. If you start it the morning of go-live, it won’t finish before users log in — reports would be slow and incomplete exactly when everyone looks. This isn’t recoverable inside the cutover window. It has to run the evening before.
Scheduled reports fire themselves
The Cloud’s scheduled email reports are migrated in the database and trigger automatically at the end of the first archive run. Two failure modes:
- With a working SMTP relay, reports go out to the real recipients during your rehearsal — embarrassing at best.
- Without SMTP, the send fails and
core:archiveexits with code 1 even though archiving succeeded — a false alarm every day.
So: decide the fate of scheduled reports before any archive run, and never wire up SMTP before you’ve dealt with them.
Premium plugins: nothing is lost, but reports disappear
Premium plugins (FormAnalytics, ActivityLog, Heatmaps, Funnels, …) that were part of the Cloud subscription are not licensed on your self-hosted instance by default. Crucially:
A missing premium plugin deletes nothing. Its tables stay intact in the database; the reports simply become invisible in the UI. Re-adding the license later restores full access, history included — no re-import, no data manipulation.
The only action required is to tell users which reports are temporarily unavailable.
What migrates vs what you rebuild
A useful mental split before Part 2:
| Carried by the dump | Rebuilt on self-hosted (not in the dump) |
|---|---|
| Historical data (visits, actions, conversions) | config.ini.php (DB connection, salt, trusted hosts) |
| Sites, goals, segments, dashboards | Geolocation (GeoIP) database |
| Users + passwords + 2FA secrets | SMTP / email relay |
In-database settings (option, plugin_setting) | Premium plugins & licenses |
| Scheduled reports (⚠️ fire on their own) | Scheduled archiving (systemd timer) |
Where this leaves us
The migration is: import a DB-only dump, fix the three invariants (prefix, version, collation), point Matomo at it, core:update, then rebuild everything the dump never carried — with Tag Manager, proxy headers, and persistent volumes as the traps most likely to bite after everything looks fine.
Part 2 walks the full procedure step by step: preparing MariaDB, inspecting and importing the dump, writing config.ini.php, running core:update, and the complete post-migration configuration. Part 3 covers the cutover timeline, archiving, GDPR/retention, backups, and rollback.