Эта инструкция проводит весь путь целиком: на входе — чистая Ubuntu 24.04 LTS, на выходе — сайт на nginx 1.24 и PHP-FPM 8.3, доступный по HTTPS с автопродлением сертификата. По дороге разбираемся с доступом по ключу, файрволом, конфигом server block, размером пула воркеров и OPcache. Пригодится тем, кто впервые арендовал VPS и не хочет собирать конфиг из случайных кусков, найденных в поиске.
Что понадобится
VPS с чистой Ubuntu 24.04 LTS (Noble) и доступом под root или пользователем с sudo.
Домен, A-запись которого уже указывает на IP сервера — без этого не выпустить сертификат.
SSH-клиент на локальной машине и полчаса времени.
Все версии ниже — те, что лежат в стандартном репозитории Noble: nginx 1.24.0 и PHP 8.3. Сторонние PPA не нужны. Домен в примерах — example.com, пользователь — deploy, IP сервера — 203.0.113.10.
Первый вход и учётная запись
Обновляем систему и заводим обычного пользователя: работать под root постоянно не стоит.
sudo apt update && sudo apt upgrade -y
sudo adduser deploy
sudo usermod -aG sudo deployКлючи вместо пароля
Ключ генерируем на локальной машине, а не на сервере.
ssh-keygen -t ed25519 -C 'deploy@example.com'
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@203.0.113.10Убеждаемся, что вход по ключу работает, и только после этого выключаем парольную аутентификацию. Отдельный файл в /etc/ssh/sshd_config.d/ удобнее правки основного конфига: его не тронет обновление пакета.
sudo tee /etc/ssh/sshd_config.d/01-hardening.conf > /dev/null <<'EOF'
PasswordAuthentication no
PermitRootLogin no
KbdInteractiveAuthentication no
EOF
sudo sshd -t
sudo sshd -T | grep -Ei '^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication)'
sudo systemctl reload sshsshd -t проверяет только синтаксис. Эффективные значения, с которыми демон реально запустится, показывает sshd -T — в его выводе все три строки должны быть со значением no.
Номер в имени файла — не украшение. Строка
Include /etc/ssh/sshd_config.d/*.confстоит в самом началеsshd_config, файлы подключаются по алфавиту, а sshd использует первое встреченное значение каждого ключа. На большинстве облачных образов уже лежит50-cloud-init.confсPasswordAuthentication yes. Назовите свой файл99-hardening.conf— и он проиграет cloud-init молча: проверка пройдёт, reload отработает, вход по паролю останется включённым.
Файрвол
ufw — надстройка над iptables (в Ubuntu — через nft-бэкенд), поэтому его правила видны в выводе sudo iptables -S. Сначала описываем правила, потом включаем: обратный порядок отрезает вас от сервера.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw show added
sudo ufw enable
sudo ufw status verboseПрофиль OpenSSH из /etc/ufw/applications.d/openssh-server открывает жёстко 22/tcp. Если соберётесь переезжать на другой порт, правило придётся писать руками: профиль про новый порт ничего не знает.
SSH на нестандартном порту
Смена порта не спасёт от целевой атаки, но уберёт из логов основную массу переборщиков. В Ubuntu 24.04 sshd поднимается через сокет-активацию, поэтому порт задаётся не в sshd_config, а в юните ssh.socket. Пустая строка ListenStream= обнуляет унаследованный список — без неё 22-й останется в дополнение к новому.
sudo mkdir -p /etc/systemd/system/ssh.socket.d
sudo tee /etc/systemd/system/ssh.socket.d/override.conf > /dev/null <<'EOF'
[Socket]
ListenStream=
ListenStream=2222
EOFПравило файрвола для нового порта должно существовать до перезапуска. И перезапускать нужно оба юнита: при Accept=no systemd передаёт слушающий дескриптор запущенному sshd, у процесса остаётся своя копия, и рестарт одного ssh.socket не заберёт старый сокет у работающего демона — 22-й останется открытым, а 2222 не поднимется.
sudo ufw allow 2222/tcp comment 'ssh'
sudo systemctl daemon-reload
sudo systemctl stop ssh.service ssh.socket
sudo systemctl start ssh.socket
sudo ss -lntp | grep sshd # в выводе должен быть 2222, а не 22Текущую сессию не закрывайте. Откройте второй терминал, войдите командой
ssh -p 2222 deploy@203.0.113.10и только после успешного входа уберите старое правило:sudo ufw delete allow OpenSSH. Обратный порядок — самый быстрый способ остаться снаружи от полностью исправного сервера.
Автообновления и fail2ban
Безопасность на минималках: автоматические обновления безопасности и бан за перебор паролей.
sudo apt install -y unattended-upgrades fail2ban
sudo dpkg-reconfigure --priority=low unattended-upgradesДефолтный jail sshd читает /var/log/auth.log, а на минимальных и облачных образах 24.04 без rsyslog этого файла нет — служба падает при старте с Have not found any log file for sshd jail. Поэтому указываем systemd-бэкенд явно, а вместе с ним и новый порт.
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[sshd]
enabled = true
backend = systemd
port = 2222
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshdУстановка nginx и PHP-FPM
nginx
sudo apt install -y nginx
nginx -v
sudo ufw allow 'Nginx Full'
systemctl status nginx --no-pagerПрофиль Nginx Full открывает 80 и 443. Есть и Nginx HTTP только для 80-го, но 443 всё равно понадобится через десять минут, а 80-й — certbot для проверки домена.
PHP 8.3 и модули
sudo apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-xml php8.3-mbstring \
php8.3-curl php8.3-zip php8.3-intl php8.3-gd php8.3-opcache
php -v
systemctl status php8.3-fpm --no-pager
ls -l /run/php/php8.3-fpm.sockПакет php8.3-fpm сразу создаёт пул www и сокет /run/php/php8.3-fpm.sock с владельцем и группой www-data. Набор модулей подбирайте под приложение: php8.3-mysql не нужен, если база — PostgreSQL.
Конфигурация сайта
Каталог и права
sudo mkdir -p /var/www/example.com/public
sudo chown -R deploy:www-data /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 2750 {} \;
sudo find /var/www/example.com -type f -exec chmod 640 {} \;Владелец — пользователь, который выкладывает код; группа — www-data, под которым работают воркеры nginx и FPM. Бит setgid в 2750 сохраняет группу у новых файлов внутри каталога. Права на запись веб-серверу не нужны нигде, кроме каталогов загрузок и кеша.
Кладём временную страницу, чтобы было что проверять:
echo '<?php phpinfo();' | sudo -u deploy tee /var/www/example.com/public/index.php > /dev/nullServer block
Конфиг сайта кладём в /etc/nginx/sites-available/example.com.conf.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php index.html;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
client_max_body_size 34m;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ /\.(?!well-known).* {
deny all;
access_log off;
log_not_found off;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 60s;
fastcgi_buffer_size 32k;
fastcgi_buffers 16 16k;
}
location ~* \.(css|js|jpg|jpeg|png|gif|svg|ico|woff2)$ {
expires 30d;
access_log off;
}
}Сниппет snippets/fastcgi-php.conf идёт в пакете и уже содержит fastcgi_split_path_info, защитный try_files $fastcgi_script_name =404 и include fastcgi.conf — дублировать эти строки не нужно.
Почему порядок локаций важен
nginx проверяет regex-локации в порядке объявления и останавливается на первом совпадении. Именно поэтому блок, закрывающий скрытые файлы, стоит выше остальных. Опустите его в конец — и запрос /.git/hooks/deploy.php попадёт в локацию ~ \.php$ и уедет в FPM, а /.git/config.js уйдёт в блок статики, где будет отдан с expires 30d и выключенным логом, то есть совершенно тихо. Блок deny в конце файла создаёт только ощущение защиты.
Исключение (?!well-known) оставляет доступ к /.well-known/ — через него certbot подтверждает владение доменом. На локации с точным совпадением вроде location = /fpm-status порядок не влияет: их nginx проверяет раньше всех регулярных выражений.
Включение и проверка
sudo ln -s /etc/nginx/sites-available/example.com.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxreload вместо restart: nginx поднимает воркеры с новым конфигом и даёт старым доработать текущие запросы. Открываем http://example.com/ — должна появиться страница phpinfo. Если браузер вместо страницы скачал файл, запрос не дошёл до FPM, смотрите локацию ~ \.php$. После проверки удалите index.php: phpinfo раскрывает пути, версии и настройки всему интернету.
Пул PHP-FPM
Настройки пула лежат в /etc/php/8.3/fpm/pool.d/www.conf. Главный вопрос здесь один: сколько одновременных PHP-запросов сервер готов обслуживать.
Три режима pm
| Режим | Как работает | Когда подходит |
|---|---|---|
static | Держит ровно pm.max_children процессов всё время, память занята постоянно. | Ровная предсказуемая нагрузка и память с запасом; нет расходов на форк. |
dynamic | Стартует с pm.start_servers, форкает новых, пока свободных меньше pm.min_spare_servers, и гасит лишних, когда свободных больше pm.max_spare_servers. Общее число процессов ограничено pm.max_children. | Значение по умолчанию и разумный выбор для большинства сайтов. |
ondemand | Воркеров нет, пока не пришёл запрос; простаивающий процесс завершается через pm.process_idle_timeout. | Много редко посещаемых сайтов на одной машине, когда память дороже задержки на старт процесса. |
Сколько воркеров поместится
Считать нужно от свободной памяти, а не от числа ядер. Пример расчёта для сервера с 4 ГБ: около 1,9 ГБ оставляем системе, nginx и базе, на FPM остаётся примерно 2,1 ГБ. Если средний воркер занимает порядка 70 МБ, получается 2100 / 70 = 30 процессов. Это иллюстрация арифметики, а не измерение: свою цифру снимайте с реальной нагрузки.
ps --no-headers -o rss -C php-fpm8.3 | awk 'NR>1{s+=$1;n++} END{if(n) printf "%.0f MB\n", s/n/1024}'
# NR>1 отбрасывает master-процесс: его RSS заметно ниже, чем у воркеровПравки в www.conf:
pm = dynamic
pm.max_children = 30
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
pm.status_path = /fpm-status
request_terminate_timeout = 65spm.max_requests перезапускает воркер после 500 обработанных запросов — дешёвая страховка от утечек памяти в сторонних библиотеках. request_terminate_timeout держим чуть больше, чем fastcgi_read_timeout в nginx (60 s): тогда первым срабатывает nginx и вы получаете предсказуемый 504, а не 502 с оборванным соединением.
sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpmЛимиты на загрузку файлов
Ярусов здесь три, и верхний — nginx: он режет тело запроса раньше, чем PHP вообще узнает о нём. В /etc/php/8.3/fpm/php.ini:
upload_max_filesize = 32M
post_max_size = 33M
memory_limit = 256MА в server block — client_max_body_size 34m;. Порядок должен быть именно такой: лимит nginx не меньше post_max_size, а тот чуть больше upload_max_filesize, потому что кроме файла в теле едут поля формы и границы multipart. Поставите в nginx ровно 32m — файл на 32 МБ не пролезет никогда, а запас, настроенный в PHP, не пригодится.
Симптомы у двух лимитов PHP разные, и их стоит развести. При превышении post_max_size массивы $_POST и $_FILES приходят пустыми. При превышении только upload_max_filesize массив $_FILES заполнен, но в поле error лежит UPLOAD_ERR_INI_SIZE (1). Обрабатывать нужно оба случая.
Медленные запросы
slowlog = /var/log/php8.3-fpm.log.slow
request_slowlog_timeout = 5sFPM запишет стек-трейс каждого запроса, который выполняется дольше пяти секунд, — по нему видно конкретную функцию, где всё встало. Только загляните в /etc/logrotate.d/php8.3-fpm: пакетное правило покрывает не любой путь, и произвольно названный slowlog никто не ротирует. Под боевым трафиком такой файл растёт без ограничений.
OPcache
Куда класть настройки
Файл /etc/php/8.3/fpm/conf.d/10-opcache.ini править нельзя, и это неочевидно: он симлинк на /etc/php/8.3/mods-available/opcache.ini, общий с CLI. Правки уедут в CLI, а phpenmod или обновление пакета php8.3-opcache их затрут, попутно устроив конфликт конфигов при апгрейде. Убедиться легко: ls -l /etc/php/8.3/fpm/conf.d/10-opcache.ini. Настройки кладём в отдельный настоящий файл с бо́льшим номером — он грузится позже и перекрывает значения.
sudo tee /etc/php/8.3/fpm/conf.d/99-opcache-tuning.ini > /dev/null <<'EOF'
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
EOF
sudo systemctl reload php8.3-fpm
php-fpm8.3 -i | grep -i 'opcache.memory_consumption\|Loaded Configuration'opcache.enable=1 уже стоит в mods-available/opcache.ini, дублировать не нужно. validate_timestamps=0 означает, что PHP перестаёт проверять mtime файлов: после каждой выкладки кода делайте sudo systemctl reload php8.3-fpm — reload шлёт мастеру SIGUSR2, и разделяемая память пересоздаётся вместе с кешем.
Мелочь, которая стабильно вызывает вопросы: max_accelerated_files PHP округляет вверх до ближайшего числа из фиксированного набора простых, так что при 20000 в конфиге статус покажет 32531. На поведение это не влияет, но цифры в конфиге и в выводе не совпадут.
Как посмотреть состояние кеша
В CLI OPcache по умолчанию отключён (opcache.enable_cli=0), поэтому php status.php ничего полезного не покажет. А если его включить, каждый CLI-процесс получит собственный экземпляр кеша, который умрёт вместе с процессом. Состояние пула FPM видно только из запроса, обслуженного самим FPM.
<?php
$status = opcache_get_status(false);
if ($status === false) {
exit('OPcache недоступен в этом SAPI' . PHP_EOL);
}
$memory = $status['memory_usage'];
$stats = $status['opcache_statistics'];
printf(
"Занято: %.1f МБ, свободно: %.1f МБ\n",
$memory['used_memory'] / 1048576,
$memory['free_memory'] / 1048576
);
printf(
"Ключей в кеше: %d из %d, промахов: %d\n",
$stats['num_cached_keys'],
$stats['max_cached_keys'],
$stats['misses']
);Проверка на false обязательна: без неё в SAPI без OPcache скрипт свалится на обращении к $status['memory_usage']. И не оставляйте такой файл в public/ — он раскрывает пути и объём кода всем желающим. Надёжнее отдавать статистику через pm.status_path, закрытый по IP:
location = /fpm-status {
allow 127.0.0.1;
allow ::1;
deny all;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}curl -s -H 'Host: example.com' http://127.0.0.1/fpm-statusHTTPS от Let's Encrypt
Выпуск сертификата
sudo snap install core && sudo snap refresh core
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbot
sudo certbot --nginx -d example.com -d www.example.comВладение доменом подтверждается по HTTP, поэтому 80-й порт должен быть открыт, а домен — уже указывать на этот сервер. Плагин --nginx сам правит server block и предлагает включить редирект с HTTP на HTTPS.
Что появилось в конфиге
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;Плюс отдельный server на 80 с редиректом. Параметр http2 внутри listen — правильная форма для nginx 1.24 из репозитория Noble. Начиная с 1.25.1 он объявлен устаревшим: если поставите nginx 1.26+ из стороннего репозитория, HTTP/2 включается отдельной директивой http2 on; внутри server.
Автопродление и deploy-хук
Сертификат живёт 90 дней, certbot продлевает его, когда до конца остаётся меньше 30. Таймер snap-версии ставится сам, проверить его можно так:
systemctl list-timers 'snap.certbot.*' --no-pagerНо после продления nginx продолжит держать в памяти старый сертификат, пока его не заставят перечитать файлы. За это отвечает deploy-хук:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
/usr/bin/systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run --run-deploy-hooks--dry-run обращается к staging-серверу и не расходует лимиты выпуска. Но deploy-хуки при этом по умолчанию не запускаются: опечатка в скрипте, забытый chmod +x или неверный путь к systemctl останутся незамеченными до реального продления через два месяца, когда nginx будет отдавать просроченный сертификат. Флаг --run-deploy-hooks прогоняет и их тоже.
Диагностика: код ответа как подсказка
Сначала — правильный лог
В server block выше задан персональный error_log, и все ошибки взаимодействия с FPM уходят именно туда. В общем /var/log/nginx/error.log останутся только сообщения уровня main и http — заглянув туда, легко решить, что «в логах пусто». FPM тоже пишет в файл, заданный в php-fpm.conf; в журнале systemd вы найдёте только старт, остановку и отказ юнита, но не 502 и не проблемы с воркерами.
sudo tail -n 50 /var/log/nginx/example.com.error.log
sudo tail -f /var/log/php8.3-fpm.logЧто означает код ответа
502 сразу — nginx не достучался до сокета. В логе сайта будет
connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied)или(2: No such file or directory). Проверьте, что служба жива, и посмотрите права по всему пути:namei -l /run/php/php8.3-fpm.sock.502 ровно через N секунд — воркер убит по
request_terminate_timeout. nginx запишетupstream prematurely closed connection while reading response header from upstream, а в/var/log/php8.3-fpm.logпоявитсяWARNING: child NNNN exited on signal 15 ... after N seconds from start.504 Gateway Time-out — это другой механизм: nginx сам перестал ждать ответа по
fastcgi_read_timeout. Спутаете два случая — будете крутить не тот параметр.413 Request Entity Too Large — тело запроса больше
client_max_body_size. До PHP запрос не доходит вообще.500 и пустая страница — фатальная ошибка PHP. В продакшене
display_errorsвыключен, поэтому смотрите лог сайта и лог FPM.server reached pm.max_children settingв логе FPM — свободных воркеров нет, запросы стоят в очереди. Поднимайте лимит, если есть память, либо разбирайтесь, почему запросы стали долгими.
Обе службы умеют проверить конфиг заранее и перечитать его без разрыва соединений:
sudo nginx -t && sudo systemctl reload nginx
sudo php-fpm8.3 -t && sudo systemctl reload php8.3-fpmЧек-лист перед боем
sudo sshd -T | grep passwordauthenticationвозвращаетno.sudo ufw status verboseпоказывает только нужные порты, а вход по SSH проверен из второго терминала.Тестовый
index.phpсphpinfo()удалён, статусные страницы закрыты по IP.sudo certbot renew --dry-run --run-deploy-hooksотрабатывает без ошибок.client_max_body_size,post_max_sizeиupload_max_filesizeсогласованы в порядке убывания.Вы знаете, где лежат оба лога, и хотя бы раз в них заглянули.
Дальше идут резервные копии, мониторинг и автоматический деплой — темы отдельных статей. Но сервер, собранный по этим шагам, уже можно отдавать в работу: он не пускает по паролю, не отдаёт наружу .git, переживает продление сертификата и внятно рассказывает в логах, что именно сломалось.







