27 posts 109 tags 7 domains

Apache + PHP-FPM + MariaDB bezpiecznie i wydajnie — konfiguracja serwera LAMP krok po kroku

Jak ustawić Apache (mpm_event, TLS, nagłówki), PHP-FPM z osobną pulą dla każdej strony, OPcache i MariaDB tak, żeby serwer był szczelny i szybki.

Problem

Świeży apt install apache2 php mariadb-server działa od razu, ale domyślne ustawienia są pomyślane pod zgodność, nie pod produkcję. Apache startuje z mpm_prefork i mod_php, więc każdy proces trzyma w pamięci cały interpreter, nawet gdy wysyła plik CSS. Wszystkie strony działają jako www-data, więc dziura w jednej daje dostęp do plików pozostałych. Do tego nagłówki zdradzają wersje oprogramowania, OPcache ma za mało pamięci, a bufor InnoDB w MariaDB ma 128 MB, niezależnie od tego, ile RAM-u ma maszyna.

Poniżej konfiguracja, którą stosuję na Debianie 13 (PHP 8.4, MariaDB 11). Ubuntu 24.04 ma w repozytorium PHP 8.3. Możesz zamienić 8.4 na 8.3 we wszystkich ścieżkach i nazwach pakietów albo doinstalować 8.4 z PPA ondrej/php. Reszta konfiguracji jest taka sama.

Instalacja: od razu PHP-FPM, bez mod_php

bash
$sudo apt update
$sudo apt install apache2 ssl-cert php8.4-fpm php8.4-{mysql,opcache,intl,mbstring,xml,curl,gd,zip} mariadb-server
$sudo a2enmod mpm_event proxy_fcgi setenvif http2 ssl headers rewrite expires brotli
$sudo a2dismod -f autoindex status
$sudo systemctl restart apache2
$sudo apachectl -V | grep MPM
Server MPM: event

mpm_event obsługuje połączenia wątkami, a PHP wykonuje osobny proces PHP-FPM. Plik statyczny obsłuży wtedy lekki wątek Apache'a, a interpreter PHP uruchomi się tylko tam, gdzie jest potrzebny. Modułu autoindex nie potrzebujesz, bo listowanie katalogów to wyciek informacji. status też wyłączam, chyba że świadomie wystawiasz go tylko na 127.0.0.1.

Apache

Co gdzie leży

Debian dzieli konfigurację Apache'a na katalogi, a pliki włącza się poleceniami zamiast edytowania apache2.conf:

KatalogCo zawieraWłączanie
/etc/apache2/conf-available/ustawienia globalne, wspólne dla wszystkich strona2enconf nazwa
/etc/apache2/sites-available/jeden plik na domenę (vhost)a2ensite nazwa
/etc/apache2/mods-available/moduły i ich ustawieniaa2enmod nazwa

a2en* tworzy tylko symlink w odpowiednim katalogu *-enabled/, a a2dis* go usuwa. Żeby dodawanie domen było wygodne, wszystko, co się powtarza, idzie do konfiguracji globalnej i do jednego szablonu (mod_macro). Plik pojedynczej domeny ma wtedy jedną linię.

Samego /etc/apache2/apache2.conf nie ruszasz. Pliki z conf-enabled/ są wczytywane na jego końcu, więc ich ustawienia nadpisują domyślne, a aktualizacja pakietu nie zgłasza konfliktu w zmienionym apache2.conf.

Każdy plik z tej sekcji tworzysz albo edytujesz przez sudo nano <ścieżka>. Wklejasz treść (Ctrl+Shift+V albo prawy przycisk myszy), zapisujesz przez Ctrl+O i Enter, wychodzisz przez Ctrl+X. Jeśli plik już istnieje, najpierw usuń jego zawartość: Alt+\ skacze na początek, Alt+A zaznacza, Alt+/ skacze na koniec, Ctrl+K wycina zaznaczenie.

Ukrycie wersji i sztuczek diagnostycznych

/etc/apache2/conf-available/security.conf już istnieje i jest włączony. Zastąp jego zawartość:

apache
ServerTokens Prod
ServerSignature Off
TraceEnable Off

<DirectoryMatch "/\.(git|svn|hg)">
    Require all denied
</DirectoryMatch>
<FilesMatch "^\.(env|htpasswd|user\.ini)|\.(sql|bak|swp|log|ini)$">
    Require all denied
</FilesMatch>

Wątki MPM

/etc/apache2/mods-available/mpm_event.conf też już istnieje. Zastąp jego zawartość:

apache
<IfModule mpm_event_module>
    StartServers             2
    ServerLimit             16
    ThreadsPerChild         25
    MaxRequestWorkers      400
    MinSpareThreads         50
    MaxSpareThreads        150
    MaxConnectionsPerChild   0
</IfModule>

MaxRequestWorkers nie może przekroczyć ServerLimit × ThreadsPerChild. Wątki event są tanie, więc o realnym limicie przepustowości decyduje pula PHP-FPM, nie ta liczba.

Ustawienia wspólne dla wszystkich stron

Nowy plik /etc/apache2/conf-available/site-defaults.conf:

apache
ServerName localhost
Timeout 30
KeepAliveTimeout 5
MaxKeepAliveRequests 500
Protocols h2 http/1.1

AddOutputFilterByType BROTLI_COMPRESS;DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json application/xml image/svg+xml image/x-icon

SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLSessionTickets off

Header always set Strict-Transport-Security "max-age=31536000"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "frame-ancestors 'self'"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"

ExpiresActive On
ExpiresByType text/css               "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType image/webp             "access plus 6 months"
ExpiresByType font/woff2             "access plus 1 year"

Alias /.well-known/acme-challenge/ /var/www/letsencrypt/.well-known/acme-challenge/
<Directory /var/www/letsencrypt>
    Options None
    AllowOverride None
    Require all granted
