Установка Nginx на Ubuntu Server занимает меньше минуты, и свежий сервер сразу начинает отдавать страницы. Работает, значит готово? Здесь настройка чаще всего и заканчивается. Конфиг из пакета - компромисс для всех случаев сразу: сжатие включено лишь формально, заголовков кэширования нет, защитных заголовков нет, пометка о сборке торчит в каждом ответе. Пока трафик маленький, разницы не видно, а в первый наплыв посетителей или на аудите безопасности выясняется, что сервер работает вполсилы. Ниже - путь от apt install до конфигурации, которую не стыдно показать на нагрузочном тесте и в отчёте сканера: gzip для текста, кэширование статики, защитные заголовки и проверка каждого шага через curl.

Первые шаги после установки пакета из репозитория Ubuntu

Установка сводится к двум командам:

sudo apt update
sudo apt install nginx
nginx -v
systemctl status nginx

Команда nginx -v покажет версию из репозитория. В Ubuntu Server 26.04 LTS это ветка 1.28, точнее сборка 1.28.3. У проекта есть ветки поновее: стабильная 1.30.x, линия разработки ушла на 1.31.6. Суффикс ubuntu1.11 в номере пакета означает одиннадцатый раунд дистрибутивных правок с перенесёнными исправлениями безопасности: для боевого сайта этого хватает, а гонка за цифрами в номере версии ничего не даёт.

Следом идёт брандмауэр:

sudo ufw allow "Nginx Full"
sudo ufw status

Правило "Nginx Full" открывает порты 80 и 443 сразу, "Nginx HTTP" и "Nginx HTTPS" - по одному порту.

Конфигурация в Ubuntu разложена по полочкам: главный файл /etc/nginx/nginx.conf, общие вставки в /etc/nginx/conf.d/, сайты в /etc/nginx/sites-available/ с символьными ссылками в /etc/nginx/sites-enabled/. Хорошая привычка - держать настройки сайта в отдельном файле, тогда обновление пакета пройдёт без конфликтов с правками.

В дистрибутивном nginx.conf уже включены worker_processes auto, worker_cpu_affinity auto, sendfile on и tcp_nopush on, а секция gzip раскомментирована ровно наполовину. Рядом с пометкой о сборке разработчики дистрибутива оставили комментарий с рекомендацией её выключить. База приличная, но до состояния под трафик её надо дотянуть руками. После каждой правки конфигурации:

sudo nginx -t && sudo systemctl reload nginx

Первая команда проверяет синтаксис и пути, вторая мягко применяет изменения без разрыва соединений.

Число воркеров и лимит открытых файлов подбирают под железо

Архитектура Nginx - это главный процесс и пул воркеров. Воркер - однопоточный процесс, который крутит тысячи соединений в одном цикле событий. Воркеров меньше, чем ядер - часть ядер простаивает, больше - процессы спорят за процессор. Значение auto создаёт по воркеру на каждое логическое ядро, в Ubuntu это уже прописано. Проверить число ядер:

nproc

Второй предел - открытые файлы. Каждое соединение с клиентом - дескриптор, каждый отданный файл - ещё один. При 2048 соединениях на воркер лимит дескрипторов кончается раньше соединений, и сервер отказывает в приёме с ошибкой Too many open files. Фактический предел воркеров виден так:

cat /proc/$(cat /run/nginx.pid)/limits | grep "open files"

Скелет рабочей конфигурации:

user www-data;
worker_processes auto;
worker_rlimit_nofile 65535;

events {
    worker_connections 2048;
}

Арифметика: 8 ядер умножить на 2048 - 16384 одновременных соединения на сервер. Для типичного сайта запас, порталу под нагрузкой поднимают до 8192 и выше. Вместе с лимитом растёт расход памяти на буферы, поэтому числа выше 16384 подкрепляют запасом памяти, а не верой в лучшее.

Gzip сжимает текстовые ответы и экономит больше половины трафика

