VPS Tutorials
Self-Hosting Matomo on a VPS (2026): Docker, MariaDB, Caddy HTTPS, Scheduled Archiving and Backups
Deploy Matomo 5 and MariaDB on a VPS with Docker Compose, configure Caddy HTTPS, trusted hosts, scheduled CLI archiving, privacy options, backup and restore, and secure upgrades.

If you don't want to hand your website traffic data to Google Analytics but still need referrer channels, page conversions, events and ecommerce analytics, Matomo is one of the most mature self-hosted options available. Your data stays on your own VPS and in your own database, and you don't lose visibility entirely because a third-party analytics service is blocked by ad blockers or affected by regional network conditions.
But Matomo isn't finished once a container starts. Database persistence, reverse proxying, trusted hosts, background archiving, privacy settings and recoverable backups — skip any one of these and problems may surface once traffic picks up.
This tutorial uses the official matomo:5-apache image and MariaDB 11.4, deployed with Docker Compose, with Caddy handling HTTPS. Matomo only listens on the VPS's 127.0.0.1:8080, the database exposes no public port, and a CLI archiving task runs every hour.
These three tools don't solve exactly the same problems.
| Requirement | Better-suited tool | Main trade-off |
|---|---|---|
| Need channels, goals, events, ecommerce and detailed visitor analytics | Matomo | Full-featured, but higher database and archiving maintenance cost |
| Only need lightweight metrics like pages, sources and devices | Umami | Simple interface, lower resource usage, less depth in analytics |
| Don't want to operate it and accept third-party data processing | Google Analytics | Easy to integrate, but privacy, data control and ad blocking need separate evaluation |
| Compliance requires data to stay on your own infrastructure | Matomo On-Premise | Stronger data control, but "self-hosted" does not automatically mean compliant |
If you only want to know how many visitors you get each day and which pages are most popular, start with Self-Hosting Umami Website Analytics on a VPS. If you need goal conversions, site search, download tracking, user flows or finer data retention policies, deploying Matomo is the better investment.
For a single-server installation with up to 100,000 page views per month, Matomo's official minimum recommendation is 2 CPU cores, 2GB of RAM and 50GB SSD. Since MariaDB also runs in Docker, it's worth buying extra memory headroom.
| Monthly page views | Recommended configuration | Notes |
|---|---|---|
| Under 100,000 | 2 cores, 4GB RAM, 50GB SSD | Small blogs and business sites; single-server deployment is simplest |
| 100,000 to 1,000,000 | 4 cores, 8GB RAM, 200GB SSD or more | CLI archiving is mandatory, and database growth must be monitored continuously |
| Over 1,000,000 | Separate load testing and planning | Database, archiving processes and object storage may need to be split out |
Disk growth depends on traffic volume, number of events, log retention period and whether raw visit data is stored, so you can't estimate it from site file size alone. The system used here is Ubuntu 24.04 LTS; point the A / AAAA records for analytics.example.com at the VPS in advance and only open SSH, 80 and 443.
Install Docker Engine and the Compose plugin from the official Docker repository, then confirm you're using Compose v2:
docker --version
docker compose version
Create the deployment and backup directories:
sudo mkdir -p /opt/matomo/backups
sudo chown -R "$USER":"$USER" /opt/matomo
cd /opt/matomo
If this VPS also runs other containers, review the VPS Docker Compose production configuration checklist first to standardize log rotation, health checks and upgrade strategy.
The application database account and the root password must be different:
openssl rand -hex 32
openssl rand -hex 32
nano .env
chmod 600 .env
Write both random values into /opt/matomo/.env:
MARIADB_ROOT_PASSWORD=替换成第一组随机值
MATOMO_DATABASE_PASSWORD=替换成第二组随机值
Never commit .env to Git, and never write plaintext passwords in the Compose file.
Create /opt/matomo/compose.yaml:
services:
db:
image: mariadb:11.4
container_name: matomo-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD in .env}"
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: "${MATOMO_DATABASE_PASSWORD:?set MATOMO_DATABASE_PASSWORD in .env}"
command:
- --max-allowed-packet=64M
- --innodb-buffer-pool-size=512M
volumes:
- matomo_db:/var/lib/mysql
healthcheck:
test:
- CMD-SHELL
- >-
mariadb-admin ping -h 127.0.0.1 -uroot
-p$$MARIADB_ROOT_PASSWORD --silent
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
app:
image: matomo:5-apache
container_name: matomo
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8080:80"
environment:
MATOMO_DATABASE_HOST: db
MATOMO_DATABASE_ADAPTER: mysql
MATOMO_DATABASE_TABLES_PREFIX: matomo_
MATOMO_DATABASE_USERNAME: matomo
MATOMO_DATABASE_PASSWORD: "${MATOMO_DATABASE_PASSWORD:?set MATOMO_DATABASE_PASSWORD in .env}"
MATOMO_DATABASE_DBNAME: matomo
PHP_MEMORY_LIMIT: 512M
volumes:
- matomo_data:/var/www/html
healthcheck:
test:
- CMD-SHELL
- >-
php -r '$$c=@fsockopen("127.0.0.1",80,$$e,$$s,5);
exit($$c ? 0 : 1);'
interval: 30s
timeout: 10s
retries: 10
start_period: 60s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
matomo_db:
name: matomo_db
matomo_data:
name: matomo_data
matomo:5-apache follows Matomo 5 patch and minor releases, so a single rebuild won't jump straight to the next major version. mariadb:11.4 is pinned to the 11.4 series, avoiding a floating major tag in production. On a 2GB machine you can first change --innodb-buffer-pool-size to 256M; don't let the database cache squeeze out memory needed by the system and PHP.
Before starting, have Compose expand the variables and check the YAML:
docker compose config >/dev/null
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 app
The first initialization creates the database and Matomo files. Once both services show healthy, check the local entry point:
curl -I http://127.0.0.1:8080
sudo ss -lntp | grep ':8080'
The listen address must be 127.0.0.1:8080, not 0.0.0.0:8080. MariaDB has no ports configuration, and 3306 should never be visible from the public internet.
Edit /etc/caddy/Caddyfile:
analytics.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
request_body {
max_size 64MB
}
}
Check and load the configuration:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://analytics.example.com
Caddy passes through X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto. When Matomo sits behind an HTTPS reverse proxy, also check the [General] configuration in config/config.ini.php after installation:
[General]
assume_secure_protocol = 1
force_ssl = 1
proxy_ip_read_last_in_list = 0
trusted_hosts[] = "analytics.example.com"
trusted_hosts is the hostname protection Matomo enables by default; don't just turn it off to silence warnings. After changing it, run:
docker compose exec app php console config:get General
docker compose exec app php console cache:clear
If you're unfamiliar with Caddy certificates, DNS and multi-domain configuration, see the complete guide to reverse proxying with Caddy on a VPS.
Open https://analytics.example.com in your browser. On the database page, enter:
| Field | Value |
|---|---|
| Database server | db |
| Login | matomo |
| Password | MATOMO_DATABASE_PASSWORD from .env |
| Database name | matomo |
| Table prefix | matomo_ |
Don't enter localhost or 127.0.0.1; those point to the Matomo container itself, not to MariaDB on the Compose network.
The wizard then creates the administrator, adds the first website and generates the JavaScript tracking code. Place the tracking code before </head> on every page of your site; for WordPress, Shopify or common CMSs, prefer actively maintained official or community plugins so the code isn't lost after a theme update.
After installation, verify:
docker compose exec app php console diagnostics:run
docker compose exec app php console core:archive --url=https://analytics.example.com
docker compose logs --tail=100 app
Then visit your site in an incognito window and confirm a test visit appears under Matomo's "Visitors in Real-time". Seeing the page load normally does not prove that tracking requests and archiving tasks are working.
Matomo aggregates raw visit data into reports. Small sites can trigger archiving via browser requests, but the official recommendation is to switch to scheduled CLI archiving once traffic exceeds 500 page views per day or server response slows down. In production, configure Cron directly — it's more predictable.
First run it manually once and confirm there are no errors:
cd /opt/matomo
docker compose exec -T app php /var/www/html/console core:archive \
--url=https://analytics.example.com
Confirm Docker's absolute path:
command -v docker
Then edit root's Cron:
sudo crontab -e
Add a line; if the previous step wasn't /usr/bin/docker, substitute the actual path:
5 * * * * cd /opt/matomo && /usr/bin/docker compose exec -T app php /var/www/html/console core:archive --url=https://analytics.example.com >> /var/log/matomo-archive.log 2>&1
Once CLI archiving runs reliably, disable browser-triggered archiving:
docker compose exec app php console config:set General enable_browser_archiving = 0
docker compose exec app php console config:get General enable_browser_archiving
Check the logs regularly so Cron doesn't fail repeatedly due to path, certificate or database issues:
sudo tail -n 100 /var/log/matomo-archive.log
Self-hosting only means you control the infrastructure; it doesn't mean your site automatically complies with GDPR, CCPA or local privacy regulations. At minimum, check these items under "Administration → Privacy":
- For IP anonymization, mask 2 or 3 bytes, and enable it before collecting real traffic for the first time;
- Set retention periods for raw visit logs and historical reports; don't store data with no business value indefinitely;
- If you don't need to identify users across sessions, use cookieless tracking and verify how accuracy changes;
- Enable a mechanism for users to opt out of tracking, and state the purpose of collection and retention period in your privacy policy;
- Don't stuff personal information such as emails, names or order notes into URLs, page titles or custom dimensions;
- Decide whether you need a consent management platform based on your region, tracking method and purpose; don't treat "Matomo" as a universal conclusion that exempts you from a cookie banner.
Matomo provides IP anonymization capability by default, but the final configuration and legal basis remain the site operator's responsibility. Have legal or privacy leads confirm business needs before making changes.
A complete backup includes at least MariaDB and the matomo_data volume. The database holds site, visit and report data, while the data volume contains config/config.ini.php, plugins and application files.
Create a database backup:
cd /opt/matomo
mkdir -p backups
docker compose exec -T db sh -c \
'mariadb-dump --single-transaction --quick --routines --triggers \
--databases "$MARIADB_DATABASE" -uroot -p"$MARIADB_ROOT_PASSWORD"' \
| gzip > "backups/matomo-db-$(date +%F-%H%M).sql.gz"
Back up the Matomo data volume:
docker run --rm \
-v matomo_data:/source:ro \
-v "$PWD/backups:/backup" \
alpine sh -c 'tar -C /source -czf /backup/matomo-data.tar.gz .'
Backup files must not live only on the same VPS. Sync them encrypted to object storage or another machine, and run restore drills regularly. You can follow the off-site backup and verification process in the VPS backup and restore drill tutorial.
To restore, first stop Matomo writes, restore the data volume, then import the SQL into a fresh MariaDB, and finally start the services and run:
docker compose exec app php console core:update
docker compose exec app php console diagnostics:run
docker compose exec app php console core:archive --url=https://analytics.example.com
Restore acceptance must check administrator login, historical reports, real-time visits, the tracking script and Cron archiving together — not just that the homepage opens.
Matomo handles visitor data and administrator accounts, so don't put off security updates for long. Before upgrading, read the Matomo release notes and image changes, then run:
cd /opt/matomo
docker compose config >/dev/null
# 先完成并验证数据库、matomo_data 两类备份
docker compose pull
docker compose up -d
docker compose exec app php console core:update
docker compose exec app php console diagnostics:run
docker compose ps
After upgrading, verify at least:
- The HTTPS homepage and admin panel are accessible;
- New real-time visits reach Matomo;
- Historical reports haven't disappeared;
core:archiveruns successfully manually;- Caddy, Matomo and MariaDB logs show no persistent errors.
Don't use the latest tag to auto-upgrade across major versions, and don't back up only the Compose file. What's truly irreplaceable is the database, the configuration and the plugins you installed.
First check whether the database is healthy:
docker compose ps
docker compose logs --tail=200 db
docker compose exec app getent hosts db
The hostname in the installation wizard must be db, and the account, password and database name must match the Compose file exactly. Changing .env on an already-initialized database does not automatically reset the database account.
Confirm the only entry point is https://analytics.example.com, that Caddy correctly passes forwarding headers, and that assume_secure_protocol and force_ssl are set. Don't have the CDN, Caddy and Matomo each perform conflicting HTTP redirects at the same time.
Run core:archive manually and check /var/log/matomo-archive.log. Once traffic grows, first confirm whether archiving is working, whether disk space is sufficient and whether MariaDB has slow queries, before blindly adding CPU.
Confirm Caddy's X-Forwarded-For, and configure proxy_client_headers, proxy_host_headers and proxy_ip_read_last_in_list according to the actual proxy chain. If Cloudflare is also in front, only trust explicit proxy sources; never treat headers submitted by arbitrary clients as the real IP.
In the browser developer tools Network tab, search for matomo.php and confirm the request isn't a 404, a 403 or blocked by a content security policy. Then check the site ID, the Matomo URL, ad blockers and the "exclude your own visits" setting.
- Matomo only listens on
127.0.0.1:8080, and MariaDB maps no public port - Domain, HTTPS, trusted hosts and reverse proxy configuration are consistent
- The administrator uses a strong password and has two-factor authentication enabled where available
- Test visits appear in real-time visitors
- Hourly CLI archiving succeeds and logs are observable
- IP anonymization, cookies, data retention and opt-out policies are confirmed
- Both the database and
matomo_datahave off-site backups - A restore drill has been completed and the recovery time recorded
- Backups exist before upgrades, and tracking and archiving are verified after upgrades
Once these steps are done, this Matomo setup is more than "the page opens" — it's a production environment with HTTPS, persistence, privacy controls, automatic archiving and recoverability. For teams that genuinely value first-party data and website conversions, this operational cost is the price of data control.