Отдавайте ассеты с хэшем в имени с заголовком Cache-Control: public, max-age=31536000, immutable, а HTML отдавайте браузерам с no-cache и задавайте для Cloudflare отдельный короткий edge TTL. Сделайте HTML кэшируемым через Cache Rule, оставьте Browser Cache TTL в режиме Respect Existing Headers и очищайте HTML последним шагом каждого деплоя. Тогда браузер при каждом визите перепроверяет небольшой документ, edge отвечает на большинство таких проверок сам, а новый релиз виден сразу после ответа на запрос purge.
Две политики кэша для двух видов файлов
Статическая сборка дает файлы двух видов. Файлы с отпечатком, например /_astro/index.3f9c1a.js, содержат хэш содержимого в имени. Когда содержимое меняется, меняется и URL, поэтому копия старого URL может год лежать в любом кэше. HTML, фиды, sitemap и robots.txt сохраняют постоянные URL, и любая их кэшированная копия может устареть.
Поэтому хэшированные файлы получают максимальный срок жизни и никогда не требуют очистки. Постоянные URL получают браузерную политику с перепроверкой и edge-политику, которую можно сбросить. Основной выигрыш в скорости дает первая группа: повторный посетитель загружает небольшой HTML-документ, часто в виде ответа 304, а все скрипты, стили и шрифты берет из локального кэша. Поэтому кэширование относится к самым дешевым способам улучшить Time to First Byte и Largest Contentful Paint, как объясняет руководство по Core Web Vitals.
Два правила деплоя делают эту схему безопасной. Загружайте новые хэшированные файлы до того, как HTML переключится на новый релиз. Храните хэшированные файлы предыдущего релиза, пока ссылающийся на них HTML может оставаться в каком-либо кэше, потому что вкладка, открытая до деплоя, запросит старые имена.
Browser TTL и edge TTL: два разных таймера
Кэш браузера принадлежит посетителю, и никакие ваши действия после ответа не удалят из него запись. Edge-кэш принадлежит Cloudflare, и purge API очищает его за секунды. Поэтому длинный browser TTL допустим только для URL, содержимое которых никогда не меняется. Для HTML директива no-cache разрешает браузеру сохранить страницу, но требует условного запроса перед каждым повторным использованием.
Edge TTL может задаваться через s-maxage, через Cloudflare-CDN-Cache-Control или CDN-Cache-Control либо через Cache Rule. Раз его можно сбросить, edge TTL для HTML служит страховкой, а не механизмом обновления. Если purge не сработал, максимальная задержка устаревания на edge равна сроку свежести плюс окно stale-while-revalidate: при max-age=300, stale-while-revalidate=60 это 360 секунд.
Сначала проверьте одну настройку зоны. Browser Cache TTL по умолчанию равен четырем часам на всех тарифах, и Cloudflare переопределяет сроки origin, которые меньше этого значения. HTML, который должен перепроверяться, может тогда часами жить в браузерах, куда не дотянется никакой purge. Установите Browser Cache TTL в Respect Existing Headers, как описано в документации по edge и browser TTL.
Как Cloudflare читает s-maxage, stale-while-revalidate и stale-if-error
Браузеры игнорируют s-maxage, поэтому Cache-Control: public, max-age=0, s-maxage=300 выглядит очевидным заголовком для HTML, и Cloudflare действительно берет его как edge TTL. Подвох описан в RFC 9111: s-maxage также несет семантику proxy-revalidate, то есть общий кэш не должен отдавать устаревший ответ без предварительной перепроверки.
На тарифах Free, Pro и Business функция Origin Cache Control включена всегда, и Cloudflare соблюдает это правило. Его документация по ревалидации называет s-maxage, must-revalidate, proxy-revalidate и no-cache директивами, которые отключают выдачу устаревших копий. Рядом с stale-while-revalidate они превращают UPDATING в EXPIRED, и посетитель ждет origin. Эти же директивы заставляют Cloudflare игнорировать stale-if-error. Заголовок вроде max-age=0, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 дает только edge TTL в 300 секунд и ничего больше.
Когда stale-while-revalidate действует, ревалидация асинхронна: первый запрос после истечения срока получает устаревшую копию с cf-cache-status: UPDATING, а Cloudflare обновляет ее в фоне. stale-if-error срабатывает только на ответы 5xx от origin. Always Online отключает обе директивы, а значения TTL должны быть целыми числами.
Чтобы задать браузерам и edge разные политики без s-maxage, используйте адресный заголовок. Cloudflare проверяет сначала Cloudflare-CDN-Cache-Control, затем CDN-Cache-Control, затем Cache-Control. Если CDN-заголовок есть, Cache-Control доходит до браузера без изменений и не влияет на edge, а Cloudflare-CDN-Cache-Control Cloudflare дальше не передает. Для HTML:
Cache-Control: no-cache
Cloudflare-CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400
Браузер перепроверяет страницу каждый раз. Cloudflare хранит ее пять минут, до минуты отдает устаревшую копию во время обновления и сутки отдает последнюю рабочую копию, если origin падает. Правила приоритета описаны в документации по CDN-Cache-Control.
Cache Rules: доверять origin или переопределять
По умолчанию Cloudflare решает, кэшировать ли ответ, по расширению файла и не кэширует HTML и JSON. У URL вида /writing/post/ нет расширения, поэтому без правила каждый запрос страницы получает DYNAMIC и уходит на origin.
У Cache Rule с Eligible for cache есть три режима Edge TTL. respect_origin следует вашим заголовкам и при их отсутствии берет значения по умолчанию, например 120 минут для ответа 200. bypass_by_default следует заголовкам, а без них не кэширует. override_origin игнорирует заголовки и задает TTL принудительно, причем минимум зависит от тарифа: 2 часа на Free, 1 час на Pro и 1 секунда на Business и Enterprise. Browser TTL можно взять из origin, переопределить или отключить.
Если origin отдает продуманные заголовки, доверяйте им. Этот набор правил для фазы http_request_cache_settings делает хост кэшируемым, а затем исключает динамические пути. Для настроек кэша побеждает последнее совпавшее правило, поэтому правило bypass стоит последним.
{
"rules": [
{
"description": "Cache the site according to origin headers",
"expression": "http.host eq \"example.com\"",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"edge_ttl": { "mode": "respect_origin" },
"browser_ttl": { "mode": "respect_origin" }
}
},
{
"description": "Never cache API routes and previews",
"expression": "http.host eq \"example.com\" and (starts_with(http.request.uri.path, \"/api/\") or starts_with(http.request.uri.path, \"/preview/\"))",
"action": "set_cache_settings",
"action_parameters": { "cache": false }
}
]
}
Не добавляйте http.request.method eq "GET" в правило кэширования. Cloudflare предупреждает, что очистка по одному URL может не сработать, если правило совпадает только с GET, потому что запросы purge внутри используют другой метод. Полный список параметров приведен в настройках Cache Rules.
Заголовки Nginx без ловушки наследования add_header
Nginx наследует директивы add_header с верхнего уровня, только если на текущем уровне их нет. Как только location добавляет собственный Cache-Control, все заголовки уровня server в нем молча пропадают, включая заголовки безопасности.
server {
add_header X-Content-Type-Options "nosniff" always;
location ^~ /_astro/ {
# X-Content-Type-Options is no longer sent for this location.
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
Переносимое решение: вынести общие заголовки в сниппет и подключать его через include в каждом блоке с собственным add_header. В Nginx 1.29.3 и новее, включая стабильную ветку 1.30, директива add_header_inherit merge вместо этого дописывает унаследованные заголовки. С merge не держите Cache-Control в блоке server, иначе location отправят его дважды.
Согласно документации модуля headers, add_header без always применяется только к ответам 200, 201, 204, 206, 301, 302, 303, 304, 307 и 308. Используйте always для заголовков безопасности и для no-store на страницах ошибок, но никогда для долгоживущих заголовков кэша, иначе 404 на опечатку в имени ассета станет immutable на год. Не смешивайте также expires с add_header Cache-Control, это дает два поля заголовка. Блок server для статической сборки, без TLS и логирования:
server {
listen 443 ssl;
http2 on;
server_name example.com;
root /srv/example.com/current;
index index.html;
gzip on;
gzip_vary on;
gzip_static on;
gzip_types text/css application/javascript application/json
application/xml application/rss+xml image/svg+xml text/plain;
location ^~ /_astro/ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location ~* \.(?:avif|webp|png|jpe?g|gif|svg|ico|woff2)$ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=86400";
try_files $uri =404;
}
location ~* \.(?:xml|txt)$ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=300";
add_header Cloudflare-CDN-Cache-Control "max-age=3600, stale-if-error=86400";
add_header Cache-Tag "html";
try_files $uri =404;
}
location / {
include snippets/security-headers.conf;
add_header Cache-Control "no-cache";
add_header Cloudflare-CDN-Cache-Control "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
add_header Cache-Tag "html";
try_files $uri $uri/ =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
include snippets/security-headers.conf;
add_header Cache-Control "no-store" always;
}
}
Для gzip_static нужна сборка с --with-http_gzip_static_module, это видно в выводе nginx -V. Origin должен принимать соединения только от Cloudflare, чтобы никто не обходил кэш. Настройку файрвола для этого разбирает руководство по защите Linux VPS.
ETag, Last-Modified и ревалидация
С etag on, который включен по умолчанию, Nginx отдает для статических файлов ETag и Last-Modified и отвечает на условные запросы кодом 304. Его ETag строится из времени изменения и размера файла, а не из хэша содержимого. Поэтому деплой, перезаписывающий все файлы, меняет все ETag, и первая ревалидация после релиза возвращает полный ответ 200. Если релиз раздают несколько серверов origin, время файлов на них должно совпадать, например при копировании через rsync -a, иначе результат ревалидации будет зависеть от того, какой сервер ответил.
Пока копия свежая, Cloudflare сам отвечает на ревалидации браузеров. После истечения срока он отправляет в Nginx условный запрос, и ответ 304 продлевает TTL без передачи тела. Когда Cloudflare меняет кодировку, он превращает сильный ETag в слабый W/"...". Это безвредно, потому что If-None-Match использует слабое сравнение. Включайте Respect Strong ETags, только если клиенту действительно нужны побайтовые валидаторы.
Сжатие: gzip на origin, Brotli и Zstandard на edge
Cloudflare запрашивает у origin accept-encoding: br, gzip и может перекодировать полученный ответ. Посетителям он отдает gzip, Brotli или Zstandard в зависимости от Accept-Encoding, тарифа и Compression Rules. По умолчанию зоны Free предпочитают Zstandard, Pro и Business выбирают Brotli, а Enterprise использует gzip. Сжимаются только ответы 200, 403 и 404, как указано в документации по сжатию.
Для origin за Cloudflare достаточно gzip. Сжимайте текстовые файлы заранее при сборке для gzip_static или включите сжатие на лету через gzip on. По умолчанию gzip_types содержит только text/html, поэтому перечислите остальные текстовые типы и оставьте gzip_vary on. Brotli и Zstandard не входят в набор модулей nginx.org и требуют сторонних модулей. Не ставьте Cache-Control: no-transform на текстовые ответы, потому что тогда Cloudflare не сожмет несжатый ответ.
Cookies, query string и ключ кэша
Ключ кэша Cloudflare по умолчанию содержит схему, хост, путь и полную query string, а также несколько заголовков запроса, например Origin. Cookies в него не входят, поэтому кэшируемый URL никогда не должен зависеть от cookie. Страницы для вошедших пользователей, корзины, API или бэкенд агента выводите через правило bypass с private или no-store либо на отдельный хост. Как обычно выглядит такой динамический путь запроса, показывает руководство по архитектуре AI-агента для production.
Заголовок ответа Set-Cookie ломает кэширование. При Eligible for cache Cloudflare сохраняет cookie и не кладет ответ в кэш, поэтому каждый запрос получает MISS. Проверьте, что ни модуль, ни балансировщик не добавляют cookies к статическим файлам, или удаляйте их через Cache Response Rule.
Каждая отдельная query string создает отдельную запись, поэтому ссылки с ?utm_source= начинают с промаха. Это снижает долю попаданий, но не корректность. Уровень кэширования Ignore Query String действует только для статических расширений файлов, а собственные ключи по параметрам query string зависят от тарифа. Sort query string доступна на всех тарифах.
Стратегии purge и очистка из CI
С апреля 2025 года все методы purge доступны на любом тарифе:
- Single URL удаляет точные URL, до 100 за запрос (500 на Enterprise), когда сборка знает, что изменилось.
- Prefix удаляет все под путем вроде
example.com/writing/, включая варианты с query string. - Tag удаляет все объекты, в ответе которых был совпадающий заголовок
Cache-Tag. Cloudflare убирает этот заголовок до того, как ответ увидит посетитель. Пометьте HTML, фиды и sitemap тегомhtml, и один вызов очистит все постоянные URL, а хэшированные ассеты останутся в кэше. - Hostname удаляет все для одного хоста.
- Everything очищает всю зону и отправляет весь трафик на origin, пока кэш не заполнится снова, поэтому оставьте его на крайний случай.
Запросы по hostname, tag, prefix и purge everything делят лимит аккаунта: 5 запросов в минуту на Free, 5 в секунду на Pro, 10 в секунду на Business и 50 в секунду на Enterprise, согласно документации по purge. Используйте API-токен только с правом Cache Purge на одну зону и храните его как секрет CI:
#!/usr/bin/env bash
set -euo pipefail
: "${CF_API_TOKEN:?}" "${CF_ZONE_ID:?}" "${ORIGIN_HOST:?}"
# 1. Hashed assets first, without --delete, so old pages keep working.
rsync -a dist/_astro/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/_astro/"
# 2. Everything else. Excluded paths are not deleted.
rsync -a --delete --exclude '/_astro/' dist/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/"
# 3. Purge HTML, feeds and sitemaps at the edge.
curl -fsS --max-time 30 -X POST \
"https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"tags":["html"]}' </dev/null \
| jq -e '.success == true' >/dev/null
Большим сайтам лучше загружать сборку в каталог релиза и атомарно переключать symlink. Успешный ответ purge означает только то, что запрос принят, поэтому завершайте задачу загрузкой измененной страницы и проверкой маркера сборки, например хэша коммита.
Проверка через curl -I, cf-cache-status и Age
curl -sSI -H 'Accept-Encoding: zstd, br, gzip' https://example.com/writing/some-post/ \
| grep -iE '^(cache-control|cf-cache-status|age|etag|content-encoding):'
Для кэшируемых запросов Cloudflare превращает HEAD в GET, поэтому curl -I тоже наполняет кэш. Первый ответ должен показать MISS, второй HIT с Age, то есть числом секунд с момента кэширования или ревалидации объекта. Каждый статус определен в документации по ответам кэша:
DYNAMIC: запрос не подходит для кэша, обычно потому что HTML не покрыт правилом или включен Development Mode.BYPASS: запрос подходит, но ответ некэшируемый из-заno-store,private,Set-CookieилиVary: *.UPDATING: отдана устаревшая копия, пока идет фоновая ревалидация.EXPIRED: устаревшая копия заново загружена синхронно. Если вы ждалиUPDATING, ищитеs-maxage,must-revalidateилиno-cache.REVALIDATED: origin подтвердил копию ответом 304, пока запрос ждал.STALE: origin не ответил, и была отдана старая копия.
После purge ожидайте MISS или EXPIRED при Tiered Cache и после purge everything. Чтобы проверить только Nginx, обратитесь к origin с разрешенного хоста командой curl -sSI --resolve example.com:443:203.0.113.10 https://example.com/, затем добавьте -H 'If-None-Match: "<etag>"' и ожидайте 304.
Рекомендуемые заголовки по типам файлов
| Тип файла | Пример | Cache-Control | Политика edge | После деплоя |
|---|---|---|---|---|
| JS, CSS, шрифты, изображения с отпечатком | /_astro/app.3f9c1a.js |
public, max-age=31536000, immutable |
Из origin | Ничего |
| HTML-страницы | /writing/post/ |
no-cache |
max-age=300, stale-while-revalidate=60, stale-if-error=86400 |
Purge тега html |
| Фиды, sitemap, robots.txt | /rss.xml |
public, max-age=300 |
max-age=3600, stale-if-error=86400 |
Purge тега html |
| Изображения и иконки без хэша | /favicon.ico |
public, max-age=86400 |
Из origin | Purge URL или новое имя |
| JSON, меняющийся между деплоями | /data/stats.json |
public, max-age=60 |
Из origin или bypass | Purge URL |
| Страница 404 | любой отсутствующий URL | no-store с always |
Не кэшируется | Ничего |
| API, превью, админка | /api/ |
private, no-store |
Правило bypass | Ничего |
Значения edge с max-age помещаются в Cloudflare-CDN-Cache-Control. Если вы кэшируете ответы 404 на edge, помечайте их тегом html, иначе страница, опубликованная позже, останется скрытой за закэшированным 404.
Чек-лист деплоя
- Имена ассетов содержат хэши содержимого, и ничто с постоянным URL не помечено
immutable. - Каждый блок Nginx с
add_headerподключает заголовки безопасности, аalwaysстоит только на заголовках безопасности и ответах с ошибками. - HTML отдает
no-cacheвместе сCloudflare-CDN-Cache-Control, безs-maxageрядом сstale-while-revalidate. gzip_typesпокрывает текстовые типы, а статические ответы не содержатSet-Cookie.- Browser Cache TTL учитывает существующие заголовки, а правило bypass стоит последним и не ограничено только
GET. - CI загружает ассеты раньше HTML, сохраняет старые хэшированные файлы, очищает тег
htmlи падает, еслиsuccessне равен true. - Страница показывает
MISS, затемHITсAge, затемUPDATINGпосле истечения edge TTL. - Origin принимает трафик только от Cloudflare.