LEMP на Ubuntu 24.04: NGINX, PHP 8.3, MariaDB и SSL
В Ubuntu 24.04 LTS PHP 8.3 приезжает из собственного репозитория дистрибутива. Один этот факт убирает шаг, с которого начинаются почти все старые инструкции, то есть подключение стороннего PHP-репозитория, а вместе с ним и всю сопутствующую головную боль при обновлениях. Ниже полная сборка LEMP на чистой машине 24.04: NGINX, MariaDB, PHP 8.3 через unix-сокет, бесплатный сертификат Let's Encrypt и те детали продления, которые обычно обнаруживаются дней через семьдесят, в воскресенье вечером.
До первой команды apt
Нужен чистый сервер 24.04, root или sudo-аккаунт и DNS, который уже указывает на него. Пропишите A-запись, а AAAA-запись только если действительно собираетесь ею пользоваться, и дождитесь, пока имя начнёт резолвиться извне. Certbot не интересуют ваши намерения, его интересует только то, что видит удостоверяющий центр.
apt update
apt full-upgrade
reboot
Про IPv6 предупреждаю сразу, потому что это самая непонятная ошибка во всём процессе. Если у домена есть AAAA-запись, Let's Encrypt сначала подключается по IPv6, а дальше всё зависит от того, как именно ломается путь по v6. Если на [::]:80 никто не слушает, соединение отклоняется, а отказ таймаутом не считается, поэтому повтора не будет и выпуск падает сразу. Если фаервол молча роняет пакеты IPv6, получается таймаут, и вот этот единственный случай Let's Encrypt повторяет по IPv4, так что первый выпуск может пройти даже на сервере со сломанным IPv6. Повтор распространяется только на этот первый запрос. Как только порт 80 начнёт уводить проверку на HTTPS, а описанная ниже настройка к этому и приходит, редирект снова пойдёт по IPv6 уже без запасного пути, и через пару месяцев продление умрёт на сервере, который никто не трогал. В обоих случаях в тексте ошибки стоит только адрес, к которому ходили, и никогда слово IPv6, поэтому в неё можно смотреть час и ничего не понять. Либо IPv6 работает целиком, либо AAAA-записи нет вообще. Для такого стека вполне хватает небольшого VPS в нашем дата-центре в Риге.
NGINX
apt install -y nginx
systemctl enable --now nginx
nginx -v
Noble ставит ветку 1.24 с привычной раскладкой Debian: /etc/nginx/sites-available, /etc/nginx/sites-enabled, каталог snippets и профиль для ufw. Дефолтный сайт работает как перехватчик на 80 порту и охотно отвечает на имена, которые вы никогда не настраивали, поэтому уберите его, как только появится ваш собственный конфиг.
unlink /etc/nginx/sites-enabled/default
MariaDB
apt install -y mariadb-server
mariadb-secure-installation
В серии 10.11, которую везёт noble, скрипт называется mariadb-secure-installation. Старое имя mysql_secure_installation ещё работает как ссылка совместимости, но исчезнет оно первым. Важнее другое: root в MariaDB аутентифицируется через плагин unix_socket, поэтому sudo mariadb пускает в консоль вообще без пароля. Дополнительный пароль для root чаще всего только усложняет скрипты резервных копий. На вопрос о смене аутентификации root отвечайте нет, на удаление анонимных пользователей, тестовой базы и удалённого root отвечайте да.
sudo mariadb
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'dlinnyy-sluchaynyy-parol';
GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;
Пакет Ubuntu слушает 127.0.0.1: адрес задан в /etc/mysql/mariadb.conf.d/50-server.cnf. Оставьте как есть. Проверьте через ss -lntp | grep 3306, и про порт 3306 в фаерволе можно больше не думать.
PHP 8.3 и расширения, которые нужны живому сайту
apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-xml php8.3-curl php8.3-mbstring php8.3-zip php8.3-gd php8.3-intl php8.3-bcmath
Три замечания к этому списку. Первое: php8.3-cli стоит в этой строке ради наглядности, а не по необходимости. В Debian и Ubuntu пакет php8.3-fpm зависит от php8.3-cli, поэтому apt поставит CLI, даже если вы его не назвали, а /usr/bin/php8.3 и альтернатива php появятся сразу: у Composer, WP-CLI и заданий cron интерпретатор есть с самого начала. Указывать пакет явно всё равно стоит, потому что так не теряется из виду главное: вы работаете с двумя SAPI и настраиваете их по отдельности, о чём и следующий абзац. Второе: пакета php8.3-json не существует, JSON вкомпилирован в ядро начиная с PHP 8.0, и apt на такой запрос просто ругается. Именно эта строка, перенесённая из инструкций эпохи PHP 7, чаще всего ломает всю команду целиком. Третье: mysqli и PDO MySQL даёт пакет php8.3-mysql.
OPcache живёт в отдельном пакете php8.3-opcache и приезжает вместе с перечисленным выше. Проверять его надо там, где он работает: CLI и пул FPM читают разные каталоги конфигурации, /etc/php/8.3/cli/conf.d и /etc/php/8.3/fpm/conf.d, поэтому php -m в консоли ничего не доказывает про FPM. Положите временный файл с phpinfo(), посмотрите его через NGINX и удалите.
systemctl status php8.3-fpm
ls -l /run/php/php8.3-fpm.sock
Когда PPA ondrej действительно оправдан
PHP 8.3 в составе noble получает обновления безопасности весь срок поддержки LTS, поэтому PPA имеет смысл подключать только если приложению нужна более свежая ветка или оно застряло на более старой.
add-apt-repository ppa:ondrej/php
apt update
apt install -y php8.4-fpm php8.4-cli
Отдавайте себе отчёт, что именно поменялось. Имя сервиса становится php8.4-fpm, конфигурация переезжает в /etc/php/8.4, а сокет становится /run/php/php8.4-fpm.sock. Если в то же самое окно работ не поправить fastcgi_pass, каждый PHP-запрос вернёт 502 Bad Gateway. Не оставляйте две версии FPM запущенными и направленными на чужие сокеты. И с этого дня версия PHP на ваших сайтах живёт по графику стороннего мейнтейнера, а не по графику Ubuntu.
Серверный блок, который работает
mkdir -p /var/www/example.com/public
chown -R www-data:www-data /var/www/example.com
Файл /etc/nginx/sites-available/example.com:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx
Файл snippets/fastcgi-php.conf идёт в пакете Ubuntu и уже задаёт SCRIPT_FILENAME, разбирает PATH_INFO и добавляет защиту try_files ... =404, которая не даёт выполнить произвольный файл. Не переписывайте эти строки руками. Обратите внимание на форму правила для точечных путей: вариант без отрицательного просмотра вперёд, location ~ /\. { deny all; }, закрывает и /.well-known/acme-challenge/, а значит ломает и выпуск, и все последующие продления.
Фаервол
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
ufw status verbose
Разрешите SSH до включения ufw, а если sshd слушает нестандартный порт, разрешайте именно его по номеру. Профиль Nginx Full открывает 80 и 443 и приходит из пакета nginx-common. Если NGINX поставлен из репозитория самого проекта, такого профиля нет, тогда открывайте 80,443/tcp вручную.
Сертификат
Оба пути рабочие. Из архива Ubuntu:
apt install -y certbot python3-certbot-nginx
Или из snap, как рекомендует сам проект и где версии свежее:
snap install core
snap refresh core
snap install --classic certbot
ln -s /snap/bin/certbot /usr/bin/certbot
Выберите что-то одно. Оба сразу означают два бинарника, дерущихся за /usr/bin/certbot, и два таймера, продлевающих один и тот же сертификат. Дальше выпуск:
certbot --nginx -d example.com -d www.example.com
Плагин nginx правит ваш серверный блок прямо на месте: добавляет listen 443 ssl, пути к сертификату, общий файл настроек TLS и, если согласиться на предложение, отдельный блок на 80 порту с редиректом на HTTPS.
Один редирект, в одном месте
Либо редирект пишет certbot, либо вы сами, но никогда оба сразу. Два редиректа на пути одного запроса дают либо цикл, либо жалобу браузера на слишком много перенаправлений, и часто это вылезает только за прокси. Написанный руками он должен быть скучным:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Этот блок заменяет блок на 80 порту для тех же имён, а не встаёт рядом с ним. Два блока на 80 порту с одинаковым server_name заставляют NGINX написать в лог предупреждение о конфликтующем имени и молча использовать первый. Используйте $host, а не зашитое имя, и сохраняйте $request_uri, чтобы путь дожил до цели. Редирект на https://example.com/, выбрасывающий путь, оказывается классической причиной падения продления через пару месяцев, потому что адрес ACME-проверки превращается в главную страницу. Если TLS терминируется впереди, редиректьте по заголовку X-Forwarded-Proto, иначе цикл гарантирован.
Что на самом деле делает таймер продления
systemctl list-timers | grep -i certbot
systemctl cat certbot.timer
Пакет из apt ставит certbot.timer, snap ставит snap.certbot.renew.timer. Оба срабатывают дважды в сутки с большой случайной задержкой, чтобы весь интернет не бил по ACME API в одну и ту же минуту. Юнит запускает certbot -q renew, а renew не делает ничего, пока до конца срока сертификата не останется примерно тридцать дней. Журнал, полный no action taken, показывает здоровое состояние, а не сломанный cron. Пакет из apt дополнительно кладёт /etc/cron.d/certbot, который проверяет наличие systemd и завершается, так что дважды продление не идёт.
Продление повторяет то, что записано в /etc/letsencrypt/renewal/example.com.conf, а не то, что осталось в истории вашей консоли: аутентификатор, инсталлятор, путь webroot, точный список имён. Если позже перестраиваете структуру сайта, правьте именно этот файл. И проверьте, кто перезагружает веб-сервер. С инсталлятором nginx certbot перезагружает NGINX сам. С голым webroot и без инсталлятора NGINX не перезагружает никто, и он держит старый сертификат в памяти до следующего рестарта. Именно так идеально продлённый сертификат продолжает показываться в браузере как просроченный. Добавьте в секцию [renewalparams]:
renew_hook = systemctl reload nginx
Как проверить продление и не упереться в лимиты
certbot renew --dry-run
Проверка идёт против тестового центра, поэтому боевые лимиты не тратятся. Прогоняются DNS, сам челлендж и плагин, но в /etc/letsencrypt/live ничего не пишется, поэтому про шаг перезагрузки этот тест не скажет ничего. Чтобы проверить именно запланированный путь, запустите юнит вручную: systemctl start certbot.service или snap.certbot.renew.service в случае snap. Это настоящая попытка продления, которая ничего не делает, если срок ещё не подошёл.
Для нового имени, в котором вы не уверены, сначала выпустите с --test-cert, убедитесь, что цепочка проходит, и только потом выпускайте боевой. Чего делать нельзя, так это гонять certbot --nginx по кругу для одних и тех же имён во время отладки. Одинаковые сертификаты быстро упираются в недельный лимит дубликатов, а неудачные проверки считаются по отдельному часовому лимиту, и в итоге выпуск закрыт на полностью исправно настроенном сервере.
Две ошибки, которые ломают продление потом
Адрес ACME-проверки. Всё, что мешает отдать http://example.com/.well-known/acme-challenge/<token>, ломает продление через недели после того, как вы про сервер забыли. Обычные виновники: то самое правило запрета точечных путей, редирект, теряющий путь, basic auth или список разрешённых IP на весь сайт, а также правило WAF впереди. Проверка следует за редиректами, в том числе на HTTPS, так что сам по себе 301 не проблема. Проверьте руками:
mkdir -p /var/www/example.com/public/.well-known/acme-challenge
echo ok > /var/www/example.com/public/.well-known/acme-challenge/test
curl -IL http://example.com/.well-known/acme-challenge/test
В конце цепочки нужен 200, после чего файл удалите. Если сайт закрыт basic auth, сделайте исключение: location ^~ /.well-known/acme-challenge/ { auth_basic off; allow all; }.
Устаревшая символическая ссылка. Файл /etc/letsencrypt/live/example.com/fullchain.pem служит символической ссылкой внутрь /etc/letsencrypt/archive/, и продление переставляет её на новый файл. Ломают это двумя способами. Копируют pem-файлы в /etc/nginx/ssl и указывают NGINX туда, после чего продление обновляет файлы, которые никто не отдаёт. Или удаляют сертификат и запускают certbot заново, получая вторую линию с именем example.com-0001: она спокойно продлевается, а NGINX продолжает читать брошенный каталог example.com. Сравните обе стороны:
certbot certificates
grep -R ssl_certificate /etc/nginx/sites-enabled/
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates -subject
Правду показывают даты из s_client. Если они расходятся с выводом certbot certificates, значит вы либо отдаёте старый путь, либо просто не перезагрузили NGINX.
Проверки перед тем, как считать работу законченной
nginx -t
systemctl status nginx php8.3-fpm mariadb --no-pager
systemctl is-enabled nginx php8.3-fpm mariadb certbot.timer
ss -lntp
Потом перезагрузите сервер и повторите проверки, потому что стек, живущий до первого обновления ядра, не готов. Оставьте включённым unattended-upgrades для обновлений безопасности и помните, что после обновления расширения PHP-FPM нужен именно restart, а не reload. Если заниматься всем этим самому не хочется, тот же стек за вас держит хостинг WordPress, а наши инженеры доступны круглосуточно через IT-поддержку, когда собранный своими руками сервер начинает капризничать.
Читайте дальше
Готовы начать?
Запустите за минуты или обсудите с инженером, что подходит вашему проекту.