Can I self-host Matomo Cloud?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Matomo. It takes one prompt, a 2048 MB VPS, and about 120 minutes. That is $42 a month you stop paying Matomo Cloud — $504 a year on the Business · 100,000 hits/month plan, a metered rate, not a whole bill.
Why people pay for Matomo Cloud
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Matomo Cloud sells the operational half of a full-fat analytics suite. The software is free to download, so what the subscription buys is somebody else running the database that every hit lands in, keeping the hourly report-building job healthy, applying the schema upgrades, and taking the backups, on servers in Frankfurt with a data-processing story you can hand to a lawyer. The bill is metered on hits rather than seats, which means it grows with the thing you were hoping would grow.
| Plan | List price | What it buys |
|---|---|---|
| Free trial | free | The vendor's FAQ says the trial covers all core features and needs no credit card. |
| Business · 50,000 hits/month | $26/mo metered | USD, excluding tax, billed monthly. The vendor's EUR figure for the same tier is 22 EUR. Annual billing is advertised as two months free. |
| Business · 100,000 hits/monththe plan this page prices against | $42/mo metered | USD, excluding tax, billed monthly. A hit is any tracked action: a page view, an event, a download, an outlink, a site search or an ecommerce request. |
| Business · 300,000 hits/month | $85/mo metered | USD, excluding tax, billed monthly. |
| Business · 1 million hits/month | $204/mo metered | USD, excluding tax, billed monthly. Every Business tier caps at 30 websites, 30 team members and 24 months of raw data retention. |
| Business · 5 million hits/month | $975/mo metered | USD, excluding tax, billed monthly. The published ladder continues to 100 million hits at 17,900 USD a month. |
| Enterprise | quote only | Quote only. The page says contact us for custom pricing and allowances. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Matomo
One project, named before the prompt, so you know what you are about to install.
The full Google-Analytics-shaped suite on your own hostname, with visitor profiles, funnels and no monthly hit meter.
It is the same software. Matomo Cloud is a hosted instance of the GPL release you can run yourself, so the feature comparison is not a comparison at all, and the honest question is which half of the job you want: the reports, or the database and the hourly archiving job that produce them. Self-hosting also removes the hit meter, which is the part of the bill that punishes a site for doing well.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Matomoour pickONE EVENING— setup effort 2 of 4
The full Google-Analytics-shaped suite on your own hostname, with visitor profiles, funnels and no monthly hit meter.
It is the same software. Matomo Cloud is a hosted instance of the GPL release you can run yourself, so the feature comparison is not a comparison at all, and the honest question is which half of the job you want: the reports, or the database and the hourly archiving job that produce them. Self-hosting also removes the hit meter, which is the part of the bill that punishes a site for doing well.
- Plausible CEONE EVENING— setup effort 2 of 4
The same privacy-first analytics dashboard the vendor sells, on your own domain, with no pageview meter counting against you.
Ranked second because it is a genuinely different trade, not a lesser Matomo. Plausible counts without cookies and without personal data, so there is no consent banner to design and no visitor profile to store, and the whole product fits on one screen. You give up what Matomo Cloud subscribers usually pay for: visitor profiles, funnels, heatmap and session tooling, ecommerce attribution, and the ability to answer a question nobody anticipated when the dashboard was built.
The swap
You'd run
Matomo
ONE EVENING · ~120 min to running · 2048 MB RAM
Matomo Cloud Business · 100,000 hits/month · a metered rate, not a whole bill · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~120 min1–3 hours, through the first backup
The prompt
Two paths to the same Matomo: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
349 lines · 14,994 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Matomo 5.12.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point here. Say why when you ask: that hostname becomes Matomo's
trusted host and goes inside the tracking snippet on every page they measure.
Matomo and its database need 2048 MB of RAM available and 10 GB free on /srv, what upstream
sizes for a site tracking 100,000 page views a month. Both images are amd64 and arm64.
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name nobody can resolve.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/matomo /srv/matomo/backups
sudo install -d -m 750 /srv/matomo/matomo
sudo install -d -m 700 /srv/matomo/mariadb
ls -la /srv/matomo
```
Assert: `backups` owned by the login user, `matomo` at mode `750` and `mariadb` at mode `700`,
both owned by root. Leave those two alone: the Matomo image unpacks its PHP tree into `matomo`
and chowns it to www-data, MariaDB chowns its own data directory, and each refuses a directory
someone claimed first.
## 3. Secrets
Two secrets: the `matomo` database user's password and the MariaDB root password. Matomo ships
no account and no admin token of its own; the wizard in step 7 creates the first user. Print
neither value, keep both out of your summary and out of every log line.
```bash
umask 077
cat > /srv/matomo/.env <<EOF
MARIADB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/matomo/.env
umask 022
ls -l /srv/matomo/.env
```
Assert: mode `-rw-------` and the login user's name twice. Compose reads this file for the
`${...}` substitutions in compose.yml whenever it runs from /srv/matomo and never mounts it
into a container.
## 4. compose.yml
```bash
cat > /srv/matomo/compose.yml <<'EOF'
# Matomo · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image README ....... https://github.com/matomo-org/docker
# docker install FAQ . https://matomo.org/faq/how-to-install/install-matomo-with-docker/
# archiving cron ..... https://matomo.org/faq/on-premise/how-to-set-up-auto-archiving-of-your-reports/
#
# Three services. `app` is Apache with PHP; `archive` is the same image with a
# loop around `console core:archive` in place of its entrypoint, sharing the
# web root because the archiver reads the config the wizard writes. Every
# ${...} comes from /srv/matomo/.env, mode 600. Digests read 2026-08-06; both
# images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: matomo-db
restart: unless-stopped
# Archiving writes wide rows; upstream's example raises this too.
command: --max-allowed-packet=64MB
environment:
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: ${MARIADB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- /srv/matomo/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
# No `ports:` at all: 3306 is reachable only from the other containers.
app:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-app
restart: unless-stopped
environment:
# These six only prefill the wizard's database form; after that the
# credentials live in config.ini.php and these are never read again.
MATOMO_DATABASE_HOST: db
MATOMO_DATABASE_ADAPTER: mysql
MATOMO_DATABASE_TABLES_PREFIX: matomo_
MATOMO_DATABASE_USERNAME: matomo
MATOMO_DATABASE_PASSWORD: ${MARIADB_PASSWORD}
MATOMO_DATABASE_DBNAME: matomo
PHP_MEMORY_LIMIT: 512M
volumes:
- /srv/matomo/matomo:/var/www/html
healthcheck:
test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/index.php || exit 1"]
interval: 10s
retries: 24
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8119.
- "127.0.0.1:8119:80"
depends_on:
db:
condition: service_healthy
archive:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-archive
restart: unless-stopped
# Apache's user, so what this writes stays readable by the web process.
# Reports are computed here, hourly, never on a page load.
user: www-data
environment:
PHP_MEMORY_LIMIT: 512M
volumes:
- /srv/matomo/matomo:/var/www/html
entrypoint: ["/bin/sh", "-c", "while true; do [ -s /var/www/html/config/config.ini.php ] && php /var/www/html/console core:archive --no-ansi; sleep 3600; done"]
depends_on:
app:
condition: service_started
EOF
cd /srv/matomo && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Upstream documents archiving as a script running hourly, and
without that third container Matomo computes reports while somebody waits on a page load.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy it first: a syntax error takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-matomo
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Matomo · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://matomo.org/faq/how-to-install/faq_98/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also Matomo's trusted host and the address inside the tracking snippet on
# every page you measure.
<DOMAIN> {
encode zstd gzip
# Matomo sets its own X-Frame-Options and CSP; these are the rest.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Caddy passes the Host through and adds X-Forwarded-For, which
# trusted_hosts[] and proxy_client_headers[] read. 8119 is loopback only.
reverse_proxy 127.0.0.1:8119
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-matomo, reload, and
report the objection. Caddy gets the certificate on first request and renews it.
## 6. Firewall
Two ports open, both Caddy's, and idempotent on a box Prompt Zero configured:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8119 stays closed because compose binds it to loopback, 3306 because compose never
publishes it. Assert: `Status: active`, rules for 80, 443/tcp, 443/udp, nothing else.
## 7. Start and verify
The first start unpacks about 200 MB of PHP into /srv/matomo/matomo, so give it time.
```bash
cd /srv/matomo
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/index.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/ | grep -c 'Matomo is libre software used to analyze traffic from your visitors'
```
Assert: the loop ends on `200`, the grep prints `1`, and you print both. If either misses,
stop, run `docker compose logs --tail 40 app` and `docker compose logs --tail 20 db`, and name
the likely step: a database that never reports healthy is step 2, a lasting `502` is step 5. A
running container is not success.
Now write the settings the wizard never asks about, into a file it reads first and cannot
overwrite.
```bash
cd /srv/matomo
docker compose exec -T -u www-data app sh -c 'cat > /var/www/html/config/common.config.ini.php' <<'EOF'
[General]
; Caddy terminates TLS, so Matomo is told the request arrived over https.
assume_secure_protocol = 1
force_ssl = 1
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
; The only hostname allowed in a Host header.
trusted_hosts[] = "<DOMAIN>"
; Reports come from the archive container, not from a page load.
enable_browser_archiving_triggering = 0
browser_archiving_disabled_enforce = 1
EOF
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/index.php
```
Assert: that prints `200`. A `500` is a typo in that file; read it back with
`docker compose exec -T app cat /var/www/html/config/common.config.ini.php` and fix it.
STOP: tell the user to open https://<DOMAIN>, work through the wizard, and wait. Do not
continue until they confirm. Tell them three things: the database screen is filled in and
its password masked, so they keep the adapter on its default and press Next; the superuser
they create is this install's only account and goes in their password manager now; the
website they name last owns the snippet Matomo prints at the end.
Once they confirm, prove the install is real and the wizard is closed:
```bash
cd /srv/matomo
curl -sS 'https://<DOMAIN>/index.php?module=Installation&action=welcome' | grep -c 'Matomo is already installed'
docker compose exec -T -u www-data app php /var/www/html/console config:get --section=General --key=trusted_hosts | grep -c '<DOMAIN>'
curl -sS -o /dev/null -w '%{http_code}\n' 'https://<DOMAIN>/matomo.php?idsite=1&rec=1&action_name=selfhost-check&url=https%3A%2F%2Fexample.com%2F'
sleep 5
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from matomo_log_visit" "$MARIADB_DATABASE"'
docker compose exec -T -u www-data app php /var/www/html/console core:archive --no-ansi | tail -5
```
Assert all five, printing what you received for each. The first grep prints `1`, the security
assert here: the wizard now refuses anyone who finds that URL. The second prints `1`, so the
effective config names that hostname and no other. The tracker returns `200`. The count is `1`
or more, the product working end to end, a tracking request that became a row. It ends with
`Done archiving!`. A `0` count means the tracker took the request and dropped it: stop and read
`docker compose logs --tail 40 app`. A failed archive run means reports stop refreshing.
The first screen at https://<DOMAIN> now shows the heading `Sign in` above a
`Username or e-mail` field, a `Password` field and a `Lost your password?` link.
## 8. First backup and restore
Two artifacts. The database holds every visit, user and computed report. The config archive
holds what rebuilds the service around it, including the `config` directory Matomo wrote its
credentials into.
```bash
cd /srv/matomo
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/matomo/backups/matomo-db-$(date +%F).sql.gz
sudo tar -czf /srv/matomo/backups/matomo-config-$(date +%F).tar.gz -C /srv/matomo compose.yml .env matomo/config -C /etc/caddy Caddyfile
ls -lh /srv/matomo/backups/
```
Assert: both files exist, both are non-empty, both sizes printed. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database.
A backup on the same disk as the data is not a backup. Run this one from the user's machine,
not the server:
```bash
mkdir -p ~/backups/matomo
scp vps:/srv/matomo/backups/* ~/backups/matomo/
```
To restore: `docker compose down`, `sudo rm -rf /srv/matomo/mariadb`, recreate it as in step 2,
untar the config archive at /srv/matomo so `.env` and `matomo/config` come back,
`docker compose up -d db`, wait about
30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d`. Tell the user why that order matters: MariaDB takes its password
from .env the moment it initialises an empty directory, and Matomo will not start without
`config`, the one part of the web root the image does not ship.
## 9. Updating later
New versions are listed at https://github.com/matomo-org/matomo/releases. Take both backups
first, then edit both image lines in /srv/matomo/compose.yml to the new tag and digest: `app`
and `archive` share an image, and a mismatch runs last month's archiver on this month's schema.
```bash
cd /srv/matomo
docker compose pull
docker compose up -d
docker compose exec -T -u www-data app php /var/www/html/console core:update --no-interaction
docker compose logs --tail 30 app
```
Matomo does not migrate its schema on boot, which is what `core:update` is for. Re-run step 7's
tracker and archive checks before calling the update done.
## 10. What will probably go wrong
The dashboard will be empty on the day it is installed and the user will decide tracking is
broken. Mine was. Step 7 turned off the archiving a page load used to trigger, so nothing is
computed until the archive container's hourly pass. The raw hits are in the database the whole
time. Before touching anything, run
`docker compose exec -T -u www-data app php /var/www/html/console core:archive --no-ansi` and
reload. If the numbers appear, nothing was wrong and the fix is to wait.
## 11. Out of scope
- Do not configure SMTP. Matomo runs without mail, and its scheduled email reports are a
second install to do properly.
- Do not enable browser-triggered archiving to make today's numbers appear sooner. That is the
setting step 7 turned off, and turning it back on is how a Matomo gets slow.
- Do not install Marketplace plugins now. Heatmaps, Funnels and the rest are paid licences,
and each is a schema change on a database with one backup.
- Do not sign up with MaxMind or hand-install a GeoIP database. Matomo downloads a free DB-IP
city database from its own Geolocation screen, later, if the user wants it.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Matomo 5.12.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.
Read this before step 1. That hostname becomes Matomo's trusted host and goes inside the
tracking snippet you paste on every page you measure, so moving it later means editing all of
them and telling Matomo about the new name.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line. Those are upstream's figures for a site tracking 100,000
page views a month, and Matomo grows into them rather than out of them.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB of RAM,
stop and resize the box: PHP and MariaDB will both start on less and the archiving run in step
7 is where it falls over, which is a much worse place to find out.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/matomo /srv/matomo/backups
sudo install -d -m 750 /srv/matomo/matomo
sudo install -d -m 700 /srv/matomo/mariadb
ls -la /srv/matomo
```
You should see: `backups` owned by you, `matomo` at mode `drwxr-x---` and `mariadb` at mode
`drwx------`, both owned by root.
If you do not: leave those two owned by root on purpose. The Matomo image unpacks its whole PHP
tree into `matomo` and chowns it to www-data the first time it starts, MariaDB chowns its own
data directory, and a directory you have already chowned to yourself makes one of them refuse
to initialise.
## 3. Secrets
Two secrets, both generated here on the server: the password for the `matomo` database user and
the MariaDB root password. Matomo ships no account and no admin token of its own, because the
browser wizard in step 7 creates the first user.
```bash
umask 077
cat > /srv/matomo/.env <<EOF
MARIADB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/matomo/.env
umask 022
ls -l /srv/matomo/.env
```
You should see: mode `-rw-------`, your own username twice, and the path.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/matomo/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten both
passwords, which is fine before the database exists and a problem afterwards: MariaDB keeps the
password it was created with, so a changed `MARIADB_PASSWORD` on an existing data directory
produces an access-denied line in the Matomo log rather than anything about passwords.
Do not paste that file, either password, or any command output containing them into this chat
window. Docker Compose reads the file itself for the `${...}` substitutions in compose.yml, and
the wizard's database form arrives with the password already filled in and masked, so you never
have to type it or look at it.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/matomo/compose.yml <<'EOF'
# Matomo · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image README ....... https://github.com/matomo-org/docker
# docker install FAQ . https://matomo.org/faq/how-to-install/install-matomo-with-docker/
# archiving cron ..... https://matomo.org/faq/on-premise/how-to-set-up-auto-archiving-of-your-reports/
#
# Three services. `app` is Apache with PHP; `archive` is the same image with a
# loop around `console core:archive` in place of its entrypoint, sharing the
# web root because the archiver reads the config the wizard writes. Every
# ${...} comes from /srv/matomo/.env, mode 600. Digests read 2026-08-06; both
# images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: matomo-db
restart: unless-stopped
# Archiving writes wide rows; upstream's example raises this too.
command: --max-allowed-packet=64MB
environment:
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: ${MARIADB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- /srv/matomo/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
# No `ports:` at all: 3306 is reachable only from the other containers.
app:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-app
restart: unless-stopped
environment:
# These six only prefill the wizard's database form; after that the
# credentials live in config.ini.php and these are never read again.
MATOMO_DATABASE_HOST: db
MATOMO_DATABASE_ADAPTER: mysql
MATOMO_DATABASE_TABLES_PREFIX: matomo_
MATOMO_DATABASE_USERNAME: matomo
MATOMO_DATABASE_PASSWORD: ${MARIADB_PASSWORD}
MATOMO_DATABASE_DBNAME: matomo
PHP_MEMORY_LIMIT: 512M
volumes:
- /srv/matomo/matomo:/var/www/html
healthcheck:
test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/index.php || exit 1"]
interval: 10s
retries: 24
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8119.
- "127.0.0.1:8119:80"
depends_on:
db:
condition: service_healthy
archive:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-archive
restart: unless-stopped
# Apache's user, so what this writes stays readable by the web process.
# Reports are computed here, hourly, never on a page load.
user: www-data
environment:
PHP_MEMORY_LIMIT: 512M
volumes:
- /srv/matomo/matomo:/var/www/html
entrypoint: ["/bin/sh", "-c", "while true; do [ -s /var/www/html/config/config.ini.php ] && php /var/www/html/console core:archive --no-ansi; sleep 3600; done"]
depends_on:
app:
condition: service_started
EOF
cd /srv/matomo && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/matomo/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/matomo/compose.yml` and paste again in one go. The third service is the one people
skip and then regret. Upstream documents scheduled archiving as a script that runs every hour,
and without it Matomo computes its reports while somebody sits watching a page load.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-matomo
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Matomo · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://matomo.org/faq/how-to-install/faq_98/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also Matomo's trusted host and the address inside the tracking snippet on
# every page you measure.
<DOMAIN> {
encode zstd gzip
# Matomo sets its own X-Frame-Options and CSP; these are the rest.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Caddy passes the Host through and adds X-Forwarded-For, which
# trusted_hosts[] and proxy_client_headers[] read. 8119 is loopback only.
reverse_proxy 127.0.0.1:8119
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-matomo /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates TLS and speaks plain http to the container, which is why step
7 writes `assume_secure_protocol` into Matomo's config: without it Matomo would decide the
request came in over http, and `force_ssl` would send the browser round in a redirect loop.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8119` or `3306`.
If you do not: delete anything for `8119` or `3306` with `sudo ufw delete allow 8119`. 8119 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. `Status: inactive` is a different problem: Prompt
Zero left this firewall enabled, so something has turned it off since, and `sudo ufw enable`
puts it back before you go any further.
## 7. Start and verify
The first start unpacks about 200 MB of PHP into /srv/matomo/matomo, so give it time.
```bash
cd /srv/matomo
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/index.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/ | grep -c 'Matomo is libre software used to analyze traffic from your visitors'
```
You should see: the loop reaching `200`, then `1` from the grep, which is the installer's
welcome page answering on your hostname.
If you do not: run `docker compose logs --tail 20 db` first, because a database that never
reports healthy holds up everything after it, and `docker compose logs --tail 40 app` second. A
`502` that never clears is step 5. A running container is not success.
Now write the settings the wizard never asks about, before anybody uses it. Matomo reads this
file before config.ini.php, so the wizard cannot overwrite it. Replace `<DOMAIN>` on the
`trusted_hosts` line before you paste.
```bash
cd /srv/matomo
docker compose exec -T -u www-data app sh -c 'cat > /var/www/html/config/common.config.ini.php' <<'EOF'
[General]
; Caddy terminates TLS and speaks plain http to the container, so Matomo is
; told the request arrived over https before it builds any https link.
assume_secure_protocol = 1
force_ssl = 1
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
; The only hostname allowed in a Host header.
trusted_hosts[] = "<DOMAIN>"
; Reports come from the archive container, not from a page load.
enable_browser_archiving_triggering = 0
browser_archiving_disabled_enforce = 1
EOF
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/index.php
```
You should see: `200`.
If you do not: a `500` means a typo in that file. Read it back with
`docker compose exec -T app cat /var/www/html/config/common.config.ini.php`, fix the line, and
run the curl again. A redirect loop in a browser means `assume_secure_protocol` did not land,
so check that the file really contains it.
Now open https://<DOMAIN> in a browser and work through the wizard. The database screen is
already filled in and its password masked, so keep the adapter on its default and press
Next. The superuser you create on the `Super User` screen is the only account this install
has, and it goes in your password manager before you click past it. The website you name on
the last screen is what the tracking snippet Matomo prints at the end belongs to.
When the wizard is finished, prove it, back in the terminal:
```bash
cd /srv/matomo
curl -sS 'https://<DOMAIN>/index.php?module=Installation&action=welcome' | grep -c 'Matomo is already installed'
docker compose exec -T -u www-data app php /var/www/html/console config:get --section=General --key=trusted_hosts | grep -c '<DOMAIN>'
curl -sS -o /dev/null -w '%{http_code}\n' 'https://<DOMAIN>/matomo.php?idsite=1&rec=1&action_name=selfhost-check&url=https%3A%2F%2Fexample.com%2F'
sleep 5
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from matomo_log_visit" "$MARIADB_DATABASE"'
docker compose exec -T -u www-data app php /var/www/html/console core:archive --no-ansi | tail -5
```
You should see, in order: `1`, `1`, `200`, a count of `1` or more, and a last block of output
ending in `Done archiving!`.
If you do not: the first `1` is the one that matters most. It means the installer now answers
`Error: Matomo is already installed.` to anyone who finds that URL, which is the difference
between a finished install and a setup wizard sitting open on a public hostname. A `0` there
means the wizard never completed, so go back to the browser. The second `1` reads Matomo's own
merged config and proves the trusted-host list names your hostname and nothing else. A count of
`0` means the tracker accepted your request and dropped it: read
`docker compose logs --tail 40 app`. If the archive run ends in an error instead, your reports
will quietly stop refreshing while the tracker keeps recording, so fix it before you trust
anything on the dashboard.
The first screen at https://<DOMAIN> now shows the heading `Sign in` above a
`Username or e-mail` field, a `Password` field and a `Lost your password?` link.
## 8. First backup and restore
Two artifacts. The database holds every visit, user and computed report. The config archive
holds what rebuilds the service around it, including the `config` directory Matomo wrote its
credentials into.
```bash
cd /srv/matomo
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/matomo/backups/matomo-db-$(date +%F).sql.gz
sudo tar -czf /srv/matomo/backups/matomo-config-$(date +%F).tar.gz -C /srv/matomo compose.yml .env matomo/config -C /etc/caddy Caddyfile
ls -lh /srv/matomo/backups/
```
You should see: two files, both a few tens of kilobytes on a fresh install. Nothing goes
offline, because `--single-transaction` snapshots a running InnoDB database.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump`
failed and the shell created the file anyway. Run the dump line without `| gzip` to read the
error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:
```bash
mkdir -p ~/backups/matomo
scp vps:/srv/matomo/backups/* ~/backups/matomo/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/matomo/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is one test visit:
```bash
cd /srv/matomo
docker compose down
sudo rm -rf /srv/matomo/mariadb
sudo install -d -m 700 /srv/matomo/mariadb
docker compose up -d db
sleep 40
gunzip -c /srv/matomo/backups/matomo-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d
sleep 20
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from matomo_log_visit" "$MARIADB_DATABASE"'
```
You should see: the same count you saw in step 7, from a database that was deleted and rebuilt.
If you do not: `Access denied` means the database container had not finished initialising, so
wait longer and run the `gunzip` line again. The `config` directory was never touched by this
exercise, which is why the site came back without it: if you ever lose that directory too,
untar the config archive into /srv/matomo before starting anything, because Matomo will not run
at all without it and MariaDB reads its password from `.env` the moment it initialises.
## 9. Updating later
New versions are listed at https://github.com/matomo-org/matomo/releases. Take both backup
artifacts first, then edit both image lines in /srv/matomo/compose.yml to the new tag and its
digest: `app` and `archive` run the same image, and a mismatch runs last month's archiver on
this month's schema.
```bash
cd /srv/matomo
docker compose pull
docker compose up -d
docker compose exec -T -u www-data app php /var/www/html/console core:update --no-interaction
docker compose logs --tail 30 app
```
You should see: `core:update` reporting the database upgrade and finishing, then a normal
Apache start with no repeating restart.
If you do not: put the old tag and digest back on both image lines and run the same commands.
Matomo does not migrate its schema on boot, which is what `core:update` is for, so an update
that skips it leaves a new binary reading an old database. Re-run the tracker and archive
checks from step 7 before you call the update done.
## 10. What will probably go wrong
The dashboard will be empty on the day you install it and you will decide tracking is broken.
Mine was. Step 7 turned off the archiving a page load used to trigger, so nothing is computed
until the archive container's hourly pass, which can be an hour away. The raw hits are in the
database the whole time. Before touching anything, run
`docker compose exec -T -u www-data app php /var/www/html/console core:archive --no-ansi` and
reload. If the numbers appear, nothing was wrong and the fix is to wait.
## 11. Out of scope
- Do not configure SMTP. Matomo runs without mail, and its scheduled email reports are a
second install to do properly.
- Do not enable browser-triggered archiving to make today's numbers appear sooner. That is the
setting step 7 turned off, and turning it back on is how a Matomo gets slow.
- Do not install Marketplace plugins now. Heatmaps, Funnels and the rest are paid licences,
and each is a schema change on a database with one backup.
- Do not sign up with MaxMind or hand-install a GeoIP database. Matomo downloads a free DB-IP
city database from its own Geolocation screen, later, if you want it.329 lines · 14,973 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Matomo 5.12.0, with the MariaDB it stores visits in, under ~/selfhost/matomo, answering
at http://localhost:8119.
## 1. Preflight
Say this first, because it decides whether the user wants this install. The tracking snippet
Matomo hands them loads from http://localhost:8119, which means "this computer" in whoever's
browser reads it, so a public page carrying it records nobody else. They get an analytics
install counting what they open here. Then detect the OS and measure:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. Matomo with MariaDB needs 2048 MB of RAM
available and 10 GB free on the home disk. If either floor is missed, print both and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/matomo/backups
ls -la ~/selfhost/matomo
```
Assert: `backups`, owned by the user. There is no `data` folder: the database and the web root
live in Docker-managed volumes, because both images chown their directory to a uid a Windows
bind mount cannot grant.
## 4. Secrets
Two secrets: the `matomo` database user's password and the MariaDB root password. Matomo ships
no account of its own; the wizard in step 7 makes the first user. Generate both here, print
neither, keep both out of your summary and any log line.
```bash
umask 077
cat > ~/selfhost/matomo/.env <<EOF
MARIADB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/matomo/.env
umask 022
ls -l ~/selfhost/matomo/.env
```
Assert: mode `-rw-------`. Git Bash ships openssl. On Windows those mode bits are advisory:
NTFS does not enforce them, and the real boundary is the user's own Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/matomo/compose.yml <<'EOF'
# Matomo · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# image README ....... https://github.com/matomo-org/docker
# docker install FAQ . https://matomo.org/faq/how-to-install/install-matomo-with-docker/
# archiving cron ..... https://matomo.org/faq/on-premise/how-to-set-up-auto-archiving-of-your-reports/
#
# Three services on the computer you are sitting at. Both data mounts are named
# volumes, not relative binds: MariaDB and the Matomo image each chown their
# directory to a uid of their own, which Docker Desktop's Windows file sharing
# cannot grant on a home-directory bind mount. Every ${...} comes from ./.env,
# mode 600. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: matomo-db
restart: unless-stopped
# Archiving writes wide rows; upstream's example raises this too.
command: --max-allowed-packet=64MB
environment:
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: ${MARIADB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- matomo-db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
# No `ports:` at all: 3306 is reachable only from the other containers.
app:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-app
restart: unless-stopped
environment:
# These six only prefill the wizard's database form, once: after it the
# credentials live in config.ini.php and nothing reads these again.
MATOMO_DATABASE_HOST: db
MATOMO_DATABASE_ADAPTER: mysql
MATOMO_DATABASE_TABLES_PREFIX: matomo_
MATOMO_DATABASE_USERNAME: matomo
MATOMO_DATABASE_PASSWORD: ${MARIADB_PASSWORD}
MATOMO_DATABASE_DBNAME: matomo
PHP_MEMORY_LIMIT: 512M
volumes:
- matomo-html:/var/www/html
healthcheck:
test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/index.php || exit 1"]
interval: 10s
retries: 24
ports:
# Loopback only: no other device on the wifi can reach 8119.
- "127.0.0.1:8119:80"
depends_on:
db:
condition: service_healthy
archive:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-archive
restart: unless-stopped
# Apache's user, so what this writes stays readable by the web process.
# Reports are computed here, hourly, never on a page load.
user: www-data
environment:
PHP_MEMORY_LIMIT: 512M
volumes:
- matomo-html:/var/www/html
entrypoint: ["/bin/sh", "-c", "while true; do [ -s /var/www/html/config/config.ini.php ] && php /var/www/html/console core:archive --no-ansi; sleep 3600; done"]
depends_on:
app:
condition: service_started
volumes:
matomo-db:
matomo-html:
EOF
cd ~/selfhost/matomo && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule: no hostname to resolve, nothing public to
attest, nothing published past loopback to close. Browsers treat http://localhost as a secure
context, so pages needing crypto still work. 8119 is bound to 127.0.0.1: not the phone, not a
laptop on the wifi, not anyone. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/matomo/compose.yml
```
Assert: one line, `- "127.0.0.1:8119:80"`. MariaDB publishes no host port, so 3306 cannot show.
## 7. Start and verify
```bash
cd ~/selfhost/matomo
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8119/index.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8119/ | grep -c 'Matomo is libre software used to analyze traffic from your visitors'
docker compose exec -T -u www-data app sh -c 'cat > /var/www/html/config/common.config.ini.php' <<'INI'
[General]
; Reports come from the archive container, not from a page load.
enable_browser_archiving_triggering = 0
browser_archiving_disabled_enforce = 1
INI
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8119/index.php
```
Assert: the loop ends on `200`, the grep prints `1`, the last line prints `200`, all three
printed. The heredoc writes the setting the wizard never asks about into a file it cannot
overwrite; a `500` after it is a typo there. The first start unpacks 200 MB of PHP, so let the
loop run out. On a miss read `docker compose logs --tail 40 app` and
`docker compose logs --tail 20 db`: a database that never reports healthy is step 4, and
`port is already allocated` means something else holds 8119. A running container is not
success.
STOP: tell the user to open http://localhost:8119, work through the wizard, and wait. Do
not continue until they confirm. Tell them the database screen is filled in and masked, so
they keep the adapter on its default and press Next, and the superuser they create is this
install's only account and goes in their password manager now. Then prove the wizard is
closed:
```bash
cd ~/selfhost/matomo
curl -sS 'http://localhost:8119/index.php?module=Installation&action=welcome' | grep -c 'Matomo is already installed'
curl -sS -o /dev/null -w '%{http_code}\n' 'http://localhost:8119/matomo.php?idsite=1&rec=1&url=https%3A%2F%2Fexample.com%2F'
sleep 5
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from matomo_log_visit" "$MARIADB_DATABASE"'
docker compose exec -T -u www-data app php /var/www/html/console core:archive --no-ansi | tail -5
```
Assert all four, printing what you got. The grep prints `1`, the security assert here: the
wizard now refuses anyone who reaches that URL. The tracker returns `200`. The count is `1` or
more, a tracking request that became a row. The archive run ends with `Done archiving!`, and a
`0` count means the tracker dropped it: read `docker compose logs --tail 40 app`.
The first screen at http://localhost:8119 now shows the heading `Sign in` above
`Username or e-mail`, `Password` and a `Lost your password?` link.
## 8. First backup and restore
Three artifacts: the database, Matomo's `config` directory, which holds the credentials and
salt inside a volume, and the two files here that rebuild the service.
```bash
cd ~/selfhost/matomo
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > backups/matomo-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html -czf - config > backups/matomo-appconfig-$(date +%F).tar.gz
tar -C ~/selfhost/matomo -czf backups/matomo-compose-$(date +%F).tar.gz compose.yml .env
ls -lh backups/
```
Assert: three files, none empty, all three sizes printed.
All three sit on the same disk as the data, which is not a backup, and on a laptop the disk and
the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or a USB stick, copy all three there with `cp`, and have them confirm the filenames.
With no such destination, say plainly that this install has no backup.
To restore: untar the compose archive into ~/selfhost/matomo first, so .env is back before
anything starts, because MariaDB takes its password from it the moment it initialises an empty
volume. Then `docker compose down -v`, the one place `-v` belongs because it drops the old
volumes deliberately, `docker compose up -d`, wait for 8119, then
`docker compose exec -T app tar -C /var/www/html -xzf - < backups/matomo-appconfig-DATE.tar.gz`
and `gunzip -c` on the `.sql.gz` piped into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`.
Reload http://localhost:8119 and sign in: that is the whole disaster plan.
## 9. Updating later
New versions are at https://github.com/matomo-org/matomo/releases. Take all three backups
first, then edit both image lines in ~/selfhost/matomo/compose.yml: `app` and `archive` share
an image.
```bash
cd ~/selfhost/matomo
docker compose pull
docker compose up -d
docker compose exec -T -u www-data app php /var/www/html/console core:update --no-interaction
```
`core:update` applies the schema change; Matomo does not migrate on boot. Re-run step 7's
checks before calling the update done.
## 10. What will probably go wrong
I rebooted this machine, opened the dashboard, and got a connection error that reads like a
lost database. It was not: Docker Desktop had not started with the session, so nothing was
listening on 8119 and nothing was being counted either. `restart: unless-stopped` acts only
once the daemon is up, so turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/matomo && docker compose up -d` before concluding anything broke.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8119 to 0.0.0.0. That puts a one-password install on every network they join.
- Do not configure SMTP, do not install Marketplace plugins, and do not sign up with MaxMind.
Heatmaps and Funnels are paid licences, and Matomo downloads a free DB-IP city database from
its own Geolocation screen if the user ever wants one.compose.local.ymlthe services, pinned · local layout83 lines
# Matomo · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# image README ....... https://github.com/matomo-org/docker
# docker install FAQ . https://matomo.org/faq/how-to-install/install-matomo-with-docker/
# archiving cron ..... https://matomo.org/faq/on-premise/how-to-set-up-auto-archiving-of-your-reports/
#
# Three services on the computer you are sitting at. Both data mounts are named
# volumes, not relative binds: MariaDB and the Matomo image each chown their
# directory to a uid of their own, which Docker Desktop's Windows file sharing
# cannot grant on a home-directory bind mount. Every ${...} comes from ./.env,
# mode 600. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: matomo-db
restart: unless-stopped
# Archiving writes wide rows; upstream's example raises this too.
command: --max-allowed-packet=64MB
environment:
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: ${MARIADB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- matomo-db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
# No `ports:` at all: 3306 is reachable only from the other containers.
app:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-app
restart: unless-stopped
environment:
# These six only prefill the wizard's database form, once: after it the
# credentials live in config.ini.php and nothing reads these again.
MATOMO_DATABASE_HOST: db
MATOMO_DATABASE_ADAPTER: mysql
MATOMO_DATABASE_TABLES_PREFIX: matomo_
MATOMO_DATABASE_USERNAME: matomo
MATOMO_DATABASE_PASSWORD: ${MARIADB_PASSWORD}
MATOMO_DATABASE_DBNAME: matomo
PHP_MEMORY_LIMIT: 512M
volumes:
- matomo-html:/var/www/html
healthcheck:
test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/index.php || exit 1"]
interval: 10s
retries: 24
ports:
# Loopback only: no other device on the wifi can reach 8119.
- "127.0.0.1:8119:80"
depends_on:
db:
condition: service_healthy
archive:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-archive
restart: unless-stopped
# Apache's user, so what this writes stays readable by the web process.
# Reports are computed here, hourly, never on a page load.
user: www-data
environment:
PHP_MEMORY_LIMIT: 512M
volumes:
- matomo-html:/var/www/html
entrypoint: ["/bin/sh", "-c", "while true; do [ -s /var/www/html/config/config.ini.php ] && php /var/www/html/console core:archive --no-ansi; sleep 3600; done"]
depends_on:
app:
condition: service_started
volumes:
matomo-db:
matomo-html:agent-readable mirror: /self-host/matomo-cloud.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned79 lines
# Matomo · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image README ....... https://github.com/matomo-org/docker
# docker install FAQ . https://matomo.org/faq/how-to-install/install-matomo-with-docker/
# archiving cron ..... https://matomo.org/faq/on-premise/how-to-set-up-auto-archiving-of-your-reports/
#
# Three services. `app` is Apache with PHP; `archive` is the same image with a
# loop around `console core:archive` in place of its entrypoint, sharing the
# web root because the archiver reads the config the wizard writes. Every
# ${...} comes from /srv/matomo/.env, mode 600. Digests read 2026-08-06; both
# images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: matomo-db
restart: unless-stopped
# Archiving writes wide rows; upstream's example raises this too.
command: --max-allowed-packet=64MB
environment:
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: ${MARIADB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- /srv/matomo/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
# No `ports:` at all: 3306 is reachable only from the other containers.
app:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-app
restart: unless-stopped
environment:
# These six only prefill the wizard's database form; after that the
# credentials live in config.ini.php and these are never read again.
MATOMO_DATABASE_HOST: db
MATOMO_DATABASE_ADAPTER: mysql
MATOMO_DATABASE_TABLES_PREFIX: matomo_
MATOMO_DATABASE_USERNAME: matomo
MATOMO_DATABASE_PASSWORD: ${MARIADB_PASSWORD}
MATOMO_DATABASE_DBNAME: matomo
PHP_MEMORY_LIMIT: 512M
volumes:
- /srv/matomo/matomo:/var/www/html
healthcheck:
test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/index.php || exit 1"]
interval: 10s
retries: 24
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8119.
- "127.0.0.1:8119:80"
depends_on:
db:
condition: service_healthy
archive:
image: matomo:5.12.0-apache@sha256:85d27206a4acdd43259909aa00cab1913dec88cfba53e1ce66a51e6caa430a55
container_name: matomo-archive
restart: unless-stopped
# Apache's user, so what this writes stays readable by the web process.
# Reports are computed here, hourly, never on a page load.
user: www-data
environment:
PHP_MEMORY_LIMIT: 512M
volumes:
- /srv/matomo/matomo:/var/www/html
entrypoint: ["/bin/sh", "-c", "while true; do [ -s /var/www/html/config/config.ini.php ] && php /var/www/html/console core:archive --no-ansi; sleep 3600; done"]
depends_on:
app:
condition: service_startedCaddyfilethe hostname and TLS26 lines
# Matomo · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://matomo.org/faq/how-to-install/faq_98/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also Matomo's trusted host and the address inside the tracking snippet on
# every page you measure.
<DOMAIN> {
encode zstd gzip
# Matomo sets its own X-Frame-Options and CSP; these are the rest.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Caddy passes the Host through and adds X-Forwarded-For, which
# trusted_hosts[] and proxy_client_headers[] read. 8119 is loopback only.
reverse_proxy 127.0.0.1:8119
}install.shthe same install, no agent184 lines
#!/usr/bin/env bash
# Matomo · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=stats.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://github.com/matomo-org/docker
# https://matomo.org/faq/how-to-install/install-matomo-with-docker/
# https://matomo.org/faq/how-to-install/faq_98/
# https://matomo.org/faq/on-premise/what-is-the-trusted-host-check-feature-in-matomo/
# https://matomo.org/faq/on-premise/how-to-set-up-auto-archiving-of-your-reports/
#
# Two secrets are generated here, on this machine: the password for the matomo
# database user and the MariaDB root password. Both go into /srv/matomo/.env
# with mode 600 and neither is ever printed. Matomo itself ships no account:
# the browser wizard creates the superuser, which is why this script stops
# short of a finished install and hands you the last two steps.
#
# DOMAIN_HOST is also Matomo's trusted host and the address inside the tracking
# snippet on every page you measure. Changing it later means editing all of them.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/matomo}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. stats.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP and MariaDB want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# matomo and mariadb stay owned by root: the Matomo image unpacks its PHP tree
# into matomo and chowns it to www-data, and MariaDB chowns its data directory.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 "$APP_DIR/matomo"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64: both travel inside a connection string that the
# wizard fills in for you. Read them later, if you ever need to, with
# sudo grep -E 'MARIADB_PASSWORD|MARIADB_ROOT_PASSWORD' /srv/matomo/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
MARIADB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-matomo"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8119 nor 3306 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8119 and 3306 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The first start unpacks about 200 MB of PHP into $APP_DIR/matomo, so the wait
# loop below is generous on purpose.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/index.php"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/index.php" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/index.php answered ${code:-nothing}. Check: docker compose logs --tail 40 app"
curl -sS "https://${DOMAIN_HOST}/" | grep -q 'Matomo is libre software used to analyze traffic from your visitors' \
|| die "that hostname answered 200 without the installer welcome page. Check: docker compose logs --tail 40 app"
# --- 7. The settings the wizard never asks about -----------------------------
#
# Matomo reads config/common.config.ini.php before config/config.ini.php, so
# the wizard cannot overwrite any of this. trusted_hosts is armed before the
# wizard runs rather than after, so the installer itself is covered too.
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" <<-'INI' | docker compose exec -T -u www-data app sh -c 'cat > /var/www/html/config/common.config.ini.php'
[General]
; Caddy terminates TLS and speaks plain http to the container, so Matomo is
; told the request arrived over https before it builds any https link.
assume_secure_protocol = 1
force_ssl = 1
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
; The only hostname allowed in a Host header.
trusted_hosts[] = "<DOMAIN>"
; Reports come from the archive container, not from a page load.
enable_browser_archiving_triggering = 0
browser_archiving_disabled_enforce = 1
INI
after="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/index.php" || true)"
[ "$after" = "200" ] || die "after writing common.config.ini.php the site answered ${after}. Read it back with: docker compose exec -T app cat /var/www/html/config/common.config.ini.php"
# --- 8. The first backup, before day one ends --------------------------------
#
# Taken now, with an empty Matomo, so the restore path is proved before there
# is anything to lose. The config archive carries the live Caddy site block,
# not the <DOMAIN> template.
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$APP_DIR/backups/matomo-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/matomo-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env matomo/config -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/matomo-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/matomo-config-${STAMP}.tar.gz" ] || die "the config archive is empty"
cat <<-DONE
Matomo is serving its installer at https://${DOMAIN_HOST}
1. Open https://${DOMAIN_HOST} in a browser now and work through the wizard.
The database screen is already filled in and its password masked, so keep
the adapter on its default and press Next. The superuser you create is the
only account this install has: put it
in your password manager before you click past that screen. The website you
name on the last screen owns the tracking snippet Matomo prints at the end.
2. Then prove the wizard is closed, which is the security check this script
cannot run for you:
curl -sS 'https://${DOMAIN_HOST}/index.php?module=Installation&action=welcome'
It must contain "Matomo is already installed". If it still shows the wizard,
an open setup form is sitting on a public hostname. Then confirm the
trusted-host list, which is what stops host-header injection:
cd $APP_DIR && docker compose exec -T -u www-data app php /var/www/html/console config:get --section=General --key=trusted_hosts
3. Prove tracking works end to end:
curl -sS -o /dev/null -w '%{http_code}\n' 'https://${DOMAIN_HOST}/matomo.php?idsite=1&rec=1&url=https%3A%2F%2Fexample.com%2F'
cd $APP_DIR && docker compose exec -T db sh -c 'exec mariadb -N -B -u"\$MARIADB_USER" -p"\$MARIADB_PASSWORD" -e "select count(*) from matomo_log_visit" "\$MARIADB_DATABASE"'
A 200 and a count of 1 or more means a tracking request became a row.
4. Reports are computed by the matomo-archive container, hourly, and never on
a page load. The dashboard is therefore empty until the first pass runs, up
to an hour after you finish the wizard. To see numbers sooner, run
docker compose exec -T -u www-data app php /var/www/html/console core:archive --no-ansi
5. First backup written to $APP_DIR/backups: a database dump and a config
archive. They are on the same disk as the data, which is not a backup.
Copy them off the box tonight:
scp vps:$APP_DIR/backups/* ~/backups/matomo/
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Matomo Cloud.
- Archiving is now a job you own. Reports are computed by a container on an hourly loop rather than when you look at them, so today's numbers can be up to an hour behind, and if that container stops your dashboard quietly freezes while the tracker keeps recording. Nobody emails you about it.
- You own a MariaDB, and Matomo is the kind of analytics that fills one. Every hit is a row, the archive tables grow on top of the raw log, and upstream sizes 2 GB of RAM and 50 GB of disk for a site doing 100,000 page views a month. Backups are a dump, restores are a load, and a dump you have never restored is not a backup.
- The first person to open the address becomes the superuser. Matomo ships with no account at all: the browser wizard creates it, so between the container starting and somebody finishing that wizard, the install is a setup form on a public hostname. This install closes it in the same sitting and then checks that it is closed.
- Cookies and consent come back to you. Matomo tracks with a first-party cookie by default and ships the opt-out, anonymisation and consent machinery, but which of those you switch on is a legal decision this install does not make for you, and it is the decision that made the cookieless trackers popular.
- Heatmaps, Funnels, A/B testing, Custom Reports, Media Analytics and the SEO plugins are paid licences on Matomo's Marketplace even when you host it yourself. What is free is the whole core suite: visits, visitors, goals, ecommerce, campaigns, segments, the reporting API, and country-level geolocation from a DB-IP database Matomo downloads for you without a MaxMind account.
Where this came from
“Until now, your Matomo interface has been initiating this archiving process in an ad hoc fashion: users were clicking on the browser to view a report and, in many cases, the report needed to be calculated on demand.”
- Upstream tells you to run archiving on a schedule, every hour, and to set browser_archiving_disabled_enforce so a page load stops computing reports on demand. source
- Upstream's Docker guide runs the archiver as a third container built from the same image, alongside the app and the database, and asks for PHP_MEMORY_LIMIT on both PHP containers. source
- The MATOMO_DATABASE_* environment variables the official image documents are read by the installation wizard's database form, which is why they stop mattering once config.ini.php exists. source
- Matomo stores a list of trusted hostnames in config/config.ini.php and checks the Host header against it, which is what stops host-header injection into password-reset links. source
- Behind a reverse proxy that terminates TLS, upstream asks for assume_secure_protocol together with force_ssl, plus proxy_client_headers[] so the visitor's real IP is read from X-Forwarded-For. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Matomo Cloud?
Not Matomo Cloud itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Matomo. The full Google-Analytics-shaped suite on your own hostname, with visitor profiles, funnels and no monthly hit meter. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 120 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Matomo Cloud?
Matomo. The full Google-Analytics-shaped suite on your own hostname, with visitor profiles, funnels and no monthly hit meter. It is the same software. Matomo Cloud is a hosted instance of the GPL release you can run yourself, so the feature comparison is not a comparison at all, and the honest question is which half of the job you want: the reports, or the database and the hourly archiving job that produce them. Self-hosting also removes the hit meter, which is the part of the bill that punishes a site for doing well. Also evaluated and ranked below it: Plausible CE. The prompt on this page installs Matomo only. Matomo is GPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Matomo Cloud?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Matomo itself is free and GPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Matomo Cloud Business · 100,000 hits/month, $42/mo — $504 a year, a metered rate, not a whole bill.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Matomo install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Matomo on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Matomo on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: The tracking snippet Matomo gives you loads from http://localhost:8119, which means the reader's own computer in the reader's own browser, so on this path Matomo counts the pages you open on this machine and nothing anyone else ever loads. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.