1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 2 из 6

expires, immutable и always

Коротко

Умолчание заменяется политикой: разным типам файлов - разный срок.

  • expires 30d ставит сразу два поля: Expires с абсолютной датой и Cache-Control: max-age=2592000.
  • expires не действует на коды ошибок - на 404 в том же блоке не уйдёт ничего.
  • immutable директива expires не умеет, его пишут через add_header. И комбинировать их не надо: в ответ уедут два поля Cache-Control.
  • immutable ставится только на адреса с хешем в имени. Для logo.png - никогда.
  • Заголовкам безопасности (вроде X-Content-Type-Options) нужен always, а ловушку «свой add_header вытесняет внешние» с 1.29.3 снимает add_header_inherit merge.

Если политика «html не кешируем, ассеты навсегда» уже настроена - листай до «Теперь сам».

Сначала ответь сам

Популярный рецепт для версионированных ассетов, который встречается в половине инструкций:

/etc/nginx/conf.d/shop.confhttp server
location /assets/ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

Что увидит клиент в заголовках ответа?

Инстинктивный ответ - одну строку Cache-Control с immutable. Настоящий - две строки Cache-Control, и в первой никакого immutable нет.

На что это похоже

Кеш-политика - это сроки годности на разных полках склада.

На консервах ставят год: состав не меняется, проверять нечего. На хлебе - день, и его пересматривают каждое утро. А на витрине с описанием ассортимента срока нет вовсе: её читают заново каждый раз, иначе покупатель придёт за товаром, которого уже нет.

index.html - это витрина. Она перечисляет, какие файлы сейчас актуальны. Ассеты с хешем в имени - консервы: содержимое по этому адресу не изменится никогда. Ошибка почти всех - поставить один срок на весь склад.

Механизм: что именно пишет expires

Директива принимает срок, а в ответ кладёт два поля. Проверено на nginx 1.31.5.

/etc/nginx/conf.d/shop.confhttp server
location /kesh/ { expires 30d; }
$  команда
curl -sI localhost/kesh/f.css
↳  выводcurl -sI localhost/kesh/f.css
HTTP/1.1 200 OK
Server: nginx/1.31.5
Date: Thu, 10 Sep 2026 19:53:00 GMT
Content-Type: text/css
Content-Length: 7
Last-Modified: Thu, 10 Sep 2026 19:52:58 GMT
Connection: keep-alive
ETag: "6aa30a9a-7"
Expires: Sat, 10 Oct 2026 19:53:00 GMT
Cache-Control: max-age=2592000
Accept-Ranges: bytes

Expires - абсолютной датой, ради HTTP/1.0, и отсчитывается она от момента запроса: Date 10 сентября, Expires 10 октября. Cache-Control - в секундах, и при конфликте главнее он. Единицы срока: ms, s, m, h, d, w, M (месяц), y (год); expires 1y даёт max-age=31536000 - ровно 365 суток. Обычное значение и три особых:

Запись Что в ответе
expires 30d; Expires через 30 дней от запроса и Cache-Control: max-age=2592000
expires -1; Cache-Control: no-cache, Expires с текущим временем
expires epoch; Cache-Control: no-cache, Expires с 1970 годом
expires off; ничего не добавляется

-1 и epoch дают одинаковый Cache-Control и различаются только датой в Expires - то есть на практике равнозначны.

точка входа: index.html expires -1; адрес один и тот же версионированное: app.4f2c1b.js max-age=31536000, immutable новая сборка - новый адрес без версии в имени: logo.png expires 30d; компромисс Срок ставится по типу содержимого, а не один на весь сайт.
Три решения, и принимают их именно в этом порядке. Средний ярус безопасен только потому, что версия зашита в адрес.

expires не действует на ошибки

Тот же блок, запрос несуществующего файла:

$  команда
curl -sI localhost/kesh/netu.css
↳  выводcurl -sI localhost/kesh/netu.css
HTTP/1.1 404 Not Found
Server: nginx/1.31.5
Date: Thu, 10 Sep 2026 19:53:00 GMT
Content-Type: text/html
Content-Length: 153
Connection: keep-alive

Ни Cache-Control, ни Expires.

expires работает на «безопасных» кодах, как и add_header без always. Для кеш-заголовков это обычно и правильно - кешировать 404 незачем, - но знать стоит: политика, написанная через expires, на ошибках не действует.

Правило

Срок ставят по типу содержимого, а не на весь сайт: точка входа - перепроверять всегда, версионированное - навсегда, остальное - на разумный срок.

Разбор: почему два Cache-Control

Вернёмся к конфигу из начала урока.

$  команда
curl -sI localhost/assets/f.css | grep -E 'Expires|Cache-Control'
↳  выводcurl -sI localhost/assets/f.css | grep -E 'Expires|Cache-Control'
Expires: Fri, 10 Sep 2027 19:53:00 GMT
Cache-Control: max-age=31536000
Cache-Control: public, max-age=31536000, immutable

Первую строку положила expires 1y, вторую - add_header. Они не заменяют друг друга, а складываются: это разные директивы, и каждая делает своё.

Работать это будет - по спецификации получатель объединяет одноимённые поля через запятую, - но читается плохо, а при правке одного из двух значений сайт получит противоречивую пару. Выбирают что-то одно, и для immutable выбор очевиден: expires его не умеет.

