Что мы настраиваем и для кого: цели и предпосылки
Чтобы Nginx отдавал статические файлы, достаточно трёх директив в server-блоке: listen, server_name и root, плюс location с try_files. Качество отдачи определяют детали: MIME-типы, заголовки кеширования, сжатие, права на файлы, контекст SELinux. Конфиг без этих настроек отвечает 200 на HTML, отдаёт CSS как application/octet-stream, а изображение за символической ссылкой закрывает ошибкой 403.
Дальше разобран полный цикл: установка пакета, выбор каталога под статику, базовый server-блок, корректные Content-Type, кеширование через expires и Cache-Control, сжатие gzip и brotli, диагностика 403 и 404. Сценарий один: сервер раздаёт статический сайт или собранный фронтенд напрямую, без бэкенда за reverse proxy.
Материал рассчитан на DevOps-инженеров, системных администраторов и разработчиков, которые сами поддерживают стенд. Команды приведены для root или пользователя с sudo. Номера версий и наличие отдельных модулей зависят от сборки: проверьте их командой nginx -V на своём сервере.
Какие версии Nginx и ОС актуальны в 2026 году
Nginx развивается двумя ветками: чётные номера (1.26, 1.28) получают только исправления и считаются стабильными, нечётные (1.27 и выше) идут как mainline с новыми возможностями. Синтаксис директив server, location, root, expires и gzip не менялся с ранних версий 1.x, поэтому конфигурация из статьи работает и на сборках, которые до сих пор стоят в LTS-дистрибутивах.
В Debian 12/13 и Ubuntu 22.04/24.04 nginx ставится из штатного репозитория. На RHEL 9, AlmaLinux 9 и Rocky 9 пакет доступен в AppStream, но собирает его Red Hat, и версия может отставать от upstream. Если нужны свежие модули, подключают официальный репозиторий nginx.org.
Два модуля требуют внимания. ngx_brotli в стандартные пакеты Debian и Ubuntu не входит: нужен сторонний репозиторий или сборка из исходников. HTTP/3 собирается с флагом --with-http_v3_module, в официальных пакетах он присутствует начиная с ветки 1.25.
Установка Nginx и структура каталогов для статики
Установка сводится к двум командам, различия только в пакетном менеджере.
# Debian 12/13, Ubuntu 22.04/24.04 apt update && apt install -y nginx # RHEL 9, AlmaLinux 9, Rocky 9 dnf install -y nginx # автозапуск и старт systemctl enable --now nginx
Проверка сборки: nginx -v выводит номер версии, nginx -V печатает параметры configure. Наличие модуля brotli видно командой nginx -V 2>&1 | tr ' ' '\n' | grep -i brotli.
Каталоги конфигурации различаются между семействами дистрибутивов. Debian и Ubuntu держат основной файл в /etc/nginx/nginx.conf, виртуальные хосты в /etc/nginx/sites-available/, а включают их символическими ссылками из /etc/nginx/sites-enabled/. В RHEL-семействе каталога sites-available нет: файл хоста кладут в /etc/nginx/conf.d/ с расширением .conf, и nginx.conf подхватывает его через include /etc/nginx/conf.d/*.conf. Готовые варианты компоновки основного файла для статики, HTTPS и прокси собраны в статье пять проверенных конфигураций nginx.conf для DevOps.
Воркеры не работают от root. Пользователь задан директивой user в nginx.conf: www-data в Debian и Ubuntu, nginx в сборках RHEL. От этого имени зависит, кому нужно дать право на чтение файлов статики.
Куда класть файлы: /var/www, /srv или свой путь
Три варианта размещения дают разный объём последующей работы.
- /var/www/<domain>/html - привычный путь, примеры из документации по умолчанию указывают сюда. В RHEL-семействе каталог нужно разметить для SELinux, иначе получите 403.
- /srv/www/<domain> - соответствует стандарту FHS, где /srv отведён под файлы, которые раздают сервисы. Удобен, когда на хосте живёт несколько приложений, и путь намекает на принадлежность.
- /data/www/<domain> или другой кастомный путь - гибок для выделенного диска или примонтированного тома. Требует явной настройки прав и SELinux-контекста.
Практическая рекомендация: в Debian и Ubuntu берите /var/www/<domain>/html, в RHEL-семействе тот же путь, но сразу с командой semanage fcontext из раздела про SELinux. Значение root в конфиге обязано совпадать с реальным каталогом до символа. Расхождение в один сегмент пути даёт 404 на существующем файле.
Базовый server-блок и location для статических файлов
Минимальный рабочий server-блок выглядит так.
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
listen 80 принимает HTTP на всех адресах хоста, server_name сопоставляет запрос с доменом из заголовка Host. Директива root добавляет URI запроса к указанному пути: обращение к /about.html превращается в /var/www/example.com/html/about.html. index задаёт файл, который отдаётся при запросе каталога. try_files проверяет файл, затем каталог со слэшем, а при неудаче возвращает 404 вместо внутренней ошибки.
В Debian и Ubuntu файл сохраняют как /etc/nginx/sites-available/example.com.conf и включают ссылкой: ln -s /etc/nginx/sites-available/example.com.conf /etc/nginx/sites-enabled/. В RHEL-семействе достаточно положить файл в /etc/nginx/conf.d/example.com.conf.
Статику удобно вынести в отдельный location, чтобы задать ей собственные заголовки кеширования и отключить лишнее логирование.
location /static/ {
root /var/www/example.com;
expires 30d;
access_log off;
}
Здесь root указывает родительский каталог, а префикс /static/ остаётся частью пути: /static/app.css ищется как /var/www/example.com/static/app.css.
root vs alias: как не получить 404 на ровном месте
root добавляет URI к пути, alias заменяет совпавшую часть location. Разница видна на одном примере.
location /img/ {
alias /var/www/media/; # /img/logo.png -> /var/www/media/logo.png
}
location /img/ {
root /var/www/media; # /img/logo.png -> /var/www/media/img/logo.png
}
Второй вариант почти всегда даёт 404, потому что Nginx дописывает /img/ к корню. У alias есть требование: завершающий слэш нужен и в location, и в значении директивы. Без него путь склеивается непредсказуемо. Правило выбора простое: путь на диске повторяет структуру URL, значит root; путь отличается, значит alias с двумя слэшами.
Проверка конфигурации и перезагрузка без простоя
Порядок применения изменений исключает сюрпризы в проде.
- nginx -t проверяет синтаксис и доступность файлов, о которых знает конфиг. Успех выглядит как syntax is ok и test is successful.
- nginx -T печатает итоговую конфигурацию со всеми include. Команда выручает, когда непонятно, откуда взялась директива, или когда включён не тот файл из sites-enabled.
- systemctl reload nginx применяет конфиг без разрыва соединений.
reload отправляет master-процессу сигнал SIGHUP: новые воркеры стартуют с обновлённым конфигом, старые дорабатывают текущие запросы и завершаются. Простоя нет. systemctl restart убивает воркеры немедленно и подходит только для замены бинарника или подключения новых модулей.
MIME-типы: почему браузер не применяет CSS и JS
Content-Type Nginx определяет по расширению через директиву types, карта типов подключается из файла mime.types. Если include mime.types отсутствует или расширение не описано, сервер отдаёт application/octet-stream. Браузер в этом случае скачивает файл и отказывается применять стили и скрипты, а в консоли появляется предупреждение о неверном MIME-типе.
Базовые строки живут в блоке http файла nginx.conf.
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
charset utf-8;
...
}
charset utf-8 добавляет кодировку к текстовым типам, и браузер не угадывает её сам. Критичные типы для 2026 года сведены в таблицу.
| Расширение | Content-Type | Комментарий |
|---|---|---|
| .css | text/css | Без него страница остаётся без оформления |
| .js | application/javascript | text/javascript тоже допустим в браузерах |
| .mjs | application/javascript | Модульные скрипты требуют валидного JS-типа при X-Content-Type-Options: nosniff |
| .json | application/json | Влияет на разбор ответа в fetch и на работу манифестов |
| .svg | image/svg+xml | Открывается как изображение, а не как файл для скачивания |
| .avif | image/avif | Современный формат изображений с лучшим сжатием |
| .webp | image/webp | Поддержан всеми актуальными браузерами |
| .woff2 | font/woff2 | Без него браузер не загрузит шрифт и напишет об ошибке MIME-типа |
| .wasm | application/wasm | В старых пакетах mime.types этого типа нет |
| .webmanifest | application/manifest+json | Нужен для PWA-манифестов |
Как добавить нестандартный MIME-тип
Расширения, которых нет в mime.types, описывают дополнительным блоком types в том же контексте http.
include /etc/nginx/mime.types;
types {
application/wasm wasm;
application/manifest+json webmanifest;
}
Порядок важен. Блок types на том же уровне расширяет существующую карту, а блок types внутри server или location заменяет её целиком. Если написать types без include mime.types, стандартные типы исчезнут: HTML уйдёт как octet-stream, и сайт сломается целиком.
Кеширование статики: expires и Cache-Control
Директива expires добавляет заголовок Expires и Cache-Control: max-age с рассчитанным числом секунд. Значения задают в днях, часах или годах.
expires 30d; # Expires + Cache-Control: max-age=2592000 expires 1y; # Expires + Cache-Control: max-age=31536000 expires -1; # Cache-Control: no-cache, браузер каждый раз проверяет свежесть expires off; # заголовки кеширования не добавляются
Флаг immutable добавляют отдельно через add_header. Он сообщает браузеру, что файл не изменится, и отключает перепроверку даже по F5.
location ~* \.(css|js|woff2|png|jpg|svg|avif|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Проверьте результат через curl -I: если в ответе два заголовка Cache-Control, оставьте один способ управления кешем, чтобы поведение было предсказуемым. ETag включён по умолчанию (etag on), директива if_modified_since exact тоже. В связке они дают ответ 304 Not Modified, когда файл не менялся, и браузер берёт копию из своего кеша.
Стратегия кеша для SPA и статических сайтов
Схема, которая не оставляет пользователей на старой версии: HTML проверяется всегда, ассеты с хешем в имени кешируются на год.
location = /index.html {
add_header Cache-Control "no-cache";
}
location ~* \.(css|js|woff2|avif|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Сборщики вроде Vite, Webpack, Hugo или Astro кладут в имя файла хеш содержимого: app.4f8c1a.js. Новый релиз меняет имя, браузер скачивает новый файл и не трогает старый. Директива immutable на файлах без хеша даёт обратный эффект: пользователи не увидят обновлений, пока не очистят кеш вручную.
Готовые блоки location для CSS, JS, изображений и шрифтов вместе с проверкой через curl и DevTools собраны в статье кеширование статических файлов в Nginx: готовые решения. Когда перед Nginx появится проксирующий слой или CDN, отдельного разбора требуют proxy_cache, TTL и инвалидация: они описаны в руководстве полная настройка кеширования Nginx.
Сжатие gzip и brotli для ускорения загрузки
gzip уменьшает текстовые ответы в разы и собран в модуле ngx_http_gzip_module. Рабочий блок для контекста http или server:
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_types
text/plain
text/css
application/javascript
application/json
application/xml
image/svg+xml
application/wasm;
Ключевые параметры стоит разобрать по одному. gzip_vary on добавляет заголовок Vary: Accept-Encoding, без него промежуточный кеш отдаёт сжатый ответ клиенту, который его не понимает. gzip_comp_level 5 даёт баланс между нагрузкой на CPU и размером: уровень 9 экономит ещё несколько процентов и заметно дороже по процессору при высокой частоте запросов. gzip_min_length 256 отключает сжатие мелких файлов, где заголовки съедают выигрыш. gzip_proxied any нужен, когда запросы приходят от прокси или CDN. Тип text/html сжимается всегда, добавлять его в список не требуется.
Уже сжатые форматы в gzip_types не включают: JPEG, PNG, WebP, AVIF, WOFF2 и MP4 повторному сжатию не поддаются, а CPU расходуется.
gzip или brotli: что выбрать в 2026 году
Brotli даёт на 15-25% меньший размер, чем gzip, на текстовых ассетах HTML, CSS, JS, JSON и SVG при сопоставимой нагрузке на процессор. Поддержка есть в Chrome, Firefox, Safari и Edge. Рабочая схема: включить оба алгоритма, brotli для современных клиентов, gzip как запасной вариант.
brotli on;
brotli_comp_level 5;
brotli_static on;
brotli_types
text/plain
text/css
application/javascript
application/json
image/svg+xml
application/wasm;
Модуль ngx_brotli в стандартные пакеты Debian и Ubuntu не входит: его подключают из стороннего репозитория или собирают вместе с Nginx. На RHEL-семействе модуль берут из EPEL или собирают вручную. Если поставить модуль нельзя, gzip с уровнем 5-6 закрывает основную задачу. brotli_comp_level выше 6 на слабом CPU смысла не имеет: выигрыш в размере минимален, а нагрузка растёт. Директива brotli_static on и её аналог gzip_static on отдают заранее сжатые файлы .br и .gz, если они лежат рядом с оригиналом, и снимают нагрузку с процессора.
Как проверить, что сжатие работает
Заголовки ответа показывают, какой алгоритм применился.
curl -I -H 'Accept-Encoding: gzip' https://example.com/static/app.css
curl -I -H 'Accept-Encoding: br' https://example.com/static/app.css
curl --compressed -o /dev/null -w '%{size_download}\n' https://example.com/static/app.css
В ответе ищите Content-Encoding: gzip или Content-Encoding: br и заголовок Vary: Accept-Encoding. Сравните Content-Length с размером файла на диске: разница в 4-8 раз для CSS и JS типична. В браузере откройте DevTools, вкладка Network, колонка Size: в скобках показан объём по сети, рядом исходный размер ресурса. Если Content-Encoding отсутствует, проверьте три вещи: попадает ли тип файла в gzip_types или brotli_types, превышает ли файл gzip_min_length, пришёл ли в запросе нужный Accept-Encoding.
Типичные ошибки: 403, 404, права, симлинки, SELinux
Диагностику начинают с логов. error_log по умолчанию лежит в /var/log/nginx/error.log и содержит точную причину: Permission denied указывает на права, No such file or directory на путь, отдельная запись появляется при запрете SELinux. access_log показывает, какой URI запрашивал клиент и с каким кодом ответил сервер.
tail -n 50 /var/log/nginx/error.log tail -n 50 /var/log/nginx/access.log
Права доступа: chown, chmod и пользователь nginx
Рабочая схема прав: файлы 644, каталоги 755, владелец root:root либо deploy:www-data. Воркеру нужен доступ на чтение файлов и бит x на каждом каталоге в цепочке пути.
chown -R root:www-data /var/www/example.com
chmod -R 755 /var/www/example.com
find /var/www/example.com -type f -exec chmod 644 {} \;
namei -l /var/www/example.com/html/index.html
Команда namei показывает права на каждом уровне пути. Если на любом каталоге у группы или остальных нет бита x, Nginx ответит 403, даже когда сам файл доступен на чтение. Права 777 задачу не решают: они открывают запись для всех и превращают каталог статики в точку входа для подмены содержимого.
Симлинки: disable_symlinks и как их разрешить
По умолчанию Nginx идёт по символическим ссылкам. Директива disable_symlinks on или disable_symlinks if_not_owner запрещает это и возвращает 403. Проверить настройку по всем файлам можно так:
grep -r disable_symlinks /etc/nginx/
Если политика мешает деплою через симлинк current -> releases/20260101, верните поведение по умолчанию директивой disable_symlinks off в блоке server или location. В режиме if_not_owner владелец ссылки и владелец цели должны совпадать с пользователем воркера. Для схемы релизов убедитесь, что www-data имеет бит x на всех каталогах в цепочке и на самом каталоге релиза, иначе получите 403, хотя файл на месте.
SELinux: контекст httpd_sys_content_t и semanage
На RHEL, AlmaLinux и Rocky ошибка 403 встречается при формально верных правах. Причина в SELinux: процесс nginx ограничен набором типов контекста и файлы за пределами стандартных путей читать не может.
getenforce ls -Z /var/www/example.com/html ausearch -m avc -ts recent
getenforce возвращает Enforcing, Permissive или Disabled. Записи avc: denied в аудите подтверждают гипотезу. Для нестандартного пути, например /data/www, назначают контекст и применяют его к файлам:
semanage fcontext -a -t httpd_sys_content_t '/data/www(/.*)?' restorecon -Rv /data/www
Если semanage не найден, установите policycoreutils-python-utils. Для каталогов, куда Nginx должен писать (логи или кеш), нужен тип httpd_sys_rw_content_t. Временное setenforce 0 годится только как проверка гипотезы: оставлять систему в Permissive на проде нельзя.
404 при существующем файле: try_files и index
Файл на диске есть, а сервер отвечает 404. Частые причины: root указывает не на тот каталог, в блоке нет index, путь в location не совпадает с реальным, у alias нет завершающего слэша или регистр букв в запросе отличается от имени файла.
nginx -T | grep -A5 'server_name example.com' curl -I http://127.0.0.1/index.html -H 'Host: example.com'
Первая команда показывает итоговый конфиг с реальным root после всех include. Вторая проверяет отдачу файла напрямую, минуя DNS и балансировщик. Если прямой запрос отдаёт 200, а внешний 404, ищите причину вне Nginx: в прокси, CDN или правилах редиректа.
Что включить в 2026 году: HTTP/3, brotli и современные форматы
Базовая настройка работает, но несколько возможностей стоит включить сразу.
- HTTP/2 на TLS-порту: listen 443 ssl; и http2 on; в блоке server. Отдача статики ускоряется за счёт мультиплексирования запросов.
- HTTP/3 через QUIC: listen 443 quic reuseport; и заголовок Alt-Svc со значением h3=":443"; ma=86400. Требуется Nginx 1.25 или новее, сборка с модулем --with-http_v3_module и открытый UDP-порт 443. Для внутренней статики хватает HTTP/2.
- TLS 1.3 и современные шифры: дают более короткий хендшейк и позволяют браузеру быстрее возвращаться на сайт.
- Brotli вместе с gzip: brotli для клиентов с поддержкой, gzip как запасной вариант.
- Предсжатые файлы .br и .gz рядом с оригиналами, чтобы сервер не тратил процессор на сжатие при каждом запросе.
- Современные MIME-типы: avif, webp, wasm, mjs, webmanifest.
Для страниц, которые подгружают контент в фоне, отдельно настраивают кеширование AJAX и Fetch-запросов и ленивую загрузку изображений: практические примеры собраны в статье динамическая загрузка контента и кеширование в Nginx.
Чек-лист проверки и итоговый конфиг
Перед выкаткой в прод пройдите по пунктам.
- nginx -t проходит без ошибок и предупреждений.
- curl -I по адресу страницы возвращает 200 и Content-Type: text/html.
- Для CSS и JS отдаются text/css и application/javascript, а не application/octet-stream.
- При заголовке Accept-Encoding: gzip или br в ответе есть Content-Encoding и Vary: Accept-Encoding.
- Cache-Control и Expires соответствуют типу файла: год для хешированных ассетов, no-cache для index.html.
- В error_log нет Permission denied и записей avc: denied.
- systemctl reload nginx завершился без ошибок, соединения не разрывались.
Итоговый server-блок, в котором собрано всё перечисленное:
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/html;
index index.html;
charset utf-8;
location = /index.html {
add_header Cache-Control "no-cache";
}
location /static/ {
root /var/www/example.com;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
try_files $uri $uri/ =404;
}
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_types text/plain text/css application/javascript
application/json image/svg+xml application/wasm;
}
Подставьте свой домен и пути, добавьте блок для HTTPS и проверьте результат командой curl -I на реальном адресе. Значения TTL меняйте по частоте релизов: при ежедневных деплоях с хешированными именами файлов годовой кеш ассетов безопасен, а для index.html держите короткий TTL или no-cache.