Goal: Production-ready Matomo on two Debian 13 servers — one for the web app (Nginx + PHP-FPM), one for the database (MariaDB + phpMyAdmin). We’ll harden the stack with UFW and Fail2ban, enable HTTPS, and explain the why behind every key config line.
Example domains: matomo.uptimeguard.net (app) and pma.uptimeguard.net (DB/phpMyAdmin). Replace with your own.
Table of Contents
- Prerequisites
- High-Level Architecture
- Server #2 — Database Node (MariaDB + phpMyAdmin)
- Server #1 — Matomo App Node (Nginx + PHP-FPM)
- Verification Checklist
- Troubleshooting
- Next Steps & Optimizations
Prerequisites
- Two Debian 13 (“Trixie”) servers, each with 2 vCPU / 4 GB RAM and SSD/NVMe.
- DNS for your domains pointing to the correct servers.
- Shell access with
sudo. - A mailbox for Let’s Encrypt (e.g.,
admin@yourdomain.tld). - Optional: a static admin IP you can allowlist.
Why two servers? Matomo’s reporting/archiving can be DB-intensive. Separating app and DB improves performance, isolation, and scaling.
High-Level Architecture
[ Browser ] ⇄ HTTPS ⇄ [ Nginx + PHP-FPM (Matomo) ] ⇄ 3306/TCP ⇄ [ MariaDB (phpMyAdmin via Nginx) ]
Hardening layers:
UFW firewall on both nodes
Fail2ban jails (sshd, nginx, mysqld-auth)
HTTPS (Let’s Encrypt)
Server #2 — Database Node (MariaDB + phpMyAdmin)
Example FQDN: pma.uptimeguard.net
3.1 Base system & firewall
sudo apt update && sudo apt -y upgrade
sudo apt -y install ufw curl vim gnupg2 ca-certificates lsb-release
sudo timedatectl set-ntp true
UFW: only 22,80,443 for admin/web; DB port 3306 restricted to the APP server's public IP!
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw allow from to any port 3306 proto tcp
sudo ufw enable
sudo ufw status verbose
Why: Default-deny inbound, expose only what’s needed. DB port is allowlisted to the Matomo node.
3.2 MariaDB install + secure + tune
sudo apt -y install mariadb-server mariadb-client
sudo systemctl enable --now mariadb
Secure quickly (Debian’s helper name on MariaDB):
sudo mariadb-secure-installation
- Set root password
- Remove anonymous users
- Disallow root remote login
- Remove test DB
Enable network listen + baseline tuning (/etc/mysql/mariadb.conf.d/60-matomo-tuning.cnf):
[mysqld] # Network bind-address = 0.0.0.0 skip_name_resolve = 1
Connections & tables
max_connections = 100
thread_cache_size = 50
table_open_cache = 4000
table_definition_cache = 2000
open_files_limit = 65535
Charset
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
InnoDB (2 vCPU / 4 GB RAM)
innodb_buffer_pool_size = 2G
innodb_buffer_pool_instances = 2
innodb_log_file_size = 512M
innodb_log_buffer_size = 64M
innodb_flush_log_at_trx_commit= 1
innodb_flush_method = O_DIRECT
innodb_file_per_table = 1
innodb_io_capacity = 1000
innodb_io_capacity_max = 2000
Slow query diagnostics
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
Binary log (PITR / optional replica in future)
server-id = 1
log_bin = /var/log/mysql/mariadb-bin
binlog_format = ROW
expire_logs_days = 7
sync_binlog = 1
Why (key lines):
bind-address = 0.0.0.0 — listen on all IFs (UFW restricts who can reach 3306).
skip_name_resolve = 1 — faster auth; you must use IP/host-specific users.
innodb_buffer_pool_size — biggest win for reads/writes; 2G on a 4G box is a sweet spot.
log_bin + sync_binlog — enables point-in-time recovery (you can disable later if you don’t need it).
slow_query_log — you’ll see queries to index/tune.
Log folder & permissions (if you use /var/log/mysql):
sudo install -d -o mysql -g mysql -m 750 /var/log/mysql
sudo touch /var/log/mysql/error.log /var/log/mysql/slow.log
sudo chown mysql:mysql /var/log/mysql/*.log
sudo chmod 640 /var/log/mysql/*.log
sudo systemctl restart mariadb
open_files_limit via systemd override (to honor 65535):
sudo systemctl edit mariadb # paste: # [Service] # LimitNOFILE=65535
sudo systemctl daemon-reload
sudo systemctl restart mariadb
3.3 Create Matomo DB & users
sudo mariadb <<'SQL' CREATE DATABASE matomo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- for app server (TCP):
CREATE USER 'matomo_usr'@'' IDENTIFIED BY 'CHANGE_ME!';
GRANT ALL PRIVILEGES ON matomo.* TO 'matomo_usr'@'';
-- for local phpMyAdmin via TCP:
CREATE USER 'matomo_usr'@'127.0.0.1' IDENTIFIED BY 'CHANGE_ME!';
GRANT ALL PRIVILEGES ON matomo.* TO 'matomo_usr'@'127.0.0.1';
-- for local phpMyAdmin via socket:
CREATE USER 'matomo_usr'@'localhost' IDENTIFIED BY 'CHANGE_ME!';
GRANT ALL PRIVILEGES ON matomo.* TO 'matomo_usr'@'localhost';
FLUSH PRIVILEGES;
SQL
Why the three hosts: phpMyAdmin on the DB node may connect via localhost (socket) or 127.0.0.1 (TCP). The app server uses its public IP.
3.4 phpMyAdmin (Nginx + HTTPS)
sudo apt -y install nginx php-fpm php-mysql php-xml php-mbstring php-zip php-gd php-curl
sudo apt -y install phpmyadmin
Nginx server block /etc/nginx/sites-available/pma.yourdomain.conf:
server { listen 80; server_name pma.yourdomain.tld;root /usr/share/phpmyadmin;
index index.php index.html;
# Optionally protect with Basic Auth (recommended)
# auth_basic "Restricted";
# auth_basic_user_file /etc/nginx/.pma_htpasswd;
location / {
try_files $uri $uri/ /index.php?$args;
}
# deny sensitive paths
location ~ ^/(setup|test|vendor|composer\.json|config\.inc\.php) {
deny all;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock; # check your PHP socket version
}
client_max_body_size 20M;}
Enable + HTTPS:
sudo ln -s /etc/nginx/sites-available/pma.yourdomain.conf /etc/nginx/sites-enabled/
sudo nginx -t &&
sudo systemctl reload nginx
sudo apt -y install certbot python3-certbot-nginx
sudo certbot --nginx -d pma.yourdomain.tld --redirect -m admin@yourdomain.tld --agree-tos -n
phpMyAdmin DB host choice:
- Use
localhost(socket) → requires user...@localhost. - Or force TCP by setting
host = 127.0.0.1in/etc/phpmyadmin/config.inc.php→ requires user...@127.0.0.1.
3.5 Fail2ban for SSH, Nginx, MariaDB
/etc/fail2ban/jail.local:
[DEFAULT] ignoreip = 127.0.0.1/8 ::1 bantime = 6h findtime = 10m maxretry = 5 backend = systemd banaction = ufw banaction_allports = ufw
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
bantime = 24h
[nginx-http-auth]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
bantime = 24h
[nginx-botsearch]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
bantime = 24h
[mysqld-auth]
enabled = true
port = 3306
logpath = /var/log/mysql/error.log
bantime = 24h
Reload:
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status
sudo fail2ban-client status mysqld-auth
Server #1 — Matomo App Node (Nginx + PHP-FPM)
Example FQDN: matomo.uptimeguard.net
4.1 Base system & firewall
sudo apt update && sudo apt -y upgrade
sudo apt -y install nginx php-fpm php-mysql php-xml php-mbstring php-zip php-gd php-curl php-intl unzip ufw
sudo timedatectl set-ntp true
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
sudo ufw status verbose
4.2 PHP-FPM settings (with commentary)
Edit /etc/php/8.4/fpm/php.ini (adjust minor version if needed):
memory_limit = 512M ; enough headroom for archiving jobs post_max_size = 32M ; typical import limits upload_max_filesize = 32M max_execution_time = 120 date.timezone = Europe/Bratislava
[opcache]
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=192 ; shared cache for PHP bytecode
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=1
opcache.revalidate_freq=2
Edit pool /etc/php/8.4/fpm/pool.d/www.conf:
pm = dynamic
pm.max_children = 12 ;
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 6
catch_workers_output = yes
sudo systemctl restart php8.4-fpm
4.3 Install Matomo
sudo mkdir -p /var/www/matomo cd /tmp
curl -LO https://builds.matomo.org/matomo-latest.zip
sudo unzip matomo-latest.zip -d /var/www/
sudo chown -R www-data:www-data /var/www/matomo
sudo find /var/www/matomo -type d -exec chmod 755 {} \;
sudo find /var/www/matomo -type f -exec chmod 644 {} \;
4.4 Nginx vhost + HTTPS
/etc/nginx/sites-available/matomo.yourdomain.conf:
server { listen 80; server_name matomo.yourdomain.tld;root /var/www/matomo;
index index.php index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
# Static cache
location ~* \.(?:css|js|gif|jpe?g|png|svg|ico|woff2?)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
# deny sensitive folders
location ~* ^/(?:config|tmp|core|lang)/ { deny all; return 403; }
location ~ /\.ht { deny all; }
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.4-fpm.sock; # verify PHP socket name
fastcgi_read_timeout 120;
}
client_max_body_size 32M;
sendfile on;
}
sudo ln -s /etc/nginx/sites-available/matomo.yourdomain.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt -y install certbot python3-certbot-nginx
sudo certbot --nginx -d matomo.yourdomain.tld --redirect -m admin@yourdomain.tld --agree-tos -n
4.5 Connect to remote DB
Open https://matomo.yourdomain.tld and in the installer use:
- Database server:
pma.yourdomain.tld - Database name:
matomo - User:
matomo_usr - Password: (the one you set)
- Port:
3306
Quick CLI test from the app node (optional):
mysql -h pma.yourdomain.tld -u matomo_usr -p -D matomo -e "SELECT 1;"
4.6 Archiving with systemd timer
Create service:
sudo tee /etc/systemd/system/matomo-archive.service >/dev/null <<'EOF' [Unit] Description=Matomo core:archive
[Service]
Type=oneshot
User=www-data
Group=www-data
ExecStart=/usr/bin/php /var/www/matomo/console core:archive --url=https://matomo.yourdomain.tld
quiet
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOF
Create timer (every 10 minutes):
sudo tee /etc/systemd/system/matomo-archive.timer >/dev/null <<'EOF' [Unit] Description=Run Matomo archiver every 10 minutes
[Timer]
OnBootSec=5m
OnUnitActiveSec=10m
Unit=matomo-archive.service
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now matomo-archive.timer
systemctl status matomo-archive.timer --no-pager
Why: Offloads heavy report building to CLI, keeps the web UI snappy.
4.7 Fail2ban for Nginx
sudo apt -y install fail2ban
/etc/fail2ban/jail.local:
[DEFAULT] ignoreip = 127.0.0.1/8 ::1 bantime = 6h findtime = 10m maxretry = 5 backend = systemd banaction = ufw banaction_allports = ufw
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
bantime = 24h
[nginx-http-auth]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
bantime = 24h
[nginx-botsearch]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
bantime = 24h
[nginx-req-limit]
enabled = true
port = http,https
filter = nginx-req-limit
logpath = /var/log/nginx/access.log
maxretry = 30
findtime = 5m
bantime = 2h
Create custom filter:
sudo tee /etc/fail2ban/filter.d/nginx-req-limit.conf >/dev/null <<'EOF' [Definition] failregex = ^<HOST> - .* "(GET|POST|HEAD) .+" (403|404) .+$ ignoreregex = EOF
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status
sudo fail2ban-client status nginx-req-limit
Verification Checklist
https://matomo.yourdomain.tldloads with valid TLS and the installer completes.- On the DB node:
ss -lntp | grep 3306shows0.0.0.0:3306ufw statusallows3306only from your app server IP.
- From the app node:
mysql -h pma.yourdomain.tld -u matomo_usr -p -D matomo -e "SELECT 1;"works.
- Archiver:
systemctl status matomo-archive.timeris active (waiting).sudo -u www-data php /var/www/matomo/console core:archive --url=https://matomo.yourdomain.tldruns without errors.
- Fail2ban:
fail2ban-client statusshows your jails.journalctl -u fail2ban -n 50shows start without errors.
Troubleshooting
- phpMyAdmin “Access denied for user … @ localhost”
phpMyAdmin uses socket forhost=localhost. Create the accountuser@localhostor force TCP inconfig.inc.phpwithhost=127.0.0.1and createuser@127.0.0.1. - MariaDB still listening on 127.0.0.1
Add a final override file99-network-final.cnfwithbind-address=0.0.0.0. Check for systemd overrides (systemctl cat mariadb). - open_files_limit warning
Add systemd override:
systemctl edit mariadb→[Service]+LimitNOFILE=65535. - Archiving appears slow
Bumppm.max_childrena little (watch memory), and consider Redis for cache/sessions later.
Next Steps & Optimizations
- Redis (sessions + cache) to reduce DB pressure.
- Backups:
mariabackup+ binlogs for point-in-time recovery; scheduled logical dumps for portability. - GeoIP2 (MaxMind) for accurate geolocation.
- Zero Trust / VPN for phpMyAdmin access.
- Monitoring: Slow log rotation +
pt-query-digest, node exporter, etc.
Final Notes
Values here are tuned for 2 vCPU / 4 GB RAM and 1–2 websites to start. As traffic grows, scale up RAM (DB first), then CPU, then storage IOPS. Keep OS & PHP/MariaDB updated, and review Fail2ban bans periodically.