Тут сюрприз: у Nginx из исходников gzip по умолчанию выключен целиком, а в пакете Ubuntu раскомментирована единственная строчка gzip on. Уровень сжатия остаётся на минимальной единице, список типов остаётся дефолтным: сжимается только HTML. CSS, JavaScript и JSON уезжают клиенту как есть, файлами по сотни килобайт. Канал забивается втрое сильнее, чем мог бы, а мобильные посетители сжигают трафик впустую.

Рабочая секция для текстовых ответов:

gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml application/rss+xml image/svg+xml;

Разбор по строкам. Тип text/html в список включать не нужно: он сжимается всегда, а явное перечисление даёт предупреждение о повторяющемся MIME-типе при проверке конфига. Уровень 5 - равновесие между выигрышем в размере и расходом процессора: девятка сжимает заметно дольше, а выигрывает у пятёрки один-два процента. Значение 256 у gzip_min_length отсекает мелочь: крошечным ответам сжатие экономит копейки. Строчка gzip_vary добавляет заголовок Vary: Accept-Encoding, без него промежуточные кэши рискуют отдать сжатую страницу клиенту без поддержки сжатия. Параметр any у gzip_proxied разрешает сжатие ответов на запросы через промежуточные узлы.

Отдельный запрет: jpg, png, webp и woff2 в gzip_types не добавляют. Форматы уже сжаты своими кодеками, повторное сжатие жжёт процессор впустую.

Проверка одной командой:

curl -s -I -H "Accept-Encoding: gzip" http://localhost/style.css | grep -i content-encoding

В ответе должна появиться строка Content-Encoding: gzip. Если проверять только главную, легко обмануться: HTML сжимается даже на дефолтных настройках, а суть правок - в стилях и скриптах.

Кэширование статических файлов на стороне браузера через expires

Кэш браузера - ближайший к посетителю кэш из всех возможных, и он бесплатный. Без явных заголовков браузер действует по эвристике: иногда месяцами держит устаревший логотип, иногда перекачивает одну и ту же картинку при каждом открытии страницы. Директива expires превращает эвристику в договор:

location ~* \.(jpg|jpeg|png|gif|webp|avif|ico|svg)$ {
    expires 30d;
    add_header Cache-Control "public, immutable";
}

location ~* \.(css|js|woff2|ttf|otf)$ {
    expires 7d;
    add_header Cache-Control "public";
}

location / {
    add_header Cache-Control "no-cache" always;
}

Смысл раскладки: картинки живут в кэше месяц, стили и скрипты - неделю, HTML постоянно сверяется с сервером. Значение no-cache, вопреки названию, не отключает кэш, а заставляет браузер сверять копию с сервером. Пользователь видит свежую страницу сразу после релиза, статику берёт с локального диска.

Пометка immutable сообщает браузеру, что файл по своему адресу никогда не меняется. Она честно работает только с версионными именами вроде app.4f2a9c.css. Если файлы называются просто style.css и правятся при каждом релизе, immutable превращает посетителей в заложников старой вёрстки на неделю вперёд.

Тут притаился подводный камень. Директивы add_header наследуются от верхнего уровня при одном условии: на текущем уровне нет ни одной своей add_header. Как только в location со статикой появляется add_header Cache-Control, все защитные заголовки с уровня server туда перестают попадать. Внешне всё работает, но сканер безопасности покажет странный результат: HTML защищён, а JS - нет. Лечится включением общего файла с заголовками в каждом таком location:

include /etc/nginx/snippets/security-headers.conf;

Security headers закрывают страницу от типовых браузерных угроз

Защитные заголовки - это правила для браузера посетителя, сервер их только объявляет. Strict-Transport-Security запрещает браузеру обращаться к сайту по HTTP в течение срока max-age. X-Content-Type-Options nosniff блокирует подмену типа содержимого. X-Frame-Options SAMEORIGIN не даёт чужим сайтам встраивать страницы в iframe. Referrer-Policy подрезает утечку адресов в реферерах. Permissions-Policy закрывает доступ к камере, микрофону и геолокации, если они сайту не нужны.

Содержимое файла /etc/nginx/snippets/security-headers.conf:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

