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
$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 MPMServer 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:
| Katalog | Co zawiera | Włączanie |
|---|---|---|
/etc/apache2/conf-available/ | ustawienia globalne, wspólne dla wszystkich stron | a2enconf nazwa |
/etc/apache2/sites-available/ | jeden plik na domenę (vhost) | a2ensite nazwa |
/etc/apache2/mods-available/ | moduły i ich ustawienia | a2enmod 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ść:
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ść:
<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:
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:
$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:
<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:
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:
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:
<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:
$sudo a2enmod macro$sudo a2enconf site-defaults php-site-macro$sudo a2dissite 000-default$sudo a2ensite 000-catchall$sudo apachectl configtestSyntax 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:
$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:
/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:
$sudo nano /etc/logrotate.d/srv-www
/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:
$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 serwera | innodb_buffer_pool_size | Budżet na PHP | max_children łącznie |
|---|---|---|---|
| 1 GB | 128M | ~400 MB | 5 |
| 2 GB | 512M | ~1 GB | 12 |
| 4 GB | 1G | ~2,5 GB | 30 |
| 8 GB | 2G | ~5 GB | 60 |
| 16 GB | 4G | ~11 GB | 130 |
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:
$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:
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:
$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 configtestSyntax 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:
$sudo nano /usr/local/sbin/newsite
Wklej do niego poniższy skrypt, zapisz i nadaj plikowi prawo wykonania:
$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:
#!/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--wwwprzełącza na wersję zwww; - drugi argument wybiera wersję PHP, o ile jest zainstalowana;
--no-certpomija certyfikat, a resztę robi normalnie.
Nowa domena
Domena już wskazuje na serwer, więc wszystko dzieje się za jednym razem:
$sudo newsite nowy-sklep.plSuccessfully received certificate.Certificate is saved at: /etc/letsencrypt/live/nowy-sklep.pl/fullchain.pemOK: 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:
$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:
$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.
$sudo certbot renew --dry-runCongratulations, 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.
$sudo newsite --www --no-cert stary-sklep.pl 8.3OK: 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ą:
$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:
$curl -skI --resolve www.stary-sklep.pl:443:203.0.113.10 https://www.stary-sklep.pl/ | head -1HTTP/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:
$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:
$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
$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:
[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_sizeto najważniejszy parametr wydajności. Wartość1Gjest 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_cacheprzy współbieżnych zapisach bardziej szkodzi, niż pomaga, i w MySQL 8 już go nie ma.innodb_flush_log_at_trx_commit = 2przyspiesza zapisy kosztem utraty do sekundy transakcji przy awarii zasilania. Na danych sklepowych zostaw1.
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:
$sudo install -d -o mysql -g adm -m 2750 /var/log/mysql$sudo systemctl restart mariadb$sudo ss -tlnp | grep 3306LISTEN 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.
#!/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"
$sudo newdb nowy-sklep.plOK: 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.envDB_HOST=localhostDB_NAME=nowy_sklep_plDB_USER=nowy_sklep_plDB_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ń
$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ł:
$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:
# WWW
tcp dport { 80, 443 } accept
Sprawdź składnię, przeładuj reguły i upewnij się, że nowa reguła działa:
$sudo nft -c -f /etc/nftables.conf && echo SKŁADNIA-OKSKŁADNIA-OK$sudo systemctl reload nftables$sudo nft list chain inet filter input | grep 443tcp 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:
$sudo ss -tlnp | awk 'NR>1 {print $4}' | sort -u0.0.0.0:220.0.0.0:4430.0.0.0:80127.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:
$sudo nano /etc/fail2ban/jail.d/apache.local
i wklej:
[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ą:
$sudo fail2ban-client -tOK: 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
$sudo nano /usr/local/sbin/dbbackup
#!/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
$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ć:
$sudo nano /etc/systemd/system/dbbackup.service
[Unit]
Description=Zrzut baz MariaDB
After=mariadb.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/dbbackup
Nice=10
IOSchedulingClass=idle
Timer, czyli kiedy:
$sudo nano /etc/systemd/system/dbbackup.timer
[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:
$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:
$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
$curl -sI https://nowy-sklep.pl | grep -iE '^(server|x-powered-by|strict-transport|x-content-type)'server: Apachestrict-transport-security: max-age=31536000x-content-type-options: nosniff$curl -so /dev/null -w '%{http_version}\n' https://nowy-sklep.pl2$curl -sI -H 'Accept-Encoding: gzip, deflate, br' https://nowy-sklep.pl | grep -i content-encodingcontent-encoding: br$curl -sI https://nowy-sklep.pl/.git/config | head -1HTTP/2 403$curl -sI https://nowy-sklep.pl/uploads/shell.php | head -1HTTP/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ż
- Zabezpieczenie serwera Linux od podstaw — SSH, nftables, fail2ban i aktualizacje, czyli fundament pod ten stos.
- PHP 7 vs PHP 8 — główne różnice — co sprawdzić w kodzie, zanim podniesiesz wersję PHP-FPM.
- Wszystkie wpisy o hardeningu w jednym miejscu.