# Can I self-host Matomo Cloud?

**YES** — it's called Matomo. ONE EVENING setup · ~2 hours to running · 2 GB RAM minimum · $42/mo you stop paying ($504/yr on the Business · 100,000 hits/month plan) — a metered rate, not a whole bill.

Matomo authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/matomo-cloud/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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/

DONE
```

## Also evaluated

Ranked below Matomo for this swap. The prompts above install Matomo only.

- **Plausible CE** — 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 page this mirrors: https://caniselfhostit.com/self-host/matomo-cloud/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
