Симптом: «задеплоили, а Nginx отдаёт старое»
Классическая ситуация: вы выкатили новую версию, перезагрузили сервисы «как обычно», но часть пользователей продолжает видеть старые стили, старый JS-бандл или даже старую HTML-страницу. На сервере файлы новые, в логах всё «ок», а снаружи — stale content.
Почти всегда это не одна причина, а сумма нескольких уровней кеширования:
- Nginx
open_file_cache(кеш дескрипторов, метаданных и ошибок по файлам); - кеш Nginx на уровне
proxy_cache/fastcgi_cache; - браузерный кеш, которым управляют
cache-control,etag,last-modified; - CDN/edge-кеш и необходимость purge;
- особенности деплоя без простоя: атомарные релизы, симлинки, graceful reload.
Ниже — практичный разбор: как быстро локализовать источник «залипания» и какие настройки реально лечат проблему, а не маскируют её.
Быстрая диагностика: где именно «залипло»
1) Проверяем, что реально отдаётся по сети
Начните с команды, которая не зависит от кеша браузера: проверьте заголовки и косвенные признаки кеширования.
curl -I https://example.com/assets/app.css
Смотрите на cache-control, etag, last-modified, а также на заголовки CDN (часто это age, иногда x-cache или аналоги). Если age есть и растёт — вероятно, ответ отдаётся с edge. Если age нет, но у вас есть собственные признаки кеша Nginx, значит проблема может быть в proxy_cache/fastcgi_cache.
2) Отличаем «браузер держит» от «сервер отдаёт»
Если curl стабильно показывает новую версию, а в браузере «старьё» — почти всегда виноваты политики кеширования или отсутствие версионирования URL ассетов. В DevTools проверяйте:
- обслуживается ли запрос из memory/disk cache;
- уходит ли условный запрос
If-None-Match/If-Modified-Since; - какой статус: 200 или 304.
3) Временно добавляем «маркер релиза» в ответ
Для инцидентов и разбора «половина нод обновилась, половина нет» удобно на короткое время добавить заголовок версии релиза.
add_header X-Release "2026-01-23.1" always;
Делайте этот заголовок временным и нейтральным: без внутренних деталей. Он помогает быстро понять, какой конфиг и какая нода реально отвечают пользователю.
open_file_cache: почему «файл новый, а Nginx как будто не видит»
Модуль open_file_cache кеширует результаты open()/stat() и ошибки (например, 404) на заданное время. Это полезно под нагрузкой, но при деплое может проявляться как «сервер видит старое».
- Nginx продолжает использовать закешированные метаданные (размер/mtime);
- при атомарных релизах через симлинк
currentможет удерживать открытый старый inode; - может кешировать «нет такого файла», и после выкладки файл некоторое время всё ещё считается отсутствующим.
Критичный момент: симлинки и inode
Если деплой — это переключение симлинка на новую директорию релиза, путь остаётся прежним, а inode у целевого файла меняется. В зависимости от параметров open_file_cache Nginx может продолжать обслуживать запросы «по старой реальности».
nginx -s reloadперечитывает конфиг и корректно делает graceful reload, но сам по себе не обязан мгновенно «обнулить» файловые метаданные, если вы дали им жить слишком долго.
Рецепт 1: делаем open_file_cache дружелюбным к деплою
Если open_file_cache вам нужен, держите окно потенциального устаревания коротким и включайте регулярную проверку.
open_file_cache max=10000 inactive=10s;
open_file_cache_valid 5s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
Смысл: часто используемые файлы кешируются, но метаданные перепроверяются каждые 5 секунд. Во многих проектах это убирает эффект «после деплоя минуту кто-то видит старое» без заметной потери производительности.
Рецепт 2: не кешируем «горячие» пути
Если у вас часто меняется HTML (SSR, шаблоны, index.html для SPA), иногда проще отключить или «сократить» влияние файлового кеша точечно, а open_file_cache оставить там, где статика реально неизменяемая (шрифты, картинки).
Проверьте контекст применения директив в вашей версии Nginx: часть параметров можно задавать на уровне http/server, а логику разделения лучше делать по разным server или отдельным путям хранения/выдачи, чтобы деплой HTML не конкурировал с кешированием тяжёлой статики.
Рецепт 3: деплой без перезаписи «по месту»
Самый надёжный вариант: не менять файлы в директории, из которой Nginx прямо сейчас обслуживает трафик. Выкладывайте релиз в новый каталог и переключайте симлинк/маршрутизацию. Тогда вы управляете обновлением через версионирование URL и заголовки, а не через «авось reload всё сбросит».