/etc/nginx/conf.d/shop.confhttp server
location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
}

try_files $uri =404 здесь ради того же, что в уроке про SPA: пропавший ассет получает честный 404, а не страницу приложения.

immutable только для адресов с версией

immutable - обещание браузеру: «содержимое по этому URL никогда не изменится». Нарушишь - и у пользователя год провисит старый файл, а сделать с этим ты уже ничего не сможешь: запроса не будет, значит и повлиять не на что. Поэтому ставят его только там, где имя файла содержит хеш содержимого: app.4f2c1b.js, style.9a71e0.css. Для logo.png - никогда.

Разбор: где наивное правило подводит

Наивное правило: «поставил add_header Cache-Control в блок - политика готова».

Готова, но заодно ты выключил всё, что было задано выше. Помнишь правило из второго раздела: если на уровне есть свой add_header, заголовки внешних уровней отбрасываются целиком.

/etc/nginx/conf.d/shop.confhttp server
add_header X-Content-Type-Options nosniff always;

location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    # nosniff отсюда исчез
}

X-Content-Type-Options: nosniff запрещает браузеру угадывать тип файла по содержимому - это один из заголовков безопасности.

Годами это лечили дублированием. С версии 1.29.3 есть строка, которая говорит это прямо:

/etc/nginx/conf.d/shop.confhttp server
add_header_inherit merge;

add_header X-Content-Type-Options nosniff always;

location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    # теперь в ответе оба заголовка
}

И вторая половина того же правила: add_header без always не срабатывает на кодах ошибок. Для кеш-заголовков это неважно, для заголовков безопасности - важно: страница 404 останется без nosniff. Отсюда простое разделение - у заголовков безопасности always, у кеш-заголовков не нужен.

Проверь, что версия свежая

add_header_inherit нет в 1.28 и более ранних версиях - конфиг просто не соберётся с unknown directive. Посмотри nginx -v перед тем, как закладываться на неё: в сборках из репозиториев дистрибутивов версия часто отстаёт.

Что ломается без этого

  • Две строки Cache-Control в ответе. Смешаны expires и add_header. Оставь что-то одно.
  • Пользователи сидят на старой версии приложения. index.html закеширован надолго; ему нужен expires -1.
  • У пользователя год висит сломанный файл. immutable поставлен на адрес без хеша в имени.
  • Заголовки безопасности пропали на части адресов. Свой add_header вытеснил внешние; лечится add_header_inherit merge.
  • Заголовки безопасности пропали на страницах ошибок. Забыт always.

Зачем это в работе

Политика кеша - это три решения, и принимают их именно в таком порядке:

/etc/nginx/conf.d/shop.confhttp
server {
    listen 80;
    server_name shop.local;
    root /var/www/shop/dist;
    index index.html;

    add_header_inherit merge;
    add_header X-Content-Type-Options nosniff always;

    # 1. точка входа: всегда перепроверять, иначе люди застрянут на старой версии
    location = /index.html { expires -1; }

    # 2. имена с хешем: версия зашита в адрес, можно на год
    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    # 3. картинки без версии в имени: месяц, компромисс
    location /images/ { expires 30d; }

    location / { try_files $uri /index.html; }
}

Читается как список решений, а не как набор директив. И проверяется он тоже списком - по одному запросу на каждое решение:

$  команда
curl -sI https://сайт/ | grep -i cache-control                    # ждём no-cache
curl -sI https://сайт/assets/app.4f2c1b.js | grep -i cache-control # ждём immutable
curl -sI https://сайт/images/logo.png | grep -i cache-control      # ждём max-age на месяц

Проверено: / получает no-cache - запрос на каталог уходит в index.html и попадает в location = /index.html. Три строки в скрипте выкладки ловят потерянный блок раньше, чем это заметит пользователь, застрявший на прошлой версии.

Теперь сам

1. Почему index.html кешировать нельзя, а app.4f2c1b.js - можно на год?

Потому что имя ассета содержит хеш содержимого: новая сборка - новое имя, новый адрес, старый кеш просто перестаёт использоваться. У index.html адрес один и тот же всегда, и именно он перечисляет, какие ассеты сейчас актуальны. Закешировал витрину - человек ходит по старому списку.

2. В блоке стоит expires 30d, а на 404 из этого блока заголовков кеша нет. Это поломка?

Нет, так и задумано: expires работает на успешных кодах. Кешировать ошибку незачем. Знать об этом стоит только затем, чтобы не искать несуществующую проблему.

3. Сайт за CDN. Что изменится в политике?

Добавится осмысленность у public и private: public разрешает хранить копию и промежуточным узлам, private - только браузеру. Ассеты с хешем - public, персональные ответы - private или вовсе no-store. Ошибка в эту сторону дороже обычной: чужой персональный ответ, попавший в общий кеш, увидит следующий посетитель.

Главное

expires ставит Cache-Control: max-age и Expires, но не работает на кодах ошибок и не умеет immutable - его пишут через add_header, и смешивать их не надо. immutable ставится только на адреса с хешем в имени. Заголовкам безопасности нужен always, а ловушку «свой add_header вытесняет внешние» с 1.29.3 снимает add_header_inherit merge.

Комментарии

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

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.