Ты собрал React или Vue, выкатил в продакшен, и начинается. Зашёл на главную - всё работает. Нажал F5 на /dashboard/orders/42 - белый экран и 404 от nginx. Выкатил новый билд - часть пользователей неделю видит старый, ловит ошибки на несовпадающих чанках. А когда поставил перед origin CDN, в логах вместо реальных IP клиентов один и тот же адрес edge-ноды, rate-limit и geo сломались, а origin при этом открыт всему интернету в обход CDN.
Все эти грабли - не про React и не про Cloudflare. Это про то, как nginx отдаёт статику и проставляет кэш-заголовки. Тема узкая, но именно тут собрана половина инцидентов фронтенда. Разберём механику: как nginx раздаёт собранный SPA, почему хешированные ассеты надо кэшировать на год, а index.html нельзя кэшировать вообще, как отдавать предсжатые brotli-файлы, чем заменили server push, и как поставить nginx как статический сайт за CDN так, чтобы origin был защищён, а в логах был настоящий IP клиента.

Механика SPA-роутинга: try_files и почему index.html - точка входа
Собранный SPA - это набор статических файлов: один index.html, кучка JS/CSS-чанков с хешами в имени (main.4f8a1c.js) и медиа. Роутинг внутри приложения делает JS в браузере через History API. Сервер про маршруты ничего не знает: для него /dashboard/orders/42 - это просто путь, под которым на диске нет файла.
Когда юзер переходит по внутренней ссылке - всё хорошо, навигацию рисует JS без обращения к серверу. Проблема ровно одна: первый запрос или F5 на глубоком URL. Браузер честно идёт на сервер за /dashboard/orders/42, файла там нет, nginx отдаёт 404. Лечение - directive try_files: попробовать отдать файл по пути, потом каталог, а если не нашли - вернуть index.html, чтобы приложение само разобрало маршрут.
Код: Выделить всё
location / {
root /var/www/app/dist;
try_files $uri $uri/ /index.html;
}
Краевой случай: не пиши последним аргументом =404 - это вернёт 404 и сломает deep links. И не путай try_files $uri $uri/ /index.html с try_files $uri /index.html =404 - вторая форма с двумя fallback некорректна, у директивы последний аргумент один.
Кэш-заголовки: immutable на год для ассетов, no-store для index.html
Тут кроется главная причина "у юзеров старая версия". Современные сборщики (Vite, webpack, esbuild) дают ассетам имя с хешем содержимого: main.4f8a1c.js. Поменялся хоть байт - меняется хеш, меняется имя файла. Значит старый и новый файл физически разные URL и не могут конфликтовать в кэше. Такой файл можно кэшировать вечно.
Код: Выделить всё
location /assets/ {
root /var/www/app/dist;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
А вот index.html кэшировать нельзя ни в коем случае. Это единственный нехешированный файл, и именно он содержит ссылки на свежие хеши ассетов. Закэшируешь index.html - браузер будет грузить старый HTML, тянуть из него ссылки на чанки, которых после деплоя уже нет на диске, и приложение упадёт с ошибкой загрузки модуля.
Код: Выделить всё
location = /index.html {
root /var/www/app/dist;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires -1;
add_header X-Frame-Options SAMEORIGIN always;
}
Предсжатие из сборки: brotli_static и gzip_static
Сжимать текстовые ассеты на лету директивой gzip - значит жечь CPU на каждый запрос. Для статики это глупо: файлы не меняются, сожми их один раз на этапе сборки и положи рядом готовые .br и .gz. nginx отдаст предсжатую версию без затрат CPU.
Директива gzip_static встроена в nginx (нужен модуль ngx_http_gzip_static_module, собирается с --with-http_gzip_static_module, в стандартных пакетах есть). С brotli сложнее: нативного brotli в ванильном nginx нет, ngx_brotli - это сторонний динамический модуль, его надо подгрузить через load_module. В Angie ситуация мягче: там нативно есть Zstd (zstd_static), но Brotli всё равно сторонний.
Код: Выделить всё
# в начале nginx.conf, если модуль динамический:
# load_module modules/ngx_http_brotli_static_module.so;
location ~* \.(js|css|svg|json)$ {
root /var/www/app/dist;
brotli_static on;
gzip_static on;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
Грабли: не забудь gzip_vary on (или эквивалент), чтобы в ответе был Vary: Accept-Encoding - иначе промежуточный кэш или CDN может отдать brotli-версию клиенту, который brotli не понимает. И следи, чтобы сборка реально клала .br/.gz в dist - без них директивы тихо отдают оригинал, и ты будешь думать, что сжатие работает, а трафик раздут.
Server push мёртв: preload-заголовки и 103 Early Hints
Раньше для ускорения первой загрузки использовали HTTP/2 Server Push: сервер сам пушил клиенту критичные ассеты, не дожидаясь запроса. В 2026 это история. Server push удалён из nginx - директив http2_push и http2_push_preload больше нет, Chrome отключил push ещё в 106-й версии, реальное использование было около 0.04% сессий. Не пиши про push в новых конфигах вообще.
Замена номер один - обычный заголовок Link с rel=preload. Он говорит браузеру: начни тянуть этот ресурс прямо сейчас, не дожидаясь, пока распарсишь HTML.
Код: Выделить всё
location = /index.html {
root /var/www/app/dist;
add_header Cache-Control "no-store";
add_header Link "</assets/main.4f8a1c.js>; rel=preload; as=script" always;
add_header Link "</assets/main.4f8a1c.css>; rel=preload; as=style" always;
}
Nginx как origin за CDN: real_ip, правильный Cache-Control и защита
Теперь главный продакшен-сценарий: nginx + Cloudflare (или любой CDN) перед ним. CDN держит на edge закэшированную статику и принимает основной трафик, а origin - твой nginx - отдаёт контент только когда у CDN кэш-промах. Тут три задачи, и каждую часто заваливают.
Задача 1. Реальный IP клиента (см. урок 11). Когда перед тобой CDN, на TCP-уровне к origin подключается edge-нода. Без модуля realip переменная $remote_addr - это адрес этой edge-ноды, а не клиента. Сломается всё, что завязано на IP: логи, rate-limit, geo, бан по IP. Cloudflare кладёт настоящий IP в заголовок CF-Connecting-IP (и дублирует в X-Forwarded-For). Включаем realip, но - внимание - доверяем заголовку ТОЛЬКО если запрос пришёл с сетей CDN.
Код: Выделить всё
# доверяем только диапазонам Cloudflare (список с их сайта, обновлять!):
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 2400:cb00::/32;
# ... полный актуальный список IPv4+IPv6 диапазонов CF
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
Задача 2. Правильные Cache-Control и Vary для CDN. CDN кэширует по тем же заголовкам, что и браузер. Логика immutable для ассетов и no-store для index.html работает и на edge: хешированные чанки CDN держит на год, а HTML не кэширует или кэширует на секунды. Обязательно отдавай Vary: Accept-Encoding, иначе CDN может закэшировать brotli-версию и отдать её клиенту без поддержки brotli. Если контент зависит от языка или авторизации - добавь это в Vary, но осторожно: широкий Vary убивает hit rate кэша.
Задача 3. Защита origin (deny all, кроме CDN). Если origin открыт всему интернету, атакующий узнает его реальный IP (через старые DNS-записи, утечки, сканеры) и ударит мимо CDN - в обход всей защиты, WAF и rate-limit edge. Закрываем origin на уровне сети файрволом, а в nginx дублируем правилом: пускать только сети CDN.
Код: Выделить всё
location / {
# пускаем только Cloudflare, остальных - в 403
allow 173.245.48.0/20;
allow 103.21.244.0/22;
allow 104.16.0.0/13;
allow 2400:cb00::/32;
# ... полный список CF
deny all;
root /var/www/app/dist;
try_files $uri $uri/ /index.html;
}
Картинки и медиа: range-запросы и immutable
Медиа отдаётся как обычная статика, но с нюансами. Видео и крупные файлы браузер тянет range-запросами (Range: bytes=...) - nginx поддерживает их из коробки для статики, ничего включать не надо, он сам отдаёт 206 Partial Content. Если файлы версионируются (имя с хешем или ?v=) - ставь immutable на год. Если имена стабильные, но содержимое может смениться (avatar.jpg) - короткий max-age плюс ETag, чтобы браузер ревалидировал условным запросом, а не тянул заново.
Код: Выделить всё
location ~* \.(png|jpg|jpeg|webp|avif|mp4|woff2)$ {
root /var/www/app/dist;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
- F5 на deep link даёт 404 - забыл try_files ... /index.html, либо последним аргументом стоит =404.
- Юзеры видят старую версию после деплоя - закэширован index.html. Ему нужен no-store/no-cache, кэшируется только хешированная статика.
- Сломались add_header при добавлении одного нового - add_header не наследуется в location, где есть свой add_header. Дублируй нужные.
- Brotli "включён", а трафик не падает - сборка не кладёт .br рядом с файлами, либо нет Vary: Accept-Encoding.
- За CDN в логах один IP - нет модуля realip или неверный real_ip_header. Лечится set_real_ip_from на сети CDN + real_ip_header CF-Connecting-IP.
- set_real_ip_from 0.0.0.0/0 - дыра: клиент подделает свой IP. Доверяй только сетям CDN.
- Origin открыт всем - бьют мимо CDN. Закрывай allow сети CDN; deny all плюс файрвол.
- Пишешь http2_push - директивы больше нет, конфиг не пройдёт nginx -t. Используй Link rel=preload или 103 Early Hints.
- Шаг 1. Собери любой Vite/CRA проект (npm run build), получи каталог dist с index.html и assets/. Положи его в /var/www/app/dist.
- Шаг 2. Создай server-блок: listen 80; root /var/www/app/dist; добавь location / с try_files $uri $uri/ /index.html.
- Шаг 3. Добавь location /assets/ с expires 1y и Cache-Control public, max-age=31536000, immutable.
- Шаг 4. Добавь location = /index.html с Cache-Control no-store и заголовком Link rel=preload на главный JS-чанк.
- Шаг 5. Сожми ассеты на сборке (find dist -name '*.js' -exec gzip -k {} +, и brotli аналогично), включи gzip_static on и brotli_static on в location ассетов. Проверь curl -H 'Accept-Encoding: br' -I и ищи Content-Encoding: br.
- Шаг 6. Проверь deep link: curl -i http://localhost/some/deep/route - должен прийти 200 и тело index.html, не 404.
- Шаг 7. Сэмулируй CDN: добавь set_real_ip_from 127.0.0.1; real_ip_header CF-Connecting-IP; и пошли curl -H 'CF-Connecting-IP: 8.8.8.8' - убедись по логу, что записался 8.8.8.8.
- Шаг 8. Закрой origin: allow 127.0.0.1; deny all; - запрос с локалхоста проходит, "чужой" получает 403. Проверь nginx -t перед каждым reload.
- Почему хешированный main.4f8a1c.js можно отдавать с immutable max-age=31536000, а index.html - нельзя кэшировать вообще?
- Что делает последний аргумент в try_files $uri $uri/ /index.html и почему ответ будет 200, а не 404?
- Зачем set_real_ip_from обязан перечислять только сети CDN, и что сломается при set_real_ip_from 0.0.0.0/0?
- Чем заменили HTTP/2 Server Push в 2026 и в какой версии nginx появился 103 Early Hints?
Быстрый и надёжный фронтенд за CDN держится на четырёх вещах. try_files ... /index.html чинит deep links SPA. Жёсткое разделение кэша: хешированные ассеты - immutable на год, index.html - no-store, иначе юзеры застрянут на старом билде. Предсжатие brotli_static/gzip_static снимает CPU и трафик, не забудь Vary. И, наконец, контур за CDN: realip с доверием только сетям CDN возвращает настоящий IP клиента (урок 11), а allow сети CDN + deny all закрывают origin от ударов в обход. Server push забудь - есть Link rel=preload и 103 Early Hints. Этот шаблон закрывает 90% задач раздачи статического сайта и SPA в проде.