Эта инструкция проводит весь путь целиком: на входе — чистая 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 ssh

sshd -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/null

Server 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 nginx

reload вместо 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 = 65s

pm.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 = 5s

FPM запишет стек-трейс каждого запроса, который выполняется дольше пяти секунд, — по нему видно конкретную функцию, где всё встало. Только загляните в /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-status

HTTPS от 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

Чек-лист перед боем

  1. sudo sshd -T | grep passwordauthentication возвращает no.

  2. sudo ufw status verbose показывает только нужные порты, а вход по SSH проверен из второго терминала.

  3. Тестовый index.php с phpinfo() удалён, статусные страницы закрыты по IP.

  4. sudo certbot renew --dry-run --run-deploy-hooks отрабатывает без ошибок.

  5. client_max_body_size, post_max_size и upload_max_filesize согласованы в порядке убывания.

  6. Вы знаете, где лежат оба лога, и хотя бы раз в них заглянули.

Дальше идут резервные копии, мониторинг и автоматический деплой — темы отдельных статей. Но сервер, собранный по этим шагам, уже можно отдавать в работу: он не пускает по паролю, не отдаёт наружу .git, переживает продление сертификата и внятно рассказывает в логах, что именно сломалось.