</Directory>

BROTLI_COMPRESS;DEFLATE kompresuje odpowiedzi brotli, jeśli przeglądarka go obsługuje, a w przeciwnym razie gzipem. Brotli daje teksty o 15–20% mniejsze niż gzip. Debian włącza jednak gzip globalnie w mods-available/deflate.conf, a ten filtr działa przed brotli. Przeglądarka wysyłająca Accept-Encoding: gzip, deflate, br dostawałaby wtedy gzip. Moduł deflate zostaje jako zapas, wystarczy zakomentować jego reguły:

bash
$sudo sed -i 's/^\(\s*AddOutputFilterByType\)/#\1/' /etc/apache2/mods-available/deflate.conf

To plik konfiguracyjny pakietu, więc przy aktualizacji Apache'a apt może zapytać, czy zachować zmienioną wersję. Zachowaj ją. Reguł kompresji w .htaccess strony nie trzeba, a nawet nie da się nimi tego naprawić, bo AddOutputFilterByType tylko dokłada filtry do listy.

SSLProtocol wyłącza wszystko poniżej TLS 1.2. SSLSessionTickets off wyłącza bilety sesji TLS, bo Apache nie rotuje ich klucza aż do restartu, co osłabia forward secrecy. Pozostałe ustawienia TLS zostają w mods-available/ssl.conf, bo jego domyślna zawartość z Debiana (m.in. cache sesji i zestaw szyfrów) jest w porządku. Globalny ServerName wycisza ostrzeżenie AH00558: Could not reliably determine the server's fully qualified domain name przy każdym configtest i restarcie. Każdy vhost ma i tak własny ServerName. Timeout 30 zamiast domyślnych 300 sekund sprawia, że wolny albo zawieszony klient nie trzyma połączenia przez 5 minut. Dyrektywy Header i Expires z poziomu serwera działają we wszystkich vhostach. Alias dla acme-challenge pozwala certbotowi wystawić certyfikat dla każdej domeny przez jeden wspólny katalog, także dla takiej, która nie ma jeszcze vhosta.

Szablon vhosta

Nowy plik /etc/apache2/conf-available/php-site-macro.conf:

apache
<Macro PhpSite $domain $canonical $user $php>
<VirtualHost *:80>
    ServerName $domain
    ServerAlias www.$domain
    RewriteEngine On
    RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
    RewriteRule ^ https://$canonical%{REQUEST_URI} [L,R=301]
</VirtualHost>

<VirtualHost *:443>
    ServerName $domain
    ServerAlias www.$domain
    DocumentRoot /srv/www/$domain/public

    SSLEngine on
    <IfFile /etc/letsencrypt/live/$domain/fullchain.pem>
        SSLCertificateFile    /etc/letsencrypt/live/$domain/fullchain.pem
        SSLCertificateKeyFile /etc/letsencrypt/live/$domain/privkey.pem
    </IfFile>
    <IfFile !/etc/letsencrypt/live/$domain/fullchain.pem>
        SSLCertificateFile    /etc/ssl/certs/ssl-cert-snakeoil.pem
        SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key
    </IfFile>

    RewriteEngine On
    RewriteCond %{HTTP_HOST} !=$canonical [NC]
    RewriteRule ^ https://$canonical%{REQUEST_URI} [L,R=301]

    <Directory /srv/www/$domain/public>
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted
        FallbackResource /index.php
    </Directory>

    <FilesMatch "\.php$">
        SetHandler "proxy:unix:/run/php/php$php-fpm-$user.sock|fcgi://$user"
    </FilesMatch>

    <Directory /srv/www/$domain/public/uploads>
        AllowOverride None
        <FilesMatch "\.ph(p[0-9]?|tml|ar)$">
            SetHandler none
            Require all denied
        </FilesMatch>
    </Directory>

    ErrorLog  ${APACHE_LOG_DIR}/$domain-error.log
    CustomLog ${APACHE_LOG_DIR}/$domain-access.log combined
</VirtualHost>
</Macro>

Plik domeny, np. /etc/apache2/sites-available/nowy-sklep.pl.conf, zawiera jedną linię: domenę, adres kanoniczny, użytkownika puli PHP i wersję PHP. Adres kanoniczny decyduje, czy strona działa z www, czy bez:

apache
Use PhpSite nowy-sklep.pl nowy-sklep.pl web-nowy-sklep-pl 8.4

/etc/apache2/sites-available/stary-sklep.pl.conf, czyli strona z www na innej wersji PHP:

apache
Use PhpSite stary-sklep.pl www.stary-sklep.pl web-stary-sklep-pl 8.3

Dopóki domena nie ma certyfikatu z Let's Encrypt, vhost używa tymczasowego, samopodpisanego certyfikatu snakeoil. Dzięki temu stronę można włączyć i sprawdzić, zanim domena zacznie wskazywać na serwer. Po wystawieniu certyfikatu wystarczy przeładować Apache'a, a <IfFile> sam przełączy go na właściwy.

Vhost przyjmuje obie formy adresu, a wszystko, co nie jest adresem kanonicznym, przekierowuje na niego kodem 301. Każda strona dostaje w ten sposób przekierowanie HTTP → HTTPS i na wybraną wersję adresu, PHP przez własną pulę FPM i blokadę wykonywania PHP w uploads/. Zmiana w szablonie, np. nowa reguła, obejmuje po systemctl reload apache2 wszystkie domeny naraz.

Domyślny vhost dla nieznanych domen

Żądanie z nagłówkiem Host, którego nie obsługuje żaden vhost (skan po IP, cudza domena wskazująca na twój serwer), trafia do pierwszego wczytanego vhosta, czyli do przypadkowej strony. Zamiast tego niech dostaje 403.

Nowy plik /etc/apache2/sites-available/000-catchall.conf:

