Когда Nginx работает как reverse proxy перед приложением, proxy_cache может резко снизить нагрузку на backend: меньше PHP, Node.js, Python, Java или Go-запросов, меньше обращений к базе, стабильнее время ответа при пиках. Это особенно заметно на проектах, где backend живёт на отдельном сервере или VDS, а Nginx принимает весь внешний HTTP-трафик.
Но почти всегда рядом с кэшем появляются два заголовка, которые делают схему не такой очевидной: Cookie в запросе клиента и Set-Cookie в ответе backend-сервера.
Главная опасность не в том, что кэш просто не заработает. Это как раз заметить легко: в логах одни MISS и BYPASS, диск пустой, backend продолжает греться. Хуже другое: можно случайно закэшировать персональную страницу, корзину, админку, ответ API с приватными данными или страницу с уникальным Set-Cookie, а затем отдать её другому посетителю.
Безопасная базовая стратегия простая: публичное можно кэшировать, всё с признаками сессии — обходить и не сохранять. Исключения делайте только для конкретных URL, где точно понятно поведение backend.
Как Nginx принимает решение о кэшировании
В типовой схеме браузер отправляет запрос в Nginx, Nginx ищет ответ в локальном кэше и, если подходящего объекта нет, идёт на backend через proxy_pass. После ответа backend Nginx решает, можно ли сохранить объект в кэше.
На это влияют метод запроса, код ответа, директивы proxy_cache_valid, заголовки Cache-Control, Expires, Vary, Set-Cookie, а также ваши условия proxy_cache_bypass и proxy_no_cache.
Важная деталь: сам факт наличия заголовка Cookie в запросе не означает, что Nginx автоматически запретит кэш. Если пользователь прислал Cookie: PHPSESSID=..., а backend вернул обычный 200 OK без приватных cache-control-заголовков и без Set-Cookie, Nginx может сохранить ответ, если конфигурация разрешает.
С Set-Cookie ситуация другая: по умолчанию Nginx не кэширует ответы, содержащие этот заголовок. Это разумная защита, но на неё не стоит полагаться как на единственный барьер. Backend может выставлять cookie не всегда, а персонализированный HTML иногда возвращается без нового Set-Cookie.
Разница между proxy_cache_bypass и proxy_no_cache
Эти две директивы часто путают, а для Cookie и Set-Cookie это критично. proxy_cache_bypass говорит Nginx: не использовать уже существующий объект из кэша для текущего запроса, сходить на backend. Но сам ответ backend после такого запроса всё ещё может быть сохранён, если не сработали другие запреты.
proxy_no_cache решает вторую часть задачи: не сохранять ответ в кэш. Поэтому для сессионных запросов обычно нужны обе директивы. Пользователь с Cookie должен обойти кэш, получить свежий ответ backend и не положить этот ответ в общий кэш.
Пример плохой логики: добавить только proxy_cache_bypass $http_cookie;. В этом случае посетитель с cookie действительно не получит старый кэшированный объект, но его персонализированный ответ может быть сохранён и потом отдан анонимному посетителю, если backend не поставил защитные заголовки.

