Matomo on Hetzner: Self-Host with MariaDB & Caddy (€8.21/mo, 30 min)
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.
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:
- Welcome — Next.
- System check — should be all green. If PHP extensions are missing, the matomo-fpm image already includes them; complain in GitHub if not.
- Database setup — Database server:
db, Login:matomo, Password: paste from.env'sMYSQL_PASSWORD, Database name:matomo, Table prefix:matomo_, Adapter:PDO\MYSQL. - Create tables — auto.
- Super user — your admin account.
- First website — name, URL, timezone, currency.
- Tracking code — copy and paste before
</head>on your site. - Done — log in.
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:
- Settings → Marketplace → Search "Google Analytics Importer" → Install. Free, official Matomo plugin.
- Get a GA4 Service Account JSON from Google Cloud Console (BigQuery Data Viewer + Analytics Reader roles).
- Settings → Plugins → Google Analytics Importer → Configure → Upload service account JSON → pick your GA4 Property → Run Import.
- Pageviews + sources + traffic channels carry over. Custom events and audiences don't — you re-instrument.
- 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-datavolume, 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.
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
- Run the Stack Picker to confirm Matomo is your right pick — or pivot.
- TCO calculator: 3-year cost vs Matomo Cloud, Plausible Cloud, GA360.
- Plausible recipe if you want the lighter-weight alternative.
Found this useful?
Try the Stack Picker to get a personal recommendation, or browse the install recipe library.