Параметр always - не украшение: без него заголовки приклеиваются только к успешным ответам и редиректам, а страницы 404 и 500 уходят клиенту без защиты, хотя именно они чаще всего отражают ввод пользователя.

Пометку о сборке прячет директива server_tokens off:

server_tokens off;

В пакете Ubuntu стоит значение build, и комментарий прямо в конфиге рекомендует его выключить. Чем меньше посторонний знает о сборке, тем меньше пищи для подбора брешей.

Про Strict-Transport-Security - отдельное предупреждение. Заголовок работает только на HTTPS, а includeSubDomains обязывает все поддомены иметь рабочий TLS: один поддомен без сертификата станет недоступен части посетителей. Разумный порядок: сначала сертификат и стабильный HTTPS на всех поддоменах, неделя со скромным max-age 300 и лишь затем годовое значение 31536000. Отклеить HSTS из браузеров после раздачи нельзя, останется ждать конца срока.

Сертификат на Ubuntu Server ставится за пару минут:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

Certbot сам найдёт секцию server по имени домена, вплетёт строки про сертификат и повесит продление системным таймером.

Отдельно заслуживает упоминания Content-Security-Policy: это самый мощный заголовок семейства и самый капризный, одна избыточно строгая директива ломает скрипты на полстраницы, поэтому подключают его последним и сначала на копии сайта. Минимальный вариант без внешних скриптов:

add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" always;

Проверка настроенного сервера перед перезагрузкой Nginx

Перезагрузка конфигурации и перезапуск службы - разные операции. Reload отправляет главному процессу сигнал HUP: старые воркеры дообрабатывают текущие запросы, новые стартуют с новой конфигурацией, соединения не рвутся. Сломанный конфиг при reload мастер просто отвергнет и продолжит работать по-старому, но после перезагрузки самой машины служба с битым конфигом не поднимется вовсе. Отсюда правило: сначала проверка, потом применение.

Рабочий цикл проверки умещается в четыре шага:

  1. sudo nginx -t - проверка синтаксиса и путей в конфигурации;
  2. sudo systemctl reload nginx - мягкое применение изменений;
  3. curl -s -I к главной странице сайта с отбором строк про заголовки защиты;
  4. тот же curl к файлу стилей с Accept-Encoding gzip и контролем content-encoding.

Команды третьего и четвёртого шага целиком:

curl -s -I https://example.com/ | grep -i -E "strict-transport|x-content|x-frame|referrer|server"
curl -s -I -H "Accept-Encoding: gzip" https://example.com/style.css | grep -i content-encoding

В первой команде ждём строку nginx без номера версии в поле Server, во второй - Content-Encoding: gzip.

Типичные ошибки на этом участке обидны своей простотой. Expires на HTML без no-cache - пользователи неделями смотрят старую версию после релиза. Годовой HSTS на домене, где у пары поддоменов нет TLS, - часть аудитории теряет доступ к сайту на месяцы. Пропущенный include в location со статикой - сканер хвалит главную и ругается на CSS.

Практическая отдача вечера настройки на живом проекте

Что всё это даёт в числах. Текстовые ответы худеют в три-четыре раза: HTML на пятом уровне сжатия теряет около двух третей веса, CSS и JavaScript - примерно столько же. Канал разгружается, время отрисовки страницы падает, мобильные посетители экономят трафик. Повторные визиты почти не касаются сервера: статику браузер берёт с локального диска, сервер отдаёт только HTML и ответы API. Прирост стойкости к всплескам трафика получается бесплатным - тот же сервер, то же железо, другая дисциплина заголовков.

Сканеры заголовков безопасности после таких правок поднимают оценку с провальной до высокой без единого изменения в коде приложения. Живой сервер отвечает на curl строкой Content-Encoding: gzip, датой кэширования на месяц вперёд для картинок и полным набором защитных заголовков даже на страницах ошибок. Финальная проверка - пройти глазами весь конфиг: каждое значение должно отвечать на вопрос, зачем оно здесь стоит. Разница с состоянием через минуту после установки - один вечер работы без единой лишней покупки железа.