apache
<VirtualHost *:80>
    ServerName catchall.invalid
    DocumentRoot /var/www/html
    RewriteEngine On
    RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
    RewriteRule ^ - [F]
</VirtualHost>

<VirtualHost *:443>
    ServerName catchall.invalid
    DocumentRoot /var/www/html
    SSLEngine on
    SSLCertificateFile    /etc/ssl/certs/ssl-cert-snakeoil.pem
    SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key
    <Location />
        Require all denied
    </Location>
</VirtualHost>

Wyjątek dla acme-challenge przepuszcza weryfikację Let's Encrypt także dla domeny, która nie ma jeszcze włączonego vhosta.

Włączenie całości:

bash
$sudo a2enmod macro
$sudo a2enconf site-defaults php-site-macro
$sudo a2dissite 000-default
$sudo a2ensite 000-catchall
$sudo apachectl configtest
Syntax OK
$sudo systemctl reload apache2

TLS

Wspólne ustawienia TLS są już w site-defaults.conf.

Certbot pracuje w trybie webroot, czyli tylko zapisuje pliki w /var/www/letsencrypt, a konfiguracji Apache'a nie rusza. Wtyczka --apache nie radzi sobie z vhostami z mod_macro, a po jej edycjach szablon przestałby być jedynym źródłem konfiguracji. Jednorazowe przygotowanie:

bash
$sudo apt install certbot
$sudo mkdir -p /var/www/letsencrypt
$sudo certbot register -m jan.kowalski@example.com --agree-tos --no-eff-email
$printf '#!/bin/sh\nsystemctl reload apache2\n' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-apache
$sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-apache
$sudo systemctl list-timers | grep certbot

certbot register zakłada konto w Let's Encrypt raz na cały serwer, a wszystkie domeny korzystają potem z tego samego konta. Adres e-mail nie musi być w żadnej z domen. Wystarczy twój zwykły adres, na który Let's Encrypt wysyła ważne komunikaty o koncie i zmianach zasad.

Certyfikat odnawia timer certbot.timer, który dwa razy dziennie uruchamia certbot renew. Pliki w /etc/letsencrypt/live/<domena>/ to symlinki przestawiane na nowe wersje, więc konfiguracja Apache'a się nie zmienia. Hook w renewal-hooks/deploy/ przeładowuje Apache'a po każdym odnowieniu, żeby nowy certyfikat od razu był w użyciu. Sam certyfikat dla domeny wystawia skrypt newsite, opisany niżej.

PHP-FPM: osobna pula i osobny użytkownik dla każdej strony

Każda domena ma własnego użytkownika systemowego (web-nowy-sklep-pl) i własną pulę FPM działającą z jego uprawnieniami. Układ katalogów:

text
/srv/www/nowy-sklep.pl/   admin:web-nowy-sklep-pl    2750
├── public/               admin:web-nowy-sklep-pl    2750   ← DocumentRoot, kod strony
├── tmp/  sessions/       web-nowy-sklep-pl          0700
└── logs/                 web-nowy-sklep-pl          0750

admin to twoje konto administratora i do niego należy kod strony. Proces PHP działa jako inny użytkownik (web-nowy-sklep-pl) i może kod tylko czytać. Apache (www-data) jest dopisany do grupy strony, żeby mógł serwować pliki statyczne. Katalogi, do których aplikacja musi pisać (uploads, cache), oddajesz puli osobno, już po założeniu strony.

Exploit, który dostanie wykonanie kodu w PHP, nie nadpisze więc plików aplikacji i nie zajrzy do innych stron. Blokuje to open_basedir, a przede wszystkim uprawnienia w systemie plików.

Pliki puli generuje skrypt newsite z sekcji Nowa domena jednym poleceniem. Najważniejsze ustawienia to user/group, gniazdo listen o nazwie zgodnej z szablonem vhosta oraz open_basedir, session.save_path i upload_tmp_dir ustawione na katalog strony. Domyślną pulę www (działa jako www-data) wyłączysz po założeniu pierwszej strony.

Rotacja logów stron

Logi PHP każdej strony (php-error.log, php-slow.log) leżą w jej katalogu logs/. Logrotate z pakietów o nich nie wie, więc bez własnej reguły rosną bez końca. Jedna reguła obejmuje wszystkie strony, także te dodane później:

bash
$sudo nano /etc/logrotate.d/srv-www
text
/srv/www/*/logs/*.log {
    weekly
    rotate 8
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
}

copytruncate kopiuje log i zeruje oryginał, więc FPM, który trzyma plik otwarty, nie wymaga przeładowania. Sprawdzenie bez rotowania:

bash
$sudo logrotate -d /etc/logrotate.d/srv-www

Ile pm.max_children?

max_children = (RAM przeznaczony na PHP) / (pamięć jednego procesu). Dla wszystkich pul razem limit nie może przekroczyć pamięci, jaką maszyna ma dla PHP. Gdy jest za wysoki, przy szczycie ruchu do akcji wkracza OOM killer. Gdy jest za niski, w logu pojawi się server reached pm.max_children, a żądania będą czekać w kolejce.

Na start, gdy nie ma jeszcze ruchu do zmierzenia, przyjmij 80 MB na proces. To bezpieczne założenie dla typowego CMS-a i sklepu (WordPress zwykle zużywa 40–70 MB, PrestaShop 60–100 MB). Budżet na PHP to RAM minus bufor MariaDB minus ok. 0,5–1 GB na system i Apache'a:

RAM serwerainnodb_buffer_pool_sizeBudżet na PHPmax_children łącznie
1 GB128M~400 MB5
2 GB512M~1 GB12
4 GB1G~2,5 GB30
8 GB2G~5 GB60
16 GB4G~11 GB130

Ten łączny limit rozdzielasz na pule. Skrypt newsite daje każdej stronie pm.max_children = 8, co wystarcza dla małej strony. Kilka stron może mieć łącznie więcej, niż wynosi limit z tabeli, bo przy pm = ondemand rzadko wszystkie pracują naraz. Głównemu sklepowi podnieś limit w jego pliku puli, np. do 20.

Po kilku dniach realnego ruchu zmierz faktyczne zużycie pamięci i przelicz limit:

bash
$ps -C php-fpm8.4 -o rss= | awk '{s+=$1; n++} END {printf "%d procesów, średnio %.0f MB\n", n, s/n/1024}'
9 procesów, średnio 58 MB

Pomiar ma sens tylko pod obciążeniem. Świeżo uruchomione procesy jeszcze nic nie załadowały i pokażą zaniżoną wartość.

php.ini produkcyjny

Zamiast edytować php.ini, który nadpisuje aktualizacja, dodaj własny plik /etc/php/8.4/fpm/conf.d/99-prod.ini:

ini
expose_php = Off
display_errors = Off
display_startup_errors = Off
log_errors = On
error_reporting = E_ALL & ~E_DEPRECATED

allow_url_fopen = Off
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec

memory_limit = 256M
max_execution_time = 30
max_input_time = 30
post_max_size = 32M
upload_max_filesize = 32M
max_file_uploads = 20

session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax

realpath_cache_size = 4096K
realpath_cache_ttl = 600

; OPcache
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 30000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60
opcache.save_comments = 1

Gdy deploy przebiega atomowo (np. przełączenie symlinka current), możesz ustawić opcache.validate_timestamps = 0 i przy każdym wdrożeniu wykonywać systemctl reload php8.4-fpm. PHP przestaje wtedy sprawdzać stat() plików. JIT (opcache.jit = tracing) w typowym CMS-ie daje niewiele, a w kodzie obliczeniowym wyraźnie przyspiesza. W PHP 8.4 jest domyślnie wyłączony. Pozostałe zmiany w nowszym PHP opisałem we wpisie PHP 7 vs PHP 8 — główne różnice.

Sprawdzenie i przeładowanie:

bash
$sudo php-fpm8.4 -tt 2>&1 | grep -E 'ERROR|test is successful'
NOTICE: configuration file /etc/php/8.4/fpm/php-fpm.conf test is successful
$sudo apachectl configtest
Syntax OK
$sudo systemctl reload php8.4-fpm apache2

Nowa domena jednym poleceniem

Wszystkie kroki dla nowej domeny, czyli użytkownik, katalogi, pula FPM, vhost i certyfikat, robi jeden skrypt. Utwórz plik w edytorze:

bash
$sudo nano /usr/local/sbin/newsite

Wklej do niego poniższy skrypt, zapisz i nadaj plikowi prawo wykonania:

bash
$sudo chmod 755 /usr/local/sbin/newsite

/usr/local/sbin jest w PATH roota, więc skrypt wywołasz przez sudo newsite … z dowolnego katalogu. Treść skryptu:

bash
#!/bin/bash
# Użycie: newsite [--www] [--no-cert] domena.pl [wersja-php]
set -euo pipefail

WWW=0
CERT=1
ARGS=()
for a in "$@"; do
    case $a in
        --www)     WWW=1 ;;
        --no-cert) CERT=0 ;;
        *)         ARGS+=("$a") ;;
    esac
done
set -- "${ARGS[@]}"

DOMAIN=${1:?Użycie: newsite [--www] [--no-cert] domena.pl [wersja-php]}
DOMAIN=${DOMAIN,,}
PHP=${2:-8.4}
OWNER=${OWNER:-${SUDO_USER:-}}
SITE_USER="web-$(echo "$DOMAIN" | tr -c 'a-z0-9\n' '-' | cut -c1-28)"
ROOT=/srv/www/$DOMAIN
POOL=/etc/php/$PHP/fpm/pool.d/$SITE_USER.conf
VHOST=/etc/apache2/sites-available/$DOMAIN.conf
CANON=$DOMAIN
if (( WWW )); then CANON=www.$DOMAIN; fi

[[ -d /etc/php/$PHP/fpm ]] || { echo "Brak php$PHP-fpm" >&2; exit 1; }
[[ -n $OWNER && $OWNER != root ]] || { echo "Uruchom przez sudo ze swojego konta albo podaj OWNER=admin" >&2; exit 1; }

# Użytkownik strony i katalogi
id "$SITE_USER" &>/dev/null || useradd --system --home-dir "$ROOT" --shell /usr/sbin/nologin "$SITE_USER"
usermod -aG "$SITE_USER" www-data
install -d -o "$OWNER" -g "$SITE_USER" -m 2750 "$ROOT" "$ROOT/public"
install -d -o "$SITE_USER" -g "$SITE_USER" -m 0700 "$ROOT/tmp" "$ROOT/sessions"
install -d -o "$SITE_USER" -g "$SITE_USER" -m 0750 "$ROOT/logs"

# Pula PHP-FPM (istniejącej nie nadpisuje)
if [[ ! -f $POOL ]]; then
    cat > "$POOL" <<EOF
[$SITE_USER]
user = $SITE_USER
group = $SITE_USER
listen = /run/php/php$PHP-fpm-$SITE_USER.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 30s
pm.max_requests = 1000

request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = $ROOT/logs/php-slow.log

php_admin_value[open_basedir] = $ROOT/:/tmp/
php_admin_value[upload_tmp_dir] = $ROOT/tmp
php_admin_value[sys_temp_dir] = $ROOT/tmp
php_admin_value[session.save_path] = $ROOT/sessions
php_admin_value[error_log] = $ROOT/logs/php-error.log
php_admin_flag[log_errors] = on
EOF
fi
"php-fpm$PHP" -t
systemctl reload "php$PHP-fpm"

# Vhost (bez certyfikatu Let's Encrypt działa na snakeoil, patrz <IfFile> w szablonie)
echo "Use PhpSite $DOMAIN $CANON $SITE_USER $PHP" > "$VHOST"
a2ensite -q "$DOMAIN"
apachectl configtest
systemctl reload apache2

# Certyfikat: domena musi już wskazywać na ten serwer
if (( CERT )); then
    NAMES=(-d "$DOMAIN")
    if getent hosts "www.$DOMAIN" >/dev/null; then
        NAMES+=(-d "www.$DOMAIN")
    elif (( WWW )); then
        echo "--www wymaga rekordu DNS dla www.$DOMAIN" >&2; exit 1
    fi
    certbot certonly --webroot -w /var/www/letsencrypt --cert-name "$DOMAIN" "${NAMES[@]}" \
        --non-interactive --keep-until-expiring
    systemctl reload apache2
fi

echo "OK: https://$CANON -> $ROOT/public (PHP $PHP, pula $SITE_USER)"
(( CERT )) || echo "Bez certyfikatu. Po przepięciu DNS uruchom to samo polecenie bez --no-cert."

Właścicielem plików strony zostaje konto, z którego wywołujesz sudo newsite (skrypt odczytuje je ze zmiennej SUDO_USER). Inne konto podasz jako sudo OWNER=admin newsite …. Skrypt można bezpiecznie uruchamiać wielokrotnie. Istniejącego użytkownika i puli nie nadpisze, a ważnego certyfikatu nie odnowi przedwcześnie. Za każdym razem nadpisze tylko linię Use w pliku vhosta.

  • bez flagi adresem kanonicznym jest domena bez www, a --www przełącza na wersję z www;
  • drugi argument wybiera wersję PHP, o ile jest zainstalowana;
  • --no-cert pomija certyfikat, a resztę robi normalnie.

Nowa domena

Domena już wskazuje na serwer, więc wszystko dzieje się za jednym razem:

bash
$sudo newsite nowy-sklep.pl
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/nowy-sklep.pl/fullchain.pem
OK: https://nowy-sklep.pl -> /srv/www/nowy-sklep.pl/public (PHP 8.4, pula web-nowy-sklep-pl)

Jeśli aplikacja zapisuje pliki, oddaj jej puli katalogi do zapisu. Reszta kodu zostaje tylko do odczytu:

bash
$sudo install -d -o web-nowy-sklep-pl -g web-nowy-sklep-pl -m 2770 /srv/www/nowy-sklep.pl/public/uploads

Przy pierwszej stronie wyłącz jeszcze domyślną pulę www. Działa jako www-data i nie jest do niczego potrzebna. Wcześniej nie wolno jej ruszać, bo FPM bez żadnej puli nie wystartuje (No pool defined). FPM wczytuje z pool.d/ tylko pliki *.conf, więc wystarczy zmienić nazwę pliku:

bash
$sudo mv /etc/php/8.4/fpm/pool.d/www.conf /etc/php/8.4/fpm/pool.d/www.conf.disabled
$sudo php-fpm8.4 -t
$sudo systemctl reload php8.4-fpm

Po pierwszym certyfikacie sprawdź, czy odnawianie zadziała. --dry-run przechodzi przez całą weryfikację na serwerze testowym Let's Encrypt i nie podmienia plików. Bez żadnego certyfikatu na serwerze certbot nie ma czego sprawdzać i odpowiada No simulated renewals were attempted.

bash
$sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/nowy-sklep.pl/fullchain.pem (success)
$sudo certbot certificates

Przeniesienie działającej strony z innego serwera

Domena wskazuje jeszcze na stary serwer, więc Let's Encrypt nie wystawi certyfikatu. Kolejność jest wtedy inna:

1. Przygotuj stronę bez certyfikatu.

bash
$sudo newsite --www --no-cert stary-sklep.pl 8.3
OK: https://www.stary-sklep.pl -> /srv/www/stary-sklep.pl/public (PHP 8.3, pula web-stary-sklep-pl)
Bez certyfikatu. Po przepięciu DNS uruchom to samo polecenie bez --no-cert.

2. Przenieś pliki i bazę. Pliki strony trafiają do /srv/www/stary-sklep.pl/public. Jeśli strona używa bazy danych, załóż ją przez sudo newdb stary-sklep.pl (sekcja o MariaDB niżej) i zaimportuj do niej zrzut ze starego serwera. Kod, który leży poza public/ (np. app/ z bootstrap.php), wgraj obok public/, a nie do środka.

Pliki ze starego serwera przychodzą z jego uprawnieniami i grupą, a tam mogło być wszystko. Ustaw je od nowa dla całego kodu: grupa strony, katalogi 2750, pliki 640. PHP wtedy czyta kod, ale nie może go nadpisać. Katalogi do zapisu mają już pliki, więc oddajesz puli cały katalog razem z zawartością:

bash
$D=/srv/www/stary-sklep.pl
$sudo chgrp -R web-stary-sklep-pl $D/public $D/app
$sudo find $D/public $D/app -type d -exec chmod 2750 {} +
$sudo find $D/public $D/app -type f -exec chmod 640 {} +
$sudo chown -R web-stary-sklep-pl:web-stary-sklep-pl $D/public/uploads
$sudo find $D/public/uploads -type d -exec chmod 2770 {} +

Tak samo potraktuj inne katalogi, do których aplikacja zapisuje (cache, var, storage).

3. Sprawdź stronę, zanim przepniesz DNS. --resolve kieruje zapytanie na nowy serwer, a -k akceptuje tymczasowy certyfikat:

bash
$curl -skI --resolve www.stary-sklep.pl:443:203.0.113.10 https://www.stary-sklep.pl/ | head -1
HTTP/2 200

W przeglądarce to samo da wpis w /etc/hosts na twoim komputerze. Przeglądarka ostrzeże wtedy o certyfikacie.

4. Przepnij DNS. Rekordy A/AAAA domeny i www skieruj na nowy serwer. Dzień wcześniej obniż TTL, np. do 300 s, żeby zmiana rozeszła się szybko. Postęp sprawdzisz przez dig +short stary-sklep.pl.

Popularne resolvery, Google (8.8.8.8) i Cloudflare (1.1.1.1), trzymają stary rekord do końca jego TTL. Korzystają z nich też przeglądarki z DNS-over-HTTPS, więc strona może u ciebie nie działać, choć dig na serwerze pokazuje już nowy adres. Cache odświeżysz ręcznie na dns.google/cache i one.one.one.one/purge-cache, osobno dla domeny i www.

5. Pobierz certyfikat. Gdy domena wskazuje już na nowy serwer, uruchom to samo polecenie bez --no-cert. Skrypt pobierze certyfikat i przeładuje Apache'a:

bash
$sudo newsite --www stary-sklep.pl 8.3

Let's Encrypt też ma swoje resolvery z cache'em. Zaraz po przepięciu może jeszcze łączyć się ze starym serwerem. Wtedy certbot zgłasza Some challenges have failed, a w szczegółach jest stary adres IP i na przykład reader size limit exceeded, bo stary serwer odpowiedział całą stroną HTML zamiast tokena. Po upływie TTL to samo polecenie przejdzie. Jeśli adres w błędzie nadal jest obcy, sprawdź w /var/log/letsencrypt/letsencrypt.log pole addressesResolved i rekordy A/AAAA domeny oraz www.

Usunięcie domeny, z pozostawieniem plików w /srv/www:

bash
$sudo a2dissite nowy-sklep.pl && sudo systemctl reload apache2
$sudo rm /etc/apache2/sites-available/nowy-sklep.pl.conf /etc/php/8.4/fpm/pool.d/web-nowy-sklep-pl.conf
$sudo systemctl reload php8.4-fpm
$sudo certbot delete --cert-name nowy-sklep.pl

MariaDB / MySQL

Podstawowe zabezpieczenie

bash
$sudo mariadb-secure-installation

Usuń anonimowych użytkowników i bazę test, a także wyłącz zdalne logowanie roota. W Debianie root@localhost loguje się przez unix_socket, więc hasło nie jest mu potrzebne. Do bazy wchodzi się wtedy przez sudo mariadb, i tak warto to zostawić.

Konfiguracja serwera

/etc/mysql/mariadb.conf.d/90-local.cnf:

ini
[mysqld]
bind-address            = 127.0.0.1
skip-name-resolve       = 1
local_infile            = 0
symbolic-links          = 0

max_connections         = 100
innodb_buffer_pool_size = 1G
innodb_log_file_size    = 256M
innodb_flush_log_at_trx_commit = 1
innodb_file_per_table   = 1

tmp_table_size          = 64M
max_heap_table_size     = 64M
table_open_cache        = 4000
query_cache_type        = 0
query_cache_size        = 0

slow_query_log          = 1
slow_query_log_file     = /var/log/mysql/slow.log
long_query_time         = 1
  • innodb_buffer_pool_size to najważniejszy parametr wydajności. Wartość 1G jest dla serwera z 4 GB RAM-u, dla innych weź ją z tabeli w sekcji Ile c2?. Jeśli bufor nie zmieści się w pamięci, MariaDB nie wystartuje. Na serwerze tylko pod bazę daje się 60–70% RAM-u, a na serwerze LAMP, gdzie dzieli pamięć z PHP, zwykle 25–30%. Najlepiej, żeby cały aktywny zbiór danych mieścił się w buforze.
  • bind-address = 127.0.0.1: baza nie słucha na interfejsie publicznym. Jeśli potrzebujesz dostępu z zewnątrz, użyj tunelu SSH (ssh -L 3306:127.0.0.1:3306 serwer), a nie otwieraj portu.
  • query_cache przy współbieżnych zapisach bardziej szkodzi, niż pomaga, i w MySQL 8 już go nie ma.
  • innodb_flush_log_at_trx_commit = 2 przyspiesza zapisy kosztem utraty do sekundy transakcji przy awarii zasilania. Na danych sklepowych zostaw 1.

Debian 13 nie tworzy katalogu /var/log/mysql, bo MariaDB loguje błędy do journala. Bez tego katalogu log wolnych zapytań się nie włączy, więc utwórz go przed restartem:

bash
$sudo install -d -o mysql -g adm -m 2750 /var/log/mysql
$sudo systemctl restart mariadb
$sudo ss -tlnp | grep 3306
LISTEN 0 80 127.0.0.1:3306 0.0.0.0:* users:(("mariadbd",pid=812,fd=22))

Jedna baza i jeden użytkownik na stronę

Konto root w MariaDB nie potrzebuje hasła: loguje się przez unix_socket, czyli każdy, kto ma w systemie sudo, wchodzi do bazy przez sudo mariadb. Strona dostaje osobnego użytkownika z hasłem i dostępem tylko do własnej bazy. Zakłada go skrypt /usr/local/sbin/newdb. Tworzysz go tak samo jak newsite: sudo nano /usr/local/sbin/newdb, wklejasz treść, zapisujesz i wykonujesz sudo chmod 755 /usr/local/sbin/newdb.

bash
#!/bin/bash
# Użycie: newdb domena.pl
set -euo pipefail

DOMAIN=${1:?Użycie: newdb domena.pl}
DOMAIN=${DOMAIN,,}
NAME=$(echo "$DOMAIN" | tr -c 'a-z0-9\n' '_' | cut -c1-32)
ROOT=/srv/www/$DOMAIN
ENV=$ROOT/db.env

[[ -d $ROOT ]] || { echo "Najpierw: newsite $DOMAIN" >&2; exit 1; }
if [[ -n $(mariadb -Nse "SELECT 1 FROM mysql.user WHERE User='$NAME'") ]]; then
    echo "Użytkownik $NAME już istnieje, dane dostępowe: $ENV" >&2; exit 1
fi

PASS=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | cut -c1-32)
mariadb <<SQL
CREATE DATABASE IF NOT EXISTS \`$NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER '$NAME'@'localhost' IDENTIFIED BY '$PASS';
GRANT ALL PRIVILEGES ON \`$NAME\`.* TO '$NAME'@'localhost';
SQL

install -m 0640 -o "$(stat -c %U "$ROOT")" -g "$(stat -c %G "$ROOT")" /dev/null "$ENV"
printf 'DB_HOST=localhost\nDB_NAME=%s\nDB_USER=%s\nDB_PASS=%s\n' "$NAME" "$NAME" "$PASS" > "$ENV"
echo "OK: baza i użytkownik $NAME, dane dostępowe w $ENV"
bash
$sudo newdb nowy-sklep.pl
OK: baza i użytkownik nowy_sklep_pl, dane dostępowe w /srv/www/nowy-sklep.pl/db.env
$cat /srv/www/nowy-sklep.pl/db.env
DB_HOST=localhost
DB_NAME=nowy_sklep_pl
DB_USER=nowy_sklep_pl
DB_PASS=k3Jd9QwX…

Nazwa bazy i użytkownika powstaje z domeny. Hasło jest losowe i trafia do db.env poza public/, więc nie da się go pobrać przez WWW. Plik może czytać twoje konto i PHP strony, a inne strony nie. Te dane wpisujesz w konfigurację aplikacji. Skrypt nie nadpisze istniejącego użytkownika, więc przypadkowe drugie uruchomienie nie zmieni hasła, którego strona już używa.

ALL PRIVILEGES dotyczy tylko bazy tej strony. CMS i sklep potrzebują CREATE/ALTER przy aktualizacjach i instalacji modułów, a do innych baz użytkownik i tak nie ma dostępu. Nigdy nie dawaj aplikacji uprawnień ON *.*. Obejmują one wtedy wszystkie bazy na serwerze, a także globalne uprawnienie FILE, które przy SQL injection pozwala czytać pliki z dysku przez LOAD_FILE().

Analiza wolnych zapytań

bash
$sudo mariadb-dumpslow -s t -t 10 /var/log/mysql/slow.log
$sudo apt install mysqltuner && sudo mysqltuner

mysqltuner podaje sensowne wskazówki dopiero po co najmniej 24 godzinach działania pod ruchem. Uruchomiony zaraz po restarcie widzi puste statystyki.

Firewall i fail2ban

Otwarcie portów 80 i 443

Zakładam firewall nftables skonfigurowany według kroku 3 procedury hardeningu serwera. Jego reguły wpuszczają z internetu tylko SSH, a resztę odrzucają, więc Apache będzie niedostępny z zewnątrz, dopóki nie dopiszesz dwóch portów: 80 dla przekierowań i weryfikacji Let's Encrypt oraz 443 dla HTTPS. Otwórz plik reguł:

bash
$sudo nano /etc/nftables.conf

W łańcuchu chain input, pod regułami SSH, a przed linią limit rate 5/minute log prefix "nft-drop: ", dopisz:

nft
        # WWW
        tcp dport { 80, 443 } accept

Sprawdź składnię, przeładuj reguły i upewnij się, że nowa reguła działa:

bash
$sudo nft -c -f /etc/nftables.conf && echo SKŁADNIA-OK
SKŁADNIA-OK
$sudo systemctl reload nftables
$sudo nft list chain inet filter input | grep 443
tcp dport { 80, 443 } accept

Portu 3306 (MariaDB) nie dopisuj. Firewall go blokuje, a do tego baza słucha tylko na 127.0.0.1, więc z zewnątrz nie da się z nią połączyć nawet przy błędzie w regułach.

Co faktycznie nasłuchuje

Firewall decyduje, co wpuścić, ale warto też wiedzieć, które usługi w ogóle czekają na połączenia:

bash
$sudo ss -tlnp | awk 'NR>1 {print $4}' | sort -u
0.0.0.0:22
0.0.0.0:443
0.0.0.0:80
127.0.0.1:3306

0.0.0.0 oznacza wszystkie interfejsy, więc taka usługa jest osiągalna z sieci, o ile firewall ją przepuści. 127.0.0.1 to tylko sam serwer. Na liście nie powinno być nic poza SSH, Apache'em i MariaDB na 127.0.0.1.

fail2ban dla Apache'a

Do jaili z wpisu o hardeningu dochodzą jaile Apache'a. Utwórz dla nich osobny plik:

bash
$sudo nano /etc/fail2ban/jail.d/apache.local

i wklej:

ini
[apache-auth]
enabled  = true
backend  = auto
logpath  = /var/log/apache2/*error.log

[apache-botsearch]
enabled  = true
backend  = auto
logpath  = /var/log/apache2/*access.log
maxretry = 3
bantime  = 1d

[apache-noscript]
enabled  = true
backend  = auto
logpath  = /var/log/apache2/*error.log

backend = auto jest tu konieczne. W jail.local z wpisu o hardeningu domyślnym źródłem logów jest dziennik systemd (backend = systemd), a Apache zapisuje logi do plików. Bez tej linii jaile by się uruchomiły, ale nigdy niczego nie zobaczyły.

Sprawdź konfigurację, zrestartuj fail2bana i upewnij się, że jaile działają:

bash
$sudo fail2ban-client -t
OK: configuration test is successful
$sudo systemctl restart fail2ban
$sudo fail2ban-client status | grep apache
`- Jail list: apache-auth, apache-botsearch, apache-noscript, sshd

Backup bazy

Każda baza ląduje w osobnym pliku, więc odtworzenie jednej strony nie dotyka pozostałych. Zrzut idzie codziennie z timera systemd, a starsze kopie są automatycznie usuwane. --single-transaction robi spójny snapshot bez blokowania tabel InnoDB, dzięki czemu strony działają normalnie w trakcie backupu.

Skrypt

bash
$sudo nano /usr/local/sbin/dbbackup
bash
#!/bin/bash
# Zrzut każdej bazy do osobnego pliku + użytkownicy i uprawnienia, rotacja po KEEP_DAYS dniach
set -euo pipefail

DIR=/var/backups/mariadb
KEEP_DAYS=14
DATE=$(date +%F)
FAIL=0

install -d -m 0700 "$DIR"
mapfile -t DBS < <(mariadb -Nse "SHOW DATABASES" | grep -Ev '^(information_schema|performance_schema|sys|mysql)$')

for db in "${DBS[@]}"; do
    install -d -m 0700 "$DIR/$db"
    out=$DIR/$db/$db-$DATE.sql.zst
    if mariadb-dump --single-transaction --quick --routines --triggers --events --databases "$db" \
        | zstd -q -f -o "$out.tmp"; then
        mv "$out.tmp" "$out"
    else
        echo "BŁĄD zrzutu: $db" >&2
        rm -f "$out.tmp"
        FAIL=1
    fi
done

mariadb-dump --system=users | zstd -q -f -o "$DIR/users-$DATE.sql.zst"
find "$DIR" -name '*.sql.zst' -mtime +"$KEEP_DAYS" -delete
exit $FAIL
bash
$sudo chmod 755 /usr/local/sbin/dbbackup

Pliki trafiają do /var/backups/mariadb/<baza>/<baza>-<data>.sql.zst. Osobny users-<data>.sql.zst zawiera użytkowników i ich uprawnienia (CREATE USER + GRANT), bo te nie są częścią zrzutu pojedynczej bazy. Błąd jednej bazy nie przerywa pozostałych, ale cały przebieg kończy się błędem i widać to w systemctl --failed.

Uruchamianie co noc

Usługa, czyli co uruchomić:

bash
$sudo nano /etc/systemd/system/dbbackup.service
ini
[Unit]
Description=Zrzut baz MariaDB
After=mariadb.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/dbbackup
Nice=10
IOSchedulingClass=idle

Timer, czyli kiedy:

bash
$sudo nano /etc/systemd/system/dbbackup.timer
ini
[Unit]
Description=Codzienny zrzut baz MariaDB

[Timer]
OnCalendar=*-*-* 03:15
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true sprawia, że jeśli serwer był wyłączony o 3:15, zrzut wykona się zaraz po starcie. Włączenie i pierwszy przebieg od ręki:

bash
$sudo systemctl daemon-reload
$sudo systemctl enable --now dbbackup.timer
$sudo systemctl start dbbackup.service
$sudo journalctl -u dbbackup.service -n 20
$sudo ls -R /var/backups/mariadb
$systemctl list-timers dbbackup.timer

Odtworzenie jednej bazy

Zrzut zawiera CREATE DATABASE i USE, więc wystarczy go wczytać. Tabele z kopii zastąpią obecne, a innych baz to nie dotknie:

bash
$zstd -dc /var/backups/mariadb/nowy_sklep_pl/nowy_sklep_pl-2026-10-16.sql.zst | sudo mariadb

Ten sam plik przenosi bazę na inny serwer. Użytkownika strony zakładasz tam przez newdb.

Kopia na tym samym dysku nie chroni przed awarią serwera, więc /var/backups/mariadb trzeba jeszcze wysłać gdzieś dalej. Ja robię to resticiem, tak jak w backupie Raspberry Pi na Hetzner Storage Box, a odtwarzanie przećwiczyłem w osobnym wpisie o restic restore i mount. Backup, którego nikt nie próbował odtworzyć, to tylko nadzieja.

Weryfikacja

bash
$curl -sI https://nowy-sklep.pl | grep -iE '^(server|x-powered-by|strict-transport|x-content-type)'
server: Apache
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
$curl -so /dev/null -w '%{http_version}\n' https://nowy-sklep.pl
2
$curl -sI -H 'Accept-Encoding: gzip, deflate, br' https://nowy-sklep.pl | grep -i content-encoding
content-encoding: br
$curl -sI https://nowy-sklep.pl/.git/config | head -1
HTTP/2 403
$curl -sI https://nowy-sklep.pl/uploads/shell.php | head -1
HTTP/2 403

Brak nagłówka X-Powered-By i sama nazwa Apache bez numeru wersji oznaczają, że expose_php i ServerTokens działają. TLS sprawdzisz zewnętrznie przez testssl.sh nowy-sklep.pl albo SSL Labs (cel: A+).

Co się dzieje pod spodem

W modelu prefork + mod_php każde połączenie zajmuje cały proces Apache'a z załadowanym PHP, który waży 30–80 MB. Obowiązuje to także przy keep-alive i przy wysyłaniu obrazka. Przy 150 równoczesnych połączeniach wychodzi kilka gigabajtów RAM-u, z których większość przez większość czasu nic nie robi.

W modelu event + PHP-FPM Apache trzyma bezczynne połączenia keep-alive w jednym wątku nasłuchującym, a pliki statyczne obsługuje lekkimi wątkami. Żądanie .php przechodzi przez gniazdo uniksowe do puli FPM, gdzie wykonuje je jeden z ograniczonej liczby procesów. Taki proces działa jako użytkownik danej strony, ma open_basedir ograniczony do jej katalogu i własne logi. OPcache trzyma skompilowany bytecode we współdzielonej pamięci puli, więc PHP nie parsuje plików przy każdym żądaniu. MariaDB z dużym buffer_pool obsługuje odczyty z RAM-u zamiast z dysku. Przy każdej warstwie widać, ile zasobów zużywa i gdzie kończą się jej uprawnienia, a o to właśnie chodzi w tej konfiguracji.

Zobacz też