Parts 1 and 2 covered the model and the full import/configuration procedure. This last part is about timing and running it in production: the day-before / day-of / stabilization timeline, scheduled archiving, backups, GDPR re-activation, and rollback.
The single most under-estimated fact of this whole migration:
The migration starts the day before, not on go-live day. The 05:00 go-live is only the tracking switch. All the heavy lifting — import, configuration, first archive — happens the evening before.
| When | What | Duration |
|---|---|---|
| Day-1, afternoon | Import + configuration + Tag Manager + rehearsal | ≈ 4 h (incl. ≈1h45 import) |
| Day-1, evening | First full archive | several hours on a large history |
| Day, before 05:00 | Tracking cutover | ≈ 30 min |
Phase A — Day before (Day-1)
This phase runs everything from Part 2, in order, ending with the first full archive. The steps that specifically belong to the day-before rehearsal:
A1–A2. Sanity gates. Containers are up (podman ps), and the image version is ≥ the dump’s version_core. If the image is older, stop here — core:update will refuse and nothing downstream matters.
A3–A8. Import and wire up. Detect the real dump format, derive the prefix, create the DB/user, verify the DB is empty, import (in tmux), verify the import and restore durability, write config.ini.php. (All detailed in Part 2.)
A9. core:update → expect « Everything is already up to date ».
A10. Tag Manager → activate, regenerate containers, confirm a 200.
A11. Inventory then disable scheduled reports — save the list first, because you’ll need it to re-enable exactly the same ones later:
$DB_EXEC="podman exec -i mariadb"
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"SELECT idreport, idsite, login, description, period FROM report WHERE deleted = 0;" \
| tee /var/backups/matomo/active-reports.txt
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"UPDATE report SET deleted = 1 WHERE deleted = 0;"
deleted is a reversible flag, not a real delete. Keep active-reports.txt — it’s the only record of which reports to bring back.
A12. Disable the GDPR purge during migration — to rule out any deletion concurrent with cutover:
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"UPDATE \`option\` SET option_value='0' WHERE option_name='delete_logs_enable';"
$APP_EXEC ./console core:clear-caches
A13–A14. Configure geolocation (UI) and generate security files.
A15. Persistence test — do not skip this. This is the check that catches the silent volume trap before it costs you. Restart the app container and confirm everything survived:
podman restart matomo && sleep 25
$APP_EXEC sh -c 'head -4 /var/www/html/config/config.ini.php' # config survived?
$APP_EXEC sh -c 'ls /var/www/html/js/container_*.js 2>/dev/null | wc -l' # containers survived?
curl -sS -o /dev/null -w "container %{http_code}\n" \
"https://analytics.example.com/js/container_${IDC}.js" # still 200?
$APP_EXEC ./console plugin:list | grep -i tagmanager # still Activated?
If the config disappears or the container falls back to 404, the volumes are not persistent → stop and fix with your infra team before any cutover. This is the trap that breaks everything silently on the first container recreation.
A16. First full archive — the evening of Day-1. Run it manually, in tmux:
tmux new -s archive
time $APP_EXEC ./console core:archive --url=https://analytics.example.com
Expect it to end with Done archiving!. An exit code 1 alongside Done archiving! is normal here — it comes from a failed report send (no SMTP yet), not from an archiving failure. This must run the evening before; started on go-live morning it won’t finish in time and reports would be slow and incomplete when users log in.
A17. Functional rehearsal — walk the go/no-go checklist below.
The go / no-go checklist
Before cutover, confirm:
- [ ] Image on the pinned build;
core:version≥ dump’sversion_core. - [ ]
core:updateran without error. - [ ] Superuser login works (Cloud credentials); 2FA works.
- [ ] Historical data visible (a past period renders).
- [ ] Site
main_urlvalues updated (no leftover Cloud URLs). - [ ] Geolocation active for new traffic.
- [ ] Scheduled reports disabled, and the
idreportlist saved. - [ ] HTTPS working,
force_ssl = 1, security files generated. - [ ] Behind the proxy: visits carry the real client IP, not the proxy’s.
- [ ] GDPR settings verified (anonymization, retention).
- [ ] Single collation — the collation query returns exactly one row.
- [ ] 🚨 Tag Manager active and containers served (
curl→ 200, not 404). - [ ]
config.ini.php, plugins, GeoIP andjs/container_*.json persistent volumes — verified by restarting the container. - [ ] A test hit shows up in real time.
- [ ] Archive timer active + first run OK.
- [ ] Monitoring in place (timer failure, disk space).
Some items are deliberately not satisfied at cutover and that’s fine — track them, don’t tick them: SMTP not configured, scheduled reports disabled, premium plugins absent, GDPR purge disabled, restorable backup tested. They’re decisions, not failures. Never tick them « to look clean » — a successful core:test-email during rehearsal would mean SMTP is live, which means reports can go out, exactly what you’re avoiding.
Phase B — Go-live day, before 05:00 (~30 min window)
B1. Confirm the day-before archive finished.
B2. Re-check the Tag Manager container serves a 200 (rerun A10 if it’s 404).
B3. Switch the tracking — the irreversible move on the sites. First, separate your two populations, because they switch differently:
# Sites WITH a Tag Manager container -> switch the container URL
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"SELECT s.idsite, s.name, c.idcontainer FROM \`site\` s
JOIN tagmanager_container c ON c.idsite = s.idsite AND c.status='active'
ORDER BY s.idsite;"
# Sites WITHOUT a container -> switch the classic tracking code
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"SELECT idsite, name FROM \`site\`
WHERE idsite NOT IN (SELECT idsite FROM tagmanager_container WHERE status='active');"
Then, on the sites:
- Update the classic tracking code (
matomo.js/matomo.phpURL) toanalytics.example.com— the sites without a container first, and anywhere the snippet is hard-coded. - Update the Tag Manager container URL on the sites that use one.
- Or switch DNS if you keep the same hostname — then no URL changes are needed.
« All sites are reporting » is not a sufficient check — it doesn’t prove the Tag-Manager-published sites are covered. Check the two populations separately in B4.
B4. Verify real-time collection, per population:
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"SELECT s.idsite, s.name,
CASE WHEN c.idsite IS NULL THEN 'direct code' ELSE 'Tag Manager' END AS mode,
COUNT(v.idvisit) AS visits_30min
FROM \`site\` s
LEFT JOIN (SELECT DISTINCT idsite FROM tagmanager_container WHERE status='active') c
ON c.idsite = s.idsite
LEFT JOIN log_visit v ON v.idsite = s.idsite
AND v.visit_last_action_time > NOW() - INTERVAL 30 MINUTE
GROUP BY s.idsite, s.name, mode ORDER BY visits_30min ASC;"
If all Tag Manager sites are at 0 while direct-code sites report, the container isn’t served or the URL wasn’t switched → revisit B2/B3. A single site at 0 isn’t necessarily a failure — low-traffic sites at 5 AM legitimately show zero; compare to each site’s usual volume, not to zero.
B5. Verify real IPs — the reverse-proxy trap. Don’t rely on counting distinct IPs. Test against a known IP:
# 1) From the test machine, note its public IP:
curl -s https://ifconfig.me ; echo
# 2) Generate a visit from that machine on a tracked site.
# 3) Confirm Matomo recorded THAT IP, not the proxy's:
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"SELECT INET6_NTOA(location_ip) AS ip, COUNT(*) AS visits FROM log_visit \
WHERE visit_last_action_time > NOW() - INTERVAL 15 MINUTE \
GROUP BY location_ip ORDER BY visits DESC;"
The IP from step 1 must appear. If every visit carries the proxy IP (or an internal 10.x / 172.16-31.x / 192.168.x), the forwarded-for headers aren’t being applied → fix proxy_client_headers and core:clear-caches. Fix immediately — visits collected meanwhile are falsified and unrecoverable. The header name must match what your proxy actually sends (X-Forwarded-For usually, sometimes X-Real-IP).
Phase C — Right after cutover (H+0 to H+2)
C1. Watch real-time for ~1h; confirm all sites report.
C2. Enable scheduled archiving — on a container host, prefer a systemd timer over cron; it shares the container’s mode (rootful/rootless) and logging.
matomo-archive.service:
[Unit]
Description=Matomo report archiving
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/podman exec -u www-data matomo ./console core:archive --url=https://analytics.example.com
matomo-archive.timer:
[Unit]
Description=Matomo hourly archiving
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
Enable it (rootful shown; add --user for rootless, which also needs loginctl enable-linger <user>):
systemctl daemon-reload && systemctl enable --now matomo-archive.timer
systemctl list-timers matomo-archive.timer
journalctl -u matomo-archive.service -n 50
core:archivedoes more than archive. At the end of each run it triggers the scheduled tasks: emailing reports and the GDPR log purge. Without this timer, neither the reports nor the purge ever run. To trigger them in isolation:./console scheduled-tasks:run.
C3. Inform users: new URL, unchanged credentials (passwords and 2FA migrated), email reports temporarily suspended, and any premium features currently unavailable.
Phase D — Stabilization (Day+1 to Day+7)
Order is imposed — do not invert it. Reports have been disabled since A11. Wire SMTP first (D1) so you can test it empty and safe, then re-enable reports (D2) knowingly. The reverse order — reports active before a working SMTP — blasts emails on the next archive run.
D1. SMTP — configure the relay, then validate empty:
$APP_EXEC ./console core:test-email
Expect the test email to arrive. Reports are still disabled, so no mass send is possible yet. If it fails, fix the relay before D2.
D2. Re-enable scheduled reports — only the ones saved in active-reports.txt, and only after D1 passes:
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"UPDATE report SET deleted = 0 WHERE idreport IN ( /* ids from active-reports.txt */ );"
Never run a global UPDATE report SET deleted = 0 — it would resurrect reports that were intentionally deleted while on Cloud.
D3. Re-enable the GDPR purge:
$DB_EXEC mariadb -u root -p"<ROOT_PWD>" matomo -e \
"UPDATE \`option\` SET option_value='1' WHERE option_name='delete_logs_enable';"
$APP_EXEC ./console core:clear-caches
Leaving it off makes the database grow indefinitely and steps outside your declared retention — it’s a compliance control, not an optimization.
D4. Backups — set up database + application volume, and test a restore. The application volume matters: it holds config.ini.php, plugins and the GeoIP database — restoring only the database won’t bring the service back. A reference logical dump:
podman exec -i mariadb mariadb-dump --single-transaction --quick \
--default-character-set=utf8mb4 -u matomo -p"<APP_PWD>" matomo \
| gzip > /backups/matomo-$(date +%F).sql.gz
Rotate on a separate target. A backup that has never been restored is not a backup.
D5. Confirm durability is restored (SELECT @@innodb_flush_log_at_trx_commit; → 1).
D6. Cancel the Cloud subscription — point of no return. Only after: a conclusive observation period, a backup successfully restored at least once, and any needed export of the data gap (below).
The data gap — a decision to make explicitly
The dump is a snapshot at time T. Between the dump and the cutover, Cloud keeps collecting. A common, defensible decision is to accept the gap: don’t replay a final dump at go-live; the visits between the dump’s last data and the tracking switch stay only in Cloud and aren’t recovered on-premise.
Consequences to keep in mind:
- ✅ The cutover window stays short — no ~2h import to fit in, just switching tracking code / DNS.
- ✅ The pinned image stays valid (it matches the already-imported dump) — no version re-qualification.
- ⚠️ The gap grows over time — its size is the interval between the dump’s last data and the cutover date. The later the go-live, the longer the missing period. That’s the one argument for cutting over sooner rather than later.
- 💡 Cloud remains readable until cancellation — if the missing period ever needs analysis, export or consult it from Cloud before cancelling (D6 is the point of no return).
Rollback
As long as Cloud is not cancelled, rolling back is quick:
- Restore the old tracking code and Cloud container URLs on the sites.
- Confirm collection resumes on Cloud.
- The on-premise instance can stay in place for analysis.
No on-premise data is lost — the imported database stays intact. Keep both the original dump and an on-premise backup before any destructive operation.
Troubleshooting cheat-sheet
| Symptom | Cause | Fix |
|---|---|---|
Unknown collation 'utf8mb4_0900_ai_ci' | MySQL 8 dump on MariaDB | in-stream sed conversion (Part 2, Step 3/4) |
| « empty database » / no data | tables_prefix ≠ dump | fix config.ini.php; re-read the prefix |
core:update « more recent version » | image < Cloud | rebuild image on the pinned build |
Unsupported host | trusted_hosts incomplete | add the domain to trusted_hosts[] |
| All visits share one IP | proxy headers not declared | proxy_client_headers[] |
| Config/plugins lost on restart | written off a persistent volume | move to persistent volumes |
| Corrupted accents | import without --default-character-set=utf8mb4 | re-import |
MySQL server has gone away | max_allowed_packet too low | raise it + re-import |
SQL error on option/site | reserved word without backticks | `option` / `site` |
core:archive exit 1 but « Done archiving! » | scheduled reports + no SMTP | calibrate alerts on log content, not exit code |
tar returns nothing (exit 1) | .tar.gz that’s actually a plain gzip | use gunzip -c |
podman ps shows nothing | wrong mode (root vs user) | run as the container-owning user |
Series wrap-up
Three parts, one migration:
- Part 1 — the model (DB-only dump, config regenerated), the three invariants (prefix, version, collation), and the landmines not in any dump.
- Part 2 — the full procedure: prepare MariaDB, inspect and import, wire up
config.ini.php,core:update, and post-migration configuration. - Part 3 — timing and operations: the day-before / day-of / stabilization runbook, archiving, backups, GDPR, and rollback.
The recurring lesson across all three: the import is the easy part. What breaks a Matomo Cloud→self-hosted migration is everything the dump doesn’t carry — and the fact that most of it fails silently, well after the migration looks done.