Matomo on Debian 13: 2-Server Setup with Nginx, MariaDB, phpMyAdmin & Fail2ban

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

  1. Prerequisites
  2. High-Level Architecture
  3. Server #2 — Database Node (MariaDB + phpMyAdmin)
    1. Base system & firewall
    2. MariaDB install + secure + tune
    3. Create Matomo DB & users
    4. phpMyAdmin (Nginx + HTTPS)
    5. Fail2ban for SSH, Nginx, MariaDB
  4. Server #1 — Matomo App Node (Nginx + PHP-FPM)
    1. Base system & firewall
    2. PHP-FPM settings (with commentary)
    3. Install Matomo
    4. Nginx vhost + HTTPS
    5. Connect to remote DB
    6. Archiving with systemd timer
    7. Fail2ban for Nginx
  5. Verification Checklist
  6. Troubleshooting
  7. 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.1 in /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.tld loads with valid TLS and the installer completes.
  • On the DB node:
    • ss -lntp | grep 3306 shows 0.0.0.0:3306
    • ufw status allows 3306 only 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.timer is active (waiting).
    • sudo -u www-data php /var/www/matomo/console core:archive --url=https://matomo.yourdomain.tld runs without errors.
  • Fail2ban:
    • fail2ban-client status shows your jails.
    • journalctl -u fail2ban -n 50 shows start without errors.

Troubleshooting

  • phpMyAdmin “Access denied for user … @ localhost”
    phpMyAdmin uses socket for host=localhost. Create the account user@localhost or force TCP in config.inc.php with host=127.0.0.1 and create user@127.0.0.1.
  • MariaDB still listening on 127.0.0.1
    Add a final override file 99-network-final.cnf with bind-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
    Bump pm.max_children a 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.