Własna aplikacja PHP na VPS - VirtualHost Apache i osobny pool PHP-FPM
Poradniki o konfiguracji Apache'a niemal zawsze zakładają WordPressa albo Joomlę, więc na sztywno wpisują nazwy katalogów, których Twoja aplikacja nie ma. Jeśli kod napisałeś sam - w Laravelu, Symfony, Slimie albo w płaskim PHP, które rosło przez pięć lat - potrzebujesz innego punktu wyjścia: VirtualHosta, który nic nie zakłada o strukturze projektu, i osobnego poola PHP-FPM zamykającego aplikację we własnym kawałku dysku. Ten artykuł pokazuje taką konfigurację, tłumaczy każdą dyrektywę i wymienia błędy, które pojawiają się, gdy zabraknie jednego elementu. Całość wygenerujesz w konfiguratorze Custom PHP Application na vps-web.com, ale to poniższe wyjaśnienia decydują o tym, czy output można spokojnie wkleić na produkcję.
Generator Custom PHP Application - co robi i komu się przyda
Konfigurator pyta o osiem wartości i zwraca sześć bloków poleceń: użytkownik systemowy i katalogi, pool PHP-FPM, opcjonalny certyfikat Let's Encrypt, plik VirtualHosta, aktywacja oraz krótka weryfikacja po wdrożeniu.
Te osiem pól to domena, opcjonalny alias subdomeny, wersja PHP, użytkownik systemowy, układ katalogów aplikacji, obsługa .htaccess, terminowanie TLS po stronie Apache'a i osobne pliki logów. Cała reszta - tryb menedżera procesów, limity pamięci, disable_functions, nagłówki bezpieczeństwa, cache plików statycznych - jest zaszyta w szablonach na wartościach sprawdzonych na produkcji. To świadoma decyzja. Formularz z dwudziestoma pięcioma polami zamienia pięciominutowe zadanie w popołudnie zastanawiania się, a kto naprawdę potrzebuje pm.max_spare_servers, ten wie, który plik otworzyć.
Dwa pola robią tu całą robotę. Układ aplikacji decyduje, czy DocumentRoot wskazuje na podkatalog public (Laravel, Symfony, Slim), czy na katalog główny projektu (płaskie PHP). Obsługa .htaccess decyduje, czy Apache w ogóle czyta pliki nadpisujące konfigurację w katalogach. Te dwa ustawienia trafione poprawnie ustawiają całą resztę.
Output celuje w Ubuntu i Debiana z Apache 2.4 oraz PHP-FPM w wersjach 8.1–8.5. Nie zakłada niczego o frameworku, bazie danych ani sposobie wdrażania kodu.
Praktyka - VirtualHost i pool krok po kroku
W przykładach używam domeny example.com, użytkownika appuser i PHP 8.3.
Jeden użytkownik, jeden pool, jedno gniazdo
Najbardziej opłacalna rzecz, jaką możesz zrobić na serwerze z więcej niż jedną stroną, to przestać uruchamiać wszystko jako www-data. Gdy każda aplikacja dzieli tę samą tożsamość, podatność pozwalająca odczytać plik w jednym serwisie wystawia dane dostępowe do bazy wszystkich pozostałych.
# Konto systemowe bez powłoki i bez katalogu domowego
useradd -r -M -d /var/www/example.com -s /usr/sbin/nologin appuser 2>/dev/null || true
install -d -o appuser -g appuser /var/www/example.com/public_html
install -d -o appuser -g appuser -m 0750 /var/www/example.com/tmp
install -d -o appuser -g appuser -m 0750 /var/www/example.com/logs
Uprawnienia 0750 na katalogach tmp i logs znaczą więcej, niż się wydaje. W tmp lądują pliki sesji PHP - przy domyślnym 0755 każdy użytkownik z dostępem do powłoki na tym serwerze odczyta identyfikatory aktywnych sesji i przejmie zalogowane konto.
Pool działa na tym koncie i nasłuchuje na własnym gnieździe uniksowym:
; /etc/php/8.3/fpm/pool.d/example.com.conf
[example.com]
user = appuser
group = appuser
listen = /run/php/example.com.sock
; Gniazdo należy do www-data, bo to Apache się z nim łączy
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 10s
pm.max_requests = 500
Tryb ondemand uruchamia procesy przy pierwszym żądaniu i pozwala im zniknąć po dziesięciu sekundach bezczynności, co pasuje do VPS-a z kilkoma serwisami o niewielkim ruchu. Przy stałym obciążeniu przejdź na dynamic, gdy opóźnienie pierwszego żądania zacznie być widoczne. pm.max_requests = 500 odświeża proces co pół tysiąca żądań, przez co wyciek pamięci w bibliotece zewnętrznej nie urośnie w nieskończoność.
Zwróć uwagę na rozdział własności gniazda: samo gniazdo należy do www-data, bo procesy Apache'a muszą się z nim połączyć, natomiast interpreter za nim działa jako appuser. Apache sięgnie do każdego poola na serwerze - i tak ma być - ale przejęty proces PHP zostaje zamknięty w jednej tożsamości.
Układ katalogów - public/ czy katalog główny
Nowoczesne frameworki PHP dostarczają katalog public z plikiem index.php i zasobami statycznymi, a kod aplikacji, vendor oraz konfiguracja leżą poziom wyżej. Ta struktura istnieje dokładnie po to, żeby serwer WWW nie miał jak dosięgnąć wrażliwych części projektu.
# Układ „public" - aplikacje frameworkowe
DocumentRoot /var/www/example.com/public_html/public
# Układ „root" - płaskie PHP, index.php w katalogu głównym
DocumentRoot /var/www/example.com/public_html
Przy pierwszym układzie vendor/ i .env znajdują się ponad DocumentRoot i Apache nie ma do nich ścieżki. Przy drugim wszystko leży w serwowanym drzewie, więc polegasz wyłącznie na jawnych regułach blokujących - dlatego konfigurator dokłada je automatycznie dla katalogów vendor, node_modules, storage, config, var i bin, gdy wybierzesz root:
<Directory "/var/www/example.com/public_html/vendor">
AllowOverride None
Require all denied
</Directory>
Zablokowanie katalogu w Apache'u nie odbiera PHP prawa do jego odczytu. require 'vendor/autoload.php' nadal zadziała, bo to operacja na systemie plików, a nie żądanie HTTP. Reguła zamyka drzwi wyłącznie od strony internetu.
Zamknięcie PHP w jednym katalogu
open_basedir to dyrektywa o najlepszym stosunku wysiłku do efektu w całej konfiguracji PHP. Ogranicza każdą operację na plikach do wskazanego drzewa katalogów, więc błąd typu local file inclusion przestaje zwracać zawartość /etc/passwd.
php_admin_value[open_basedir] = /var/www/example.com/
php_admin_value[upload_tmp_dir] = /var/www/example.com/tmp
php_admin_value[session.save_path] = /var/www/example.com/tmp
php_admin_value[sys_temp_dir] = /var/www/example.com/tmp
; Pusta wartość całkowicie wyłącza pliki .user.ini
php_admin_value[user_ini.filename] =
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,dl
php_admin_flag[allow_url_include] = off
php_admin_flag[allow_url_fopen] = off
php_admin_value i php_admin_flag różnią się od php_value i php_flag tym, że aplikacja nie zmieni ich w locie przez ini_set(). To sedno całej konstrukcji - przejęty skrypt nie zdejmie z siebie własnych ograniczeń.
Wyłączenie user_ini.filename zamyka niepozorną lukę: bez tego ktoś, kto potrafi zapisać plik w katalogu z uploadem, wrzuca tam .user.ini i zmienia ustawienia PHP dla całego katalogu. Pełną listę dyrektyw dostępnych per pool znajdziesz w dokumentacji konfiguracji PHP-FPM.
allow_url_fopen = off to ustawienie, które najczęściej psuje istniejącą aplikację, i jest wyłączone celowo. Blokuje file_get_contents('https://…'), czyli klasyczne wejście dla ataków SSRF. Aplikacje potrzebujące zapytań HTTP na zewnątrz powinny używać cURL-a.
Routing bez pliku .htaccess
Każdy framework z kontrolerem wejścia potrzebuje tego samego: żądania niewskazujące na istniejący plik mają trafić do index.php. Standardowa odpowiedź to .htaccess z trzema liniami RewriteCond. Apache ma jednolinijkowy odpowiednik:
<Directory "/var/www/example.com/public_html/public">
Options -Indexes +SymLinksIfOwnerMatch
AllowOverride None
FallbackResource /index.php
Require all granted
</Directory>
AllowOverride None oznacza, że Apache w ogóle nie szuka plików .htaccess, co usuwa sprawdzanie systemu plików przy każdym żądaniu i jedną drogę dla kogoś, kto uzyska prawo zapisu w katalogu. Routingiem zajmuje się FallbackResource, a oryginalny adres nadal jest dostępny w aplikacji przez $_SERVER['REQUEST_URI'].
Jedno ograniczenie, wprost opisane w dokumentacji mod_dir: FallbackResource działa tylko wtedy, gdy żaden inny handler nie został przypisany do żądania. Dlatego handler FastCGI musi siedzieć w bloku <FilesMatch> ograniczonym do plików .php, a nie w bloku <Directory>:
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/example.com.sock|fcgi://localhost"
</FilesMatch>
Żądanie /users/42 nie pasuje do żadnego pliku, nie dostaje handlera, trafia na FallbackResource, wskakuje do /index.php i dopiero wtedy idzie do PHP-FPM. Przenieś SetHandler do bloku <Directory>, a handler obejmie każde żądanie - również nieistniejące ścieżki - FallbackResource nigdy nie zadziała, a aplikacja zacznie zwracać 404 na trasach, które istnieją.
Jeśli .htaccess Twojego frameworka robi coś, czego wolisz nie tracić, przestaw pole na allow - Apache przeczyta plik z ograniczonym zakresem nadpisań.
Zamknięcie pułapek na poziomie plików
Trzy wzorce wystarczają, żeby domknąć to, co najczęściej wycieka z wdrożenia PHP:
# Rozszerzenia, które nigdy nie powinny być serwowane ani interpretowane
<FilesMatch "\.(phtml|pht|phps|phar|php[0-9])$">
Require all denied
</FilesMatch>
# Pliki kropkowe, manifesty zależności, zrzuty, kopie, śmieci po edytorach
<FilesMatch "(^\.|^composer\.(json|lock)$|^package(-lock)?\.json$|\.(env|ini|sql|sqlite|db|log|bak|old|swp|dist|inc|sh|md|ya?ml)$)">
Require all denied
</FilesMatch>
# Metadane systemów kontroli wersji
<DirectoryMatch "/\.(git|svn|hg)(/|$)">
Require all denied
</DirectoryMatch>
Wystawiony katalog .git to nie jest problem teoretyczny - automatyczne skanery odpytują /.git/HEAD na każdej nowej domenie, a czytelny .git/config często zawiera adres repozytorium razem z tokenem dostępu. Odsłonięty .env oddaje dane bazy i klucz aplikacji w jednym żądaniu.
Wzorzec ^\. blokuje wszystkie pliki kropkowe po nazwie. Weryfikacja ACME nadal działa, bo FilesMatch dopasowuje nazwę pliku, a nie katalogu, a sam token wyzwania kropką się nie zaczyna.
TLS na Apache'u albo na proxy
Gdy Apache sam obsługuje TLS, VirtualHost na porcie 80 zostawia otwartą jedną ścieżkę na odnowienie certyfikatu i przekierowuje resztę:
<VirtualHost *:80>
ServerName example.com
Alias /.well-known/acme-challenge/ /var/lib/letsencrypt/.well-known/acme-challenge/
<Directory "/var/lib/letsencrypt/">
Require all granted
</Directory>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
RewriteRule ^ https://example.com%{REQUEST_URI} [L,R=301]
</VirtualHost>
certbot certonly --agree-tos --email root@example.com \
--webroot -w /var/lib/letsencrypt/ -d example.com
Za Nginx Proxy Managerem albo innym reverse proxy TLS kończy się wyżej, a Apache nasłuchuje tylko na porcie 80. Zmieniają się dwie rzeczy: nagłówek HSTS pomijasz tutaj, bo należy do tego, co terminuje TLS, oraz konfigurujesz mod_remoteip, żeby w logach lądowały prawdziwe adresy klientów zamiast adresu proxy - opisałem to w osobnym artykule o mod_remoteip.
Na koniec włącz moduły, na których opiera się konfiguracja:
a2enmod headers expires proxy_fcgi setenvif
a2ensite example.com
apache2ctl configtest && systemctl reload apache2
Częste błędy i pułapki
403 na każdym żądaniu i AH01276 w logu. Pełny komunikat brzmi AH01276: No matching DirectoryIndex (index.php,index.html) found, and server-generated directory index forbidden by Options directive. Przy układzie public prawie zawsze znaczy to, że DocumentRoot wskazuje na katalog, którego jeszcze nie ma albo jest pusty - Apache został skonfigurowany przed wgraniem kodu. Utwórz katalog; mylące jest to, że configtest przechodzi w obu przypadkach.
AH01630: client denied by server configuration. Albo w bloku <Directory> brakuje Require all granted, albo ścieżka w bloku nie zgadza się co do znaku z DocumentRoot. Wystarczy rozjazd na ukośniku na końcu.
Got error 'Primary script unknown' i puste 404. PHP-FPM odebrał żądanie, ale nie znalazł skryptu. Zwykle ścieżka gniazda w SetHandler nie zgadza się z wartością listen w poolu - literówka w nazwie domeny albo plik poola zapisany bez przeładowania php-fpm.
502 lub 503 z AH01079: failed to make connection to backend. Gniazdo nie istnieje albo Apache nie może go otworzyć. Zacznij od ss -xl | grep example.com.sock. Jeśli gniazdo jest, problemem są listen.owner lub listen.mode - Apache działa jako www-data i potrzebuje 0660 z pasującą własnością.
Trasy zwracają 404, ale /index.php/users/42 działa. FallbackResource nie wchodzi do gry. W dziewięciu przypadkach na dziesięć SetHandler wylądował w bloku <Directory> zamiast w <FilesMatch>, więc każde żądanie ma już przypisany handler.
open_basedir restriction in effect po wdrożeniu. Coś spoza zamkniętego drzewa jest odczytywane - biblioteka systemowa z /usr/share/php albo katalog storage podlinkowany symbolicznie gdzie indziej. Dopisz ścieżkę po dwukropku, zamiast usuwać dyrektywę: php_admin_value[open_basedir] = /var/www/example.com/:/usr/share/php/.
Nagłówki bezpieczeństwa znikają bez śladu. Blok <IfModule mod_headers.c> przy wyłączonym module jest po cichu pomijany - bez błędu, bez ostrzeżenia, bez nagłówków. Uruchom a2enmod headers expires i sprawdź przez curl -I.
Zmiany w kodzie nie są widoczne po wdrożeniu. Jeśli włączyłeś OPcache z opcache.validate_timestamps = 0 dla wydajności, każde wdrożenie musi kończyć się systemctl reload php8.3-fpm. Konfigurator zostawia OPcache na ustawieniach dystrybucyjnych właśnie po to, żeby ta pułapka nie zaskoczyła nikogo w niedzielę.
FAQ - najczęściej zadawane pytania
Czy każda strona na serwerze potrzebuje osobnego poola PHP-FPM?
Na serwerze z więcej niż jedną aplikacją tak. Wspólny pool oznacza, że wszystkie serwisy działają jako ten sam użytkownik, więc błąd pozwalający odczytać plik w jednym wystawia dane pozostałych. Osobne poole dają też limity pamięci i logi błędów per strona, co znacząco przyspiesza diagnostykę.
Czym różni się php_admin_value od php_value w pliku poola?
php_admin_value i php_admin_flag są nie do zmiany z poziomu aplikacji przez ini_set(), w przeciwieństwie do php_value i php_flag. Wszystko, co dotyczy bezpieczeństwa - open_basedir, disable_functions, allow_url_fopen - należy do wariantów admin.
Czy tej konfiguracji mogę użyć do Laravela?
Tak. Wybierz układ public, żeby DocumentRoot trafił na katalog public/, oraz obsługę .htaccess ustawioną na none, żeby routingiem zajął się FallbackResource zamiast reguł dostarczonych przez framework. Oba warianty działają; drugi eliminuje odpytywanie systemu plików przy każdym żądaniu.
Dlaczego allow_url_fopen jest wyłączone i co przez to przestanie działać?
Blokuje przekazanie adresu URL do file_get_contents() i fopen(), czyli najczęstszą drogę do ataków SSRF. Przestaną działać biblioteki pobierające dane zdalne bez cURL-a. Poprawnym rozwiązaniem jest przejście na cURL, szybkim - przestawienie flagi na on ze świadomością ryzyka.
Czy mogę zostawić www-data zamiast zakładać osobnego użytkownika?
Możesz - wpisanie www-data w formularzu pomija tworzenie konta. Na serwerze z jedną stroną to sensowny wybór, który oszczędza problemów z uprawnieniami w istniejących skryptach wdrożeniowych. Przy kilku serwisach rezygnujesz jednak z izolacji, dla której cała konfiguracja per pool ma sens.
Czy FallbackResource jest tak samo dobre jak mod_rewrite?
Do klasycznego routingu przez kontroler wejścia tak, i zajmuje jedną linię zamiast pięciu. Nie zrobi natomiast przekierowań warunkowych, wymuszania HTTPS ani normalizacji ukośnika na końcu - do tego nadal potrzebny jest mod_rewrite. Część hostingów współdzielonych wyłącza FallbackResource, ale na własnym VPS-ie to nie problem.
Przed czym właściwie chroni SymLinksIfOwnerMatch?
Pozwala korzystać z dowiązań symbolicznych tylko wtedy, gdy dowiązanie i cel mają tego samego właściciela. Blokuje to sytuację, w której ktoś z prawem zapisu w serwowanym katalogu podlinkowuje plik z zupełnie innego miejsca na dysku. Pliki statyczne serwuje Apache, więc open_basedir ich nie dotyczy - tę lukę zamyka właśnie ta opcja.
Ile ustawić pm.max_children?
Podziel pamięć, którą możesz przeznaczyć na PHP, przez szczytowe zużycie pojedynczego żądania. Przy 256 MB na proces i 2 GB dostępnych osiem jest rozsądnym sufitem. Zbyt wysoka wartość zamienia skok ruchu w zabicie procesów przez mechanizm OOM zamiast w kolejkę.
Czy ta konfiguracja zadziała na Nginksie?
Plik poola PHP-FPM jest identyczny, bo to konfiguracja po stronie PHP. VirtualHost jest specyficzny dla Apache'a i wymagałby przepisania na blok server z fastcgi_pass.
Następne kroki
Wygeneruj pełną konfigurację dla własnej domeny w konfiguratorze Custom PHP Application na vps-web.com - osiem pól, sześć bloków poleceń gotowych do wklejenia w terminal.
Jeśli stawiasz CMS-a, a nie własny kod, reguły utwardzania różnią się na tyle, że warto sięgnąć po dedykowane narzędzie: utwardzanie VirtualHosta pod Joomlę zna katalogi wymagające zapisu i te, w których PHP nigdy nie powinno się wykonać. Do zwykłego VirtualHosta bez warstwy izolacji PHP-FPM zacznij od konfiguracji VirtualHost dla Apache na Ubuntu i Debianie.
Konfiguracje serwerowe pokazuję też na wideo, na żywo na prawdziwych maszynach, na moim kanale YouTube - zasubskrybuj, jeśli wolisz obejrzeć niż przeczytać.