Базовый безопасный шаблон для reverse proxy
Ниже конфигурация, от которой удобно отталкиваться. Она кэширует только безопасные методы GET и HEAD, обходит кэш при авторизации, при сессионных cookie, при явном запросе no-cache, а также не сохраняет ответ, если backend вернул Set-Cookie. Блоки map размещаются в контексте http, а server и location — в вашем виртуальном хосте.
proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=app_cache:128m max_size=5g inactive=60m use_temp_path=off;
map $request_method $skip_cache_method {
default 1;
GET 0;
HEAD 0;
}
map $http_authorization $skip_cache_auth {
default 1;
'' 0;
}
map $http_cookie $skip_cache_cookie {
default 0;
~*(PHPSESSID|sessionid|sid=|laravel_session|wordpress_logged_in|wp-postpass|comment_author_|XSRF-TOKEN|csrftoken) 1;
}
map $arg_nocache $skip_cache_arg {
default 0;
~.+ 1;
}
map $http_cache_control $skip_cache_cc {
default 0;
~*no-cache 1;
~*no-store 1;
}
map $upstream_http_set_cookie $skip_store_set_cookie {
default 1;
'' 0;
}
upstream app_backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
server_name example.test;
add_header X-Cache-Status $upstream_cache_status always;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache app_cache;
proxy_cache_key $scheme$request_method$host$request_uri;
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_arg $skip_cache_cc;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_arg $skip_cache_cc $skip_store_set_cookie;
}
}Здесь $skip_store_set_cookie строится на переменной $upstream_http_set_cookie. Если backend вернул Set-Cookie, ответ не сохраняется. При этом запросы с сессионными cookie отсекаются раньше: они не читают общий кэш и не записывают в него результат.
Регулярное выражение в $skip_cache_cookie нужно адаптировать под ваш стек. Для WordPress важны wordpress_logged_in, wp-postpass, иногда cookie комментариев. Для PHP-приложений часто встречается PHPSESSID. Для Laravel — laravel_session и XSRF-TOKEN. Для Django — sessionid и csrftoken.
Почему нельзя просто кэшировать всё с Cookie
Иногда в access log видно, что почти каждый посетитель приходит с каким-то cookie: аналитика, A/B-тесты, согласие с cookies, город, язык интерфейса. Если без разбора делать bypass на любой $http_cookie, hit ratio может стать нулевым. Если, наоборот, игнорировать все cookie, возникает риск смешать разные варианты ответа.
Правильный компромисс — различать технические cookie и cookie, влияющие на тело ответа. Если cookie используется только для клиентской аналитики и backend не меняет HTML, он не должен мешать кэшу. Если cookie меняет валюту, регион, язык, корзину, авторизацию, роль пользователя или feature flags, такой запрос нельзя класть в общий кэш без отдельного ключа или отдельной зоны.
Например, языковая cookie может быть частью кэш-ключа, если вы осознанно хотите хранить отдельные версии страницы:
map $http_cookie $cache_lang {
default ru;
~*lang=en en;
~*lang=ru ru;
}
proxy_cache_key $scheme$request_method$host$cache_lang$request_uri;Но добавлять в proxy_cache_key весь $http_cookie почти всегда плохая идея. Значение cookie часто уникально для пользователя, поэтому кэш превратится в набор одноразовых объектов, быстро заполнит диск и почти не даст HIT.
Что делать с backend, который ставит лишний Set-Cookie
Многие CMS и фреймворки любят ставить Set-Cookie даже на публичных страницах: CSRF-token, тестовая cookie, идентификатор трекинга, пустая сессия. Для Nginx это сигнал не сохранять ответ. На первый взгляд хочется добавить proxy_ignore_headers Set-Cookie и забыть, но это опасный путь.
proxy_ignore_headers Set-Cookie говорит Nginx не учитывать этот заголовок при принятии решения о кэшировании. Однако заголовок может остаться в ответе клиенту и, в зависимости от конфигурации, попасть в закэшированный объект. В худшем случае один посетитель получит cookie, выданную другому.
Если вы точно кэшируете публичный endpoint, где Set-Cookie не нужен, применяйте связку proxy_ignore_headers и proxy_hide_header только в узком location.
location /public-feed/ {
proxy_pass http://app_backend;
proxy_cache app_cache;
proxy_cache_valid 200 5m;
proxy_ignore_headers Set-Cookie;
proxy_hide_header Set-Cookie;
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie;
}Такой блок уместен для публичной ленты, каталога, статичного JSON, страницы документации или другого ответа, который не должен выставлять cookie вообще. Для главной страницы интернет-магазина, личного кабинета, корзины или оформления заказа это не подходит.
Если cookie появляется из-за ошибки приложения, лучше исправить backend: не стартовать сессию на публичных страницах, не генерировать CSRF-cookie там, где нет формы, отделить публичный layout от авторизованного.
Cache lock: защита backend от лавины MISS
Даже безопасный кэш может создать проблему в момент истечения TTL. Представьте популярную страницу, у которой proxy_cache_valid равен 10 минутам. Когда объект протух, сотни клиентов одновременно получают MISS или EXPIRED и идут на backend. Для тяжёлой страницы это выглядит как лавина собственных пользователей.
Директива proxy_cache_lock решает задачу: только один запрос обновляет объект, остальные ждут результат или, при дополнительных настройках, получают устаревшую версию. Это особенно полезно для главных страниц, каталогов, API-справочников и любых дорогих ответов.
proxy_cache_lock on;
proxy_cache_lock_timeout 10s;
proxy_cache_lock_age 20s;proxy_cache_lock_timeout ограничивает ожидание клиентов, которые встали в очередь за обновлением. proxy_cache_lock_age задаёт время, после которого Nginx может разрешить ещё один запрос к backend, если первый завис. Значения подбирают по реальному времени генерации страницы.
Stale cache: отдаём старое, когда backend болеет
stale cache — это возможность отдать устаревший объект, если backend временно недоступен, отвечает ошибкой или обновление уже идёт. Для публичных страниц это часто лучше, чем показывать пользователям 502 или 504. Но stale должен применяться только к тем объектам, которые изначально безопасно попали в кэш.
proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_revalidate on;updating позволяет отдавать старый объект, пока один запрос обновляет кэш. proxy_cache_background_update делает обновление в фоне, а клиент получает stale-версию быстрее. proxy_cache_revalidate включает условную проверку через If-Modified-Since и If-None-Match, если backend поддерживает Last-Modified или ETag.
Не стоит включать stale бездумно для API, где устаревшие данные критичны: баланс, статус платежа, наличие товара на складе в момент оформления, права доступа. Для таких endpoint лучше явно отключить кэш или использовать короткий TTL и строгие правила инвалидации.

