Skip to content
$_ setuptracking
Recipes

Matomo on Hetzner: Self-Host with MariaDB & Caddy (€8.21/mo, 30 min)

2 min read
Isometric illustration of coder at server with code editor monitor on platform

Goal: a fully functional Matomo install on Hetzner Cloud (€8.21/mo CX32) with MariaDB and nginx + Let’s Encrypt, ready for goals, funnels, ecommerce, and the GA4 importer. ~30 minutes start-to-finish.

Matomo is the heaviest of the self-hosted analytics stacks because it’s also the most feature-complete. PHP 8.3 + MariaDB 11 + nginx is the boring, proven combo — no Docker required if you’d rather skip it, but we’ll containerize for clean ops. Tested on a fresh CX32 in Falkenstein (Ubuntu 24.04 LTS), 2026-05-02.

Why Matomo (and when to skip it)

Pick Matomo if you need any of these and Plausible doesn’t have them:

  • Goals + conversion attribution (multi-touch, last-non-direct, etc.)
  • Sales funnel reports
  • Ecommerce tracking (orders, revenue, abandoned carts)
  • Heatmaps + session recordings (paid plugin)
  • Importing your existing GA4 data (yes, the official importer works)
  • White-label / multi-tenant (unlimited sites in one install)

If you only need pageviews + sources, Plausible CE on Hetzner is half the RAM and a third the setup time. Don’t over-engineer.

Hardware sizing — pick CX32, not CX22

Matomo is JVM-of-PHP — it eats RAM. CX22 (4 GB) works for a personal install with <100k events/mo, but PHP-FPM + MariaDB + GeoIP + nginx will sustained-load above 70%. CX32 (8 GB / 2 vCPU / 80 GB SSD / €8.21/mo) is the comfortable floor. Above 5M events/mo go to CX42.

Step 1 — Provision the server (same as before)

Hetzner Cloud Console → Add Server → Falkenstein → Ubuntu 24.04 → CX32 → SSH key → name matomo-1. Note the public IPv4 as $IP. Cost: €8.21/mo.

Read:  Umami on Vercel + Neon: Free Self-Hosted Analytics in 5 Minutes

Step 2 — DNS

Type: A
Name: analytics      (or @)
Value: $IP
TTL:   60

If you fronted with Cloudflare, turn the proxy off for this hostname (Caddy/nginx will handle TLS). Verify: dig +short analytics.your-site.com.

Step 3 — Bootstrap the server + Docker

ssh root@$IP

apt update && apt upgrade -y
apt install -y curl ca-certificates gnupg ufw

install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
  | tee /etc/apt/sources.list.d/docker.list > /dev/null

apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

Step 4 — docker-compose for Matomo + MariaDB

mkdir -p /opt/matomo && cd /opt/matomo
nano docker-compose.yml

Paste this exactly:

services:
  db:
    image: mariadb:11
    restart: unless-stopped
    command: --max-allowed-packet=64MB
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE:      matomo
      MYSQL_USER:          matomo
      MYSQL_PASSWORD:      ${MYSQL_PASSWORD}
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-pyourpass"]
      interval: 30s
      retries: 5

  matomo:
    image: matomo:5-fpm
    restart: unless-stopped
    depends_on:
      - db
    environment:
      MATOMO_DATABASE_HOST:     db
      MATOMO_DATABASE_USERNAME: matomo
      MATOMO_DATABASE_PASSWORD: ${MYSQL_PASSWORD}
      MATOMO_DATABASE_DBNAME:   matomo
      MATOMO_DATABASE_TABLES_PREFIX: matomo_
      PHP_MEMORY_LIMIT: 512M
    volumes:
      - matomo-data:/var/www/html

  web:
    image: nginx:1.27-alpine
    restart: unless-stopped
    depends_on:
      - matomo
    ports:
      - "127.0.0.1:8088:80"   # localhost only — Caddy fronts
    volumes:
      - matomo-data:/var/www/html:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

volumes:
  db-data:
  matomo-data:

Now the nginx.conf Matomo needs (pretty standard PHP-FPM config):

cat > nginx.conf <<'EOF'
server {
    listen 80;
    server_name _;
    root /var/www/html;
    index index.php;

    client_max_body_size 64M;

    location / { try_files $uri $uri/ =404; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass matomo:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
    }

    location ~ /\. { deny all; }
    location ~ ^/(config|tmp|core|lang)/ { deny all; }
    location ~ /[a-zA-Z0-9_-]+\.(?:js|css|png|jpg|gif|svg|ico|woff2?)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}
EOF

And the .env:

cat > .env <

Step 5 — Boot and front with Caddy

docker compose up -d

First boot pulls ~400 MB of images and initializes the DB. Watch:

docker compose logs -f web matomo

When you see nginx ready and PHP-FPM accepting connections (~60 seconds), test locally:

curl -I http://127.0.0.1:8088
# Expect: HTTP/1.1 302 Found  Location: /index.php?...

Now install Caddy and front it:

apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy

cat > /etc/caddy/Caddyfile <

Caddy fetches the Let's Encrypt cert in ~10 seconds. Verify:

journalctl -u caddy -f | grep -E "obtained|certificate"

Step 6 — First-run install wizard

