Раздача статического контента через Nginx: пошаговая настройка в 2026 году | AdminWiki

Раздача статического контента через Nginx: пошаговая настройка в 2026 году

22 сентября 2026 14 мин. чтения
Содержание статьи

Что мы настраиваем и для кого: цели и предпосылки

Чтобы 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 с двумя слэшами.

Проверка конфигурации и перезагрузка без простоя

Порядок применения изменений исключает сюрпризы в проде.

  1. nginx -t проверяет синтаксис и доступность файлов, о которых знает конфиг. Успех выглядит как syntax is ok и test is successful.
  2. nginx -T печатает итоговую конфигурацию со всеми include. Команда выручает, когда непонятно, откуда взялась директива, или когда включён не тот файл из sites-enabled.
  3. 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Комментарий
.csstext/cssБез него страница остаётся без оформления
.jsapplication/javascripttext/javascript тоже допустим в браузерах
.mjsapplication/javascriptМодульные скрипты требуют валидного JS-типа при X-Content-Type-Options: nosniff
.jsonapplication/jsonВлияет на разбор ответа в fetch и на работу манифестов
.svgimage/svg+xmlОткрывается как изображение, а не как файл для скачивания
.avifimage/avifСовременный формат изображений с лучшим сжатием
.webpimage/webpПоддержан всеми актуальными браузерами
.woff2font/woff2Без него браузер не загрузит шрифт и напишет об ошибке MIME-типа
.wasmapplication/wasmВ старых пакетах mime.types этого типа нет
.webmanifestapplication/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 задачу не решают: они открывают запись для всех и превращают каталог статики в точку входа для подмены содержимого.

По умолчанию 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.

Чек-лист проверки и итоговый конфиг

Перед выкаткой в прод пройдите по пунктам.

  1. nginx -t проходит без ошибок и предупреждений.
  2. curl -I по адресу страницы возвращает 200 и Content-Type: text/html.
  3. Для CSS и JS отдаются text/css и application/javascript, а не application/octet-stream.
  4. При заголовке Accept-Encoding: gzip или br в ответе есть Content-Encoding и Vary: Accept-Encoding.
  5. Cache-Control и Expires соответствуют типу файла: год для хешированных ассетов, no-cache для index.html.
  6. В error_log нет Permission denied и записей avc: denied.
  7. 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.

Поделиться:
Сохранить гайд? В закладки браузера