Отдельные зоны: публичное, API и админка
Одна из практичных схем — не пытаться сделать один универсальный location / для всего сайта. Разделите URL по смыслу. Публичные страницы получают кэш, админка и личный кабинет — полный bypass, API — отдельные правила.
location /admin/ {
proxy_pass http://app_backend;
proxy_cache off;
proxy_no_cache 1;
add_header Cache-Control no-store always;
}
location /account/ {
proxy_pass http://app_backend;
proxy_cache off;
proxy_no_cache 1;
add_header Cache-Control no-store always;
}
location /api/public/ {
proxy_pass http://app_backend;
proxy_cache app_cache;
proxy_cache_valid 200 30s;
proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_arg $skip_cache_cc;
proxy_no_cache $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_arg $skip_cache_cc $skip_store_set_cookie;
}
location /api/private/ {
proxy_pass http://app_backend;
proxy_cache off;
proxy_no_cache 1;
add_header Cache-Control no-store always;
}Для админки и приватных API лучше не оставлять место для неоднозначности. Даже если backend сам отправляет Cache-Control: private или no-store, дополнительный запрет на уровне reverse proxy снижает риск регрессии после обновления приложения.
Похожий принцип разделения вариантов полезен и при кэшировании изображений: если отдаёте WebP или AVIF через Nginx, посмотрите разбор про WebP, AVIF, map и cache key. А для файлов, которые должны открываться только по временной ссылке, пригодится материал про Nginx secure_link, TTL и кэш.
Заголовки backend: что желательно отдавать приложению
Nginx может многое исправить на прокси-уровне, но самый надёжный источник истины — само приложение. Backend должен явно сообщать, что является публичным, а что приватным. Для публичных страниц подойдут Cache-Control: public, max-age=..., ETag, Last-Modified. Для личных страниц — Cache-Control: private, no-store или как минимум private, no-cache в зависимости от требований.
Если ответ зависит от заголовка Authorization, от cookie с ролью пользователя или от географии, нужно либо не кэшировать его в общем proxy_cache, либо включать соответствующий фактор в ключ. Но включение фактора в ключ безопасно только тогда, когда количество вариантов ограничено и понятно. Роль guest и member — ещё контролируемый вариант. Уникальный токен авторизации — нет.
Отдельно проверьте заголовок Vary. Если backend отправляет Vary: Cookie, это обычно признак того, что ответ может зависеть от cookie. Для общего кэша это красный флаг. Если backend отправляет Vary: Accept-Encoding, это нормально для большинства схем с Nginx.
Проверка конфигурации без сюрпризов
После изменений сначала проверьте синтаксис и перезагрузите Nginx. Команды одинаковы для Debian/Ubuntu, RHEL-based дистрибутивов, Fedora и большинства systemd-систем, если Nginx установлен из пакетов.
sudo nginx -t
sudo systemctl reload nginxЗатем проверьте поведение заголовка X-Cache-Status. Первый анонимный запрос к публичной странице обычно даст MISS, второй — HIT. Запрос с сессионной cookie должен дать BYPASS или не должен стать HIT из общего кэша.
curl -I -H 'Host: example.test' http://127.0.0.1/
curl -I -H 'Host: example.test' http://127.0.0.1/
curl -I -H 'Host: example.test' -H 'Cookie: PHPSESSID=abc' http://127.0.0.1/
curl -I -H 'Host: example.test' -H 'Cache-Control: no-cache' http://127.0.0.1/Если backend отдаёт Set-Cookie, убедитесь, что такой ответ не сохраняется. Для этого удобно временно выбрать URL, который гарантированно выставляет cookie, выполнить запрос дважды и посмотреть, не появляется ли HIT. Если появляется, ищите proxy_ignore_headers Set-Cookie, слишком широкие исключения или ошибку в proxy_no_cache.
Логи для диагностики HIT, MISS и BYPASS
Один заголовок в ответе удобен при ручной проверке, но в production нужны логи. Добавьте отдельный формат, где видны статус кэша, время ответа upstream, имя upstream и признаки запроса. Не обязательно логировать полный Cookie: это и шум, и потенциальные персональные данные.
log_format cache_main '$remote_addr $host $request_method $uri $status cache=$upstream_cache_status upstream=$upstream_addr rt=$request_time urt=$upstream_response_time skip_cookie=$skip_cache_cookie skip_auth=$skip_cache_auth';
access_log /var/log/nginx/access_cache.log cache_main;По такому логу легко увидеть, почему кэш не используется. Если большинство запросов имеют skip_cookie=1, возможно, регулярное выражение слишком широкое. Если много MISS и мало HIT, проверьте TTL, кэш-ключ, query string, заголовки backend и размер зоны. Если много EXPIRED при пиках, добавьте cache lock и stale-обновление.
Типовые ошибки в настройке Cookie и Set-Cookie
Первая ошибка — использовать proxy_cache_bypass $http_cookie без proxy_no_cache. Это может привести к сохранению ответа, полученного по сессионному запросу. Вторая ошибка — ставить bypass на любой cookie и затем удивляться, что кэш не работает: современные сайты почти всегда имеют хотя бы одну техническую cookie.
Третья ошибка — глобально включить proxy_ignore_headers Set-Cookie. Эта директива должна быть исключением для конкретного публичного location, а не настройкой на весь сайт. Четвёртая — забыть про Authorization. API-клиенты часто ходят без cookie, но с bearer-токеном; такие ответы не должны попадать в общий кэш.
Пятая ошибка — кэшировать ответы на POST, PUT, PATCH или DELETE без очень строгого понимания протокола. Для большинства сайтов и API reverse proxy должен кэшировать только GET и HEAD. Шестая — не очищать кэш при деплое или изменении контента.
Практичный чек-лист перед включением в production
- Определите публичные URL, которые можно кэшировать без учёта пользователя.
- Составьте список cookie, влияющих на авторизацию, корзину, роль, регион, язык и персонализацию.
- Подключите эти cookie к
proxy_cache_bypassиproxy_no_cache. - Запретите сохранение ответов с
Set-Cookieчерезproxy_no_cache. - Не используйте
proxy_ignore_headers Set-Cookieглобально. - Для админки, кабинета и приватного API включите
proxy_cache off. - Добавьте
X-Cache-Statusи отдельный access log для диагностики. - Включите
proxy_cache_lockдля популярных страниц. - Разрешите
stale cacheтолько там, где устаревший ответ безопаснее ошибки. - Протестируйте анонимный запрос, запрос с cookie, запрос с
Authorizationи ответ сSet-Cookie.
Итоговая логика
Nginx proxy_cache хорошо работает перед backend, когда вы явно разделяете публичные и сессионные ответы. Cookie в запросе — это не автоматический запрет кэша, поэтому сессионные cookie нужно распознавать самостоятельно. Set-Cookie в ответе по умолчанию мешает сохранению, и это полезная защита, которую не стоит отключать глобально.
proxy_cache_bypass отвечает за чтение из кэша, proxy_no_cache — за запись, а для безопасной схемы с пользователями обычно нужны оба механизма. После этого можно добавлять производительные улучшения: cache lock, чтобы не завалить backend лавиной одинаковых MISS, и stale cache, чтобы публичные страницы оставались доступными при кратковременных сбоях upstream.
Главное правило остаётся тем же: сначала безопасность и предсказуемость, потом агрессивное кэширование. Тогда reverse proxy действительно разгружает приложение, а не превращается в источник трудноуловимых инцидентов.