Open https://analytics.your-site.com. The Matomo installer walks you through 8 steps:

  1. Welcome — Next.
  2. System check — should be all green. If PHP extensions are missing, the matomo-fpm image already includes them; complain in GitHub if not.
  3. Database setup — Database server: db, Login: matomo, Password: paste from .env's MYSQL_PASSWORD, Database name: matomo, Table prefix: matomo_, Adapter: PDO\MYSQL.
  4. Create tables — auto.
  5. Super user — your admin account.
  6. First website — name, URL, timezone, currency.
  7. Tracking code — copy and paste before </head> on your site.
  8. Done — log in.
Read:  PostHog on Hetzner: Self-Host Product Analytics with Docker (€16.41/mo, 45 min)

Within ~30 seconds of pasting the snippet, the dashboard shows your first visit.

Step 7 — Cron for archiving (mandatory above ~10 sites)

Matomo aggregates raw events into pre-computed reports. Without a cron, the aggregation runs synchronously when you open the dashboard — slow. Add this:

echo '5 * * * * docker compose -f /opt/matomo/docker-compose.yml exec -T matomo /usr/local/bin/php /var/www/html/console core:archive --url=https://analytics.your-site.com/ > /var/log/matomo-archive.log 2>&1' | crontab -

Then in Matomo: Settings → System → General → Archive reports = "from cron only" → Save.

Step 8 — GA4 importer (the actual reason you came)

If you're migrating off GA4 with historical data:

  1. Settings → Marketplace → Search "Google Analytics Importer" → Install. Free, official Matomo plugin.
  2. Get a GA4 Service Account JSON from Google Cloud Console (BigQuery Data Viewer + Analytics Reader roles).
  3. Settings → Plugins → Google Analytics Importer → Configure → Upload service account JSON → pick your GA4 Property → Run Import.
  4. Pageviews + sources + traffic channels carry over. Custom events and audiences don't — you re-instrument.
  5. Run both GA4 and Matomo in parallel for 30 days, compare totals. Within 5–15% is normal (different bot filtering, attribution windows).

What just happened (mechanism)

  • MariaDB 11 — primary store for everything: visits, actions, goals, ecommerce. Schema is normalized + has pre-aggregated archive tables for fast dashboard queries.
  • matomo:5-fpm — PHP-FPM process pool serving Matomo's PHP code. Talks to MariaDB on the internal Docker network.
  • nginx:alpine — serves static assets (CSS/JS/images) directly from the shared matomo-data volume, proxies dynamic requests to PHP-FPM via FastCGI.
  • Caddy on the host — fronts everything with Let's Encrypt TLS, redirects HTTP→HTTPS.
  • Cron archiver — runs hourly, builds pre-aggregated reports so dashboard queries take ms not seconds.
Read:  Umami on Vercel + Neon: Free Self-Hosted Analytics in 5 Minutes

Cookies set on visitors: 4 by default (_pk_id, _pk_ses, _pk_ref, _pk_cvar). Switch to cookieless by enabling Settings → Privacy → Anonymize visitors → Disable cookies. You lose visitor-uniqueness across sessions but gain consent-banner-free operation. The Stack Picker recommends Plausible if you need this — Matomo's cookieless mode is functional but is not its first-class path.

Backup, updates, monitoring

Backup — MariaDB volume + the matomo-data volume:

cat > /usr/local/bin/matomo-backup.sh <<'EOF'
#!/bin/bash
set -e
DATE=$(date +%F)
mkdir -p /backups/matomo
cd /opt/matomo
docker compose exec -T db mariadb-dump -u root -p"$(grep MYSQL_ROOT_PASSWORD .env | cut -d= -f2)" matomo | gzip > /backups/matomo/db-$DATE.sql.gz
tar czf /backups/matomo/files-$DATE.tar.gz -C /var/lib/docker/volumes/matomo_matomo-data/_data .
find /backups/matomo -mtime +14 -delete
EOF
chmod +x /usr/local/bin/matomo-backup.sh
echo "0 4 * * * /usr/local/bin/matomo-backup.sh" | crontab -

Updates: Matomo updates via the in-app updater (Settings → System → Updater). The Docker image follows minor versions — pull the latest 5.x weekly with docker compose pull && docker compose up -d.

Monitoring: Add UptimeRobot on https://analytics.your-site.com/matomo.php.

3-year cost

Item Year 1 3-year total
Hetzner CX32 €98.52 €295.56
Backup storage (Hetzner Storage Box 100GB) €38.40 €115.20
Total €136.92 €410.76

Matomo Cloud at the same volume (~100k pageviews/mo) is €228/year — self-hosting wins by year 1, even after counting your time.

When NOT to use this setup

  • You don't need goals/funnels/ecommerce. Plausible CE is half the operational complexity. See that recipe.
  • You hate PHP. PHP 8.3 is fine but Matomo's plugin ecosystem is PHP-only — every customization means PHP. If you'd rather write JS/Go, PostHog fits better.
  • You expect >20M events/mo. MariaDB will start showing cracks. Matomo Cloud or PostHog (ClickHouse-backed) scale better at that point.

Troubleshooting

"Connection error" on the install wizard step 3. The PHP container can't reach the DB container. Check docker compose ps — is db healthy? Did you wait 30s for MariaDB to finish initializing? Logs: docker compose logs db.

"max_allowed_packet" error during data import. Add --max-allowed-packet=128M to the db service command in compose, restart with docker compose up -d db.

Reports show stale data after a day. Cron archiver isn't running or doesn't have a real domain. Check tail -f /var/log/matomo-archive.log.

Next


Found this useful?

Try the Stack Picker to get a personal recommendation, or browse the install recipe library.