Cache-Control, ETag, Last-Modified: как сделать браузер союзником
Частая причина «nginx stale content» на деле — браузер. Вы выдали ассету долгий cache-control, но URL не меняете. Браузер действует правильно: «мне разрешили хранить неделю — храню неделю».
Правило №1: неизменяемым ассетам — неизменяемые имена
Если вы собираете фронтенд (Webpack/Vite/Rollup), включайте хеши в именах файлов: app.3f2a1c.css, main.9b77e1.js. Тогда можно безопасно выдавать долгий кеш: новый деплой создаёт новые URL.
- ассеты с хешами:
cache-control: public, max-age=31536000, immutable; - HTML (точки входа):
cache-control: no-cacheили короткийmax-age, чтобы браузер делал revalidate; - не смешивайте подходы: «долгий кеш» только там, где URL гарантированно меняется.
Базовый пример для статики и точки входа
location ^~ /assets/ {
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-cache";
}
Так вы перестаёте воевать с кешем клиента: новая версия — новый URL. Старая может жить сколько угодно, но её никто не запросит.
ETag и Last-Modified: где помогают, а где нет
etag и last-modified экономят трафик через 304, но не спасают, если вы дали файлу большой max-age и не меняете URL. При длинном max-age браузер может вообще не делать запрос на сервер.
- для HTML/API чаще всего правильно:
cache-control: no-cache(кешировать можно, но всегда перепроверять); - для хешированной статики достаточно
immutable, и вы меньше зависите от нюансовetag/last-modified; - если несколько нод и разная ФС: следите за временем (NTP), иначе сравнение по
last-modifiedможет вести себя неожиданно.
Если вы активно оптимизируете отдачу статики и формат изображений, пригодится разбор про маппинг и кеширование альтернативных форматов: WebP/AVIF в Nginx через map и правильный кеш.
Nginx proxy_cache/fastcgi_cache: почему кэш сервера переживает деплой
Если у вас включён proxy_cache или fastcgi_cache, то «устаревший контент» может быть не про файлы. Кеш хранит HTTP-ответы бэкенда и будет отдавать их до истечения TTL или до явной инвалидации.
Проверяем, участвует ли кеш
Добавьте диагностический заголовок (на время или постоянно в ограниченном виде):
add_header X-Cache-Status $upstream_cache_status always;
Типичные значения: HIT, MISS, BYPASS, EXPIRED. Если после деплоя на HTML вы видите HIT там, где ожидаете изменения, причина найдена.
Стратегии, которые реально работают
- Версионирование ключа кеша: добавляете «версию релиза» в
proxy_cache_key. После деплоя ключи становятся новыми автоматически. - Явная инвалидация: точечный purge/очистка по ключам или управляемая замена области кеша.
- Не кешировать то, что меняется и персонализируется: HTML, ответы с cookie/authorization, личные кабинеты — иначе stale станет нормой.
Вариант 1: «версия релиза» в ключе кеша
Если вы меняете версию релиза при деплое (например, через include-файл или шаблон конфигурации), встройте её в ключ.
set $cache_version "2026-01-23.1";
proxy_cache_key "$scheme$request_method$host$request_uri|$cache_version";
После смены $cache_version Nginx начнёт заполнять новый слой кеша без массового purge. Старый слой умрёт по TTL.
Вариант 2: очистка кеша как часть деплоя (осторожно)
Если кеш небольшой и проще «снести и прогреть», делайте это процедурно, понимая риски по нагрузке:
sudo systemctl reload nginx
sudo rm -rf /var/cache/nginx/proxy_cache/*
На нагруженных проектах лучше версионирование ключей или точечная инвалидация, иначе каждый релиз превращается в «холодный старт» для бэкенда.
CDN purge: когда «старое» сидит не у вас
Если перед Nginx стоит CDN, то даже правильные заголовки и чистый Nginx-кеш не гарантируют мгновенное обновление. Edge может продолжать отдавать старое до истечения TTL или до purge.
Типовые причины stale на CDN
- слишком длинный
cache-controlдля HTML; - нет версионирования URL ассетов;
- включён «cache everything» без исключений для HTML/API;
- несколько origin-серверов, часть отвечает старым релизом, а CDN закешировал «неудачный» вариант;
- purge сделан не по тем путям.
Практика: разделяйте правила для HTML и ассетов
Самый безопасный паттерн:
- CDN кеширует только статические ассеты с хешами (долго);
- HTML либо не кешируется на CDN, либо кешируется очень коротко с валидацией;
- точки входа (например,
/,/index.html) имеют минимальный TTL.
Тогда purge требуется редко: деплой меняет ссылки на ассеты, CDN подтянет новые по новым URL.
Если purge всё же нужен: делайте его точечно
Чистите конкретные пути (HTML, manifest, критичные JSON), а не «purge all». Иначе вы сами создаёте холодный старт на каждом релизе.

Deploy without downtime: как деплоить так, чтобы кеши не мешали
«Деплой без простоя» — это не только отсутствие 502 при перезапуске. Это ещё и предсказуемость выдачи контента сразу после релиза.
Опорный набор практик
- Атомарный релиз: выкладка в новый каталог и переключение симлинка.
- Версионирование ассетов: хеши в именах и долгий кеш с
immutable. - HTML всегда перепроверяем:
cache-control: no-cacheили небольшойmax-ageс revalidate. - Управляемая инвалидация: версия в cache key или точечный purge, а не «надеемся на reload».
- Наблюдаемость: заголовок версии релиза,
X-Cache-Status, метрики hit ratio.
Проверочный чек-лист после деплоя
- Проверьте главную HTML: нет ли
cache-control: max-age=...на часы/дни. - Проверьте один CSS/JS с хешем: есть ли
immutableи долгий TTL. - Убедитесь, что HTML ссылается на новые хешированные файлы.
- Если есть CDN: посмотрите
ageи убедитесь, что HTML не застрял на edge. - Если есть Nginx cache: проверьте
$upstream_cache_statusна HTML. - Если есть
open_file_cache: убедитесь, чтоopen_file_cache_validне измеряется минутами.
Типовые анти-паттерны, которые почти гарантируют stale content
- Одинаковый URL для
app.jsпри долгомcache-control. - Кеширование HTML «как статики» на CDN на часы/дни.
open_file_cache_validв десятки секунд/минут при частых релизах и симлинках.- Смешанный парк нод: часть обновилась, часть нет, а балансировщик/CDN кешируют ответы без привязки к версии.
- «Purge all» на каждом деплое вместо версионирования URL или ключей.
Если вам нужно аккуратно управлять TTL и доступом к кешируемым ссылкам (например, для приватной статики), посмотрите материал про TTL в ссылках: Nginx secure_link, TTL и кеширование.
Минимальный рабочий набор настроек (как отправная точка)
Это не «серебряная пуля», а базовый ориентир, который обычно сильно снижает проблемы со stale.
# Статика с хешами
location ^~ /assets/ {
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML: всегда перепроверять
location = / {
expires -1;
add_header Cache-Control "no-cache";
}
location = /index.html {
expires -1;
add_header Cache-Control "no-cache";
}
# open_file_cache: короткое окно устаревания
open_file_cache max=10000 inactive=10s;
open_file_cache_valid 5s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
Вывод
Чтобы Nginx перестал отдавать stale-контент после деплоя, думайте слоями: файловый кеш (open_file_cache), серверный HTTP-кеш (proxy_cache/fastcgi_cache), браузерный кеш через cache-control, валидация через etag/last-modified и edge-кеш CDN с процедурой purge. Самый устойчивый путь — версионировать ассеты и отделять кеширование HTML от кеширования статики: тогда деплой без простоя становится предсказуемым, а кеши начинают работать на вас.
Если проект растёт и хочется изолировать окружения, держать стабильные конфиги Nginx и контролировать кеши на уровне инфраструктуры, удобнее делать это на VDS, где вы управляете всей цепочкой доставки и обновлений.


