Когда вы мигрируете сайт на новый домен или подключаете CDN, неизбежно всплывают абсолютные ссылки в HTML/CSS/JS. Исправлять их на стороне приложения не всегда быстро или вообще возможно. В таких случаях помогает ngx_http_sub_module — тот самый sub_filter, который меняет содержимое ответа «на лету», уже после получения его от бекенда или чтения с диска.
Что такое sub_filter и чем он отличается от rewrite
sub_filter — это фильтр тела ответа. Он не работает с URI запроса и не перенаправляет клиента. Для редиректов и правки заголовков используйте return, rewrite и proxy_redirect. sub_filter полезен именно для замены текста внутри HTML, CSS, JS, XML и других текстовых типов: переписать хосты, пути к статике, вставить префикс версии, поправить абсолютные ссылки на относительные или наоборот.
Ключевые особенности:
- Работает с заданными типами контента через
sub_filter_types(по умолчанию толькоtext/html). - Строго текстовая замена. Не поддерживает регулярные выражения.
- Чувствительность к регистру (обычная подстрочная замена).
- Выполняется после декомпрессии контента и до финальной компрессии, поэтому важно учесть
gzip/brotliи модульgunzip.
Базовая схема использования
Наиболее частый сценарий — вы выступаете реверс-прокси к приложению и хотите заменить хост статических ссылок или домен страницы.
server {
listen 443 ssl http2;
server_name new.example.com;
# Прокси до приложения
location / {
proxy_pass http://app;
# ВАЖНО: выключаем компрессию от апстрима, чтобы фильтровать тело
proxy_set_header Accept-Encoding "";
# Если апстрим всё равно вернул gzip, декомпрессируем
gunzip on;
gunzip_types text/html text/plain text/css application/javascript application/json application/xml;
# Собственно замена
sub_filter "https://old.example.com" "https://new.example.com";
sub_filter_once off; # заменять все вхождения
# Типы, в которых ищем и меняем (расширяйте осторожно)
sub_filter_types text/html text/css application/javascript application/json application_xml;
# Корректируем метаданные, т.к. тело изменено
sub_filter_last_modified on; # Last-Modified будет актуализирован
proxy_hide_header ETag; # ETag становится невалидным после правки
add_header ETag "" always; # убрать старый ETag, если апстрим его прислал
add_header Accept-Ranges none always; # частичные диапазоны неприменимы к переписанному телу
proxy_buffering on; # фильтрам удобнее с буферизацией
}
}
Мы явно гасим Accept-Encoding, чтобы апстрим вернул текст без gzip/br. Тогда sub_filter сможет прочитать и заменить содержимое. Если апстрим всё же ответит с Content-Encoding: gzip, модуль gunzip распакует тело. Для br (brotli) обратной распаковки в Nginx нет — либо отключайте br на апстриме, либо обнуляйте Accept-Encoding как выше. Часто такой фронт удобно держать на собственном VDS — полный контроль над фильтрами и заголовками.
Заменяя тело ответа, корректируйте валидаторы и диапазоны: sub_filter_last_modified on, скрытие ETag и Accept-Ranges: none — стандартный набор.

Случай 1: Переезд домена без правки приложения
Классика: у вас в шаблонах забит старый абсолютный домен, а быстро править нельзя. Заменим его на новый, включая протокол. Если у вас HSTS и только HTTPS, можно заменить на схему https:// однозначно. Проверьте, что на новом домене установлены актуальные SSL-сертификаты.
location / {
proxy_pass http://app;
proxy_set_header Accept-Encoding "";
gunzip on;
sub_filter "http://old.example.com" "https://new.example.com";
sub_filter "https://old.example.com" "https://new.example.com";
sub_filter_once off;
sub_filter_types text/html application/xml;
sub_filter_last_modified on;
proxy_hide_header ETag;
add_header ETag "" always;
}
Если в проекте используются протокол-независимые ссылки вида //old.example.com, добавьте еще одно правило подмены такой строки. С переменными можно построить универсальную замену, например, подменять $host на $server_name в HTML:
sub_filter "//$host" "//$server_name";
Но будьте аккуратны: переменные берутся из запроса, а не из исходного документа. Если страница построена со ссылками на несколько доменов, применяйте более точечные правила. Если готовите полноценный переезд — заранее оформите регистрацию доменов и продумайте смену NS/TTL.
Случай 2: Подключение CDN для статики
Хотите отдать картинки, CSS и JS через CDN, но у приложения нет поддержки asset-host? Условно меняем в HTML и CSS абсолютные пути на CDN-хост. Для проектной гибкости можно завести карту и переключатель по доменам:
map $http_host $cdn_host {
default "cdn.example.net";
staging.example.net "staging-cdn.example.net";
}
location / {
proxy_pass http://app;
proxy_set_header Accept-Encoding "";
gunzip on;
# Меняем ссылки на статику в HTML/CSS
sub_filter "https://new.example.com/static/" "https://$cdn_host/static/";
sub_filter "//new.example.com/static/" "//$cdn_host/static/";
sub_filter_once off;
sub_filter_types text/html text/css;
sub_filter_last_modified on;
proxy_hide_header ETag;
add_header ETag "" always;
}
Лучше ограничить sub_filter_types только text/html и text/css, не трогать JS/JSON без необходимости — легко повредить строки и сломать бандл. Для изображений и бинарных форматов замена не нужна и опасна.
Типы контента и кодировки
По умолчанию sub_filter обрабатывает только text/html. Частая причина «не работает» — апстрим отвечает application/xhtml+xml или application/javascript, а вы не добавили sub_filter_types. Временно можно включить sub_filter_types * и посмотреть, меняется ли что-то, а затем сузить список до минимально необходимого.
Что касается кодировок: замена — побайтная. Если апстрим отдает text/html; charset=windows-1251, искомая подстрока должна быть ровно в той же кодировке. Универсальный способ — добиваться, чтобы апстрим отдавал UTF-8, либо использовать модуль charset на стороне Nginx для перекодировки тела до момента замены. Но перекодировка — это отдельная нагрузка, учитывайте стоимость.
Gzip, Brotli и цепочка фильтров
sub_filter способен работать только с распакованным телом. Есть три подхода:
- Выключить компрессию на апстриме, прислав пустой
Accept-Encoding. - Оставить компрессию включенной, но использовать
gunzip on;чтобы распаковатьgzip(неbr!). - Фильтровать статику с диска (которую отдает сам Nginx) и включать
gzipуже послеsub_filterна фронте.
Если у вас активно brotli на бекенде, приемлемого «debr» в Nginx нет. Тогда надежный вариант — отправлять на бекенд Accept-Encoding: identity (пустая строка в proxy_set_header Accept-Encoding ""; эквивалентна) и пусть компрессию выполняет фронтовый Nginx.
Заголовки и кэш: тонкости совместимости
Меняя тело ответа, вы потенциально нарушаете валидаторы кеша и контроль целостности. Минимальный набор практик:
- Снимайте
ETag(proxy_hide_header ETagи добавляйте пустойETag), иначе клиент может кэшировать несогласованное тело. - Включайте
sub_filter_last_modified on, чтобыLast-Modifiedсоответствовал моменту модификации тела фильтром. - Явно добавьте
Vary, если от логики зависитAccept-EncodingилиAccept-Language. Пример:add_header Vary Accept-Encoding,Accept-Language always; - Отключайте
Accept-Rangesдля таких ответов:add_header Accept-Ranges none always;.
Если фильтруете только HTML, а статику отдаете как есть с сильным кэшем, проблем меньше. Для CDN полезно внедрить версионирование ассетов: см. версионирование статики и CDN-кеш.

Производительность и масштабирование
sub_filter увеличивает нагрузку на CPU и память, особенно при больших страницах и шаблонах с длинными строками. Рекомендации:
- Ограничивайте
sub_filter_typesтолько необходимыми типами. - Сужайте область действия до нужных
location, не включайте фильтр на API и бинарные загрузки. - Держите
proxy_buffering on, подберите буферы под средний размер страниц:proxy_buffers,proxy_buffer_size,proxy_busy_buffers_size. - Используйте краткосрочно: как временную «заплатку» на период миграции/катовера, параллельно исправляя первоисточники в коде.
Совместимость: HTTP/2, HTTP/3, SSI и Range
С HTTP/2/HTTP/3 проблем обычно нет — фильтр работает на уровне тела ответа, транспорт неважен. Важнее порядок фильтров: если вы используете SSI, частые инклюды могут быть отрендерены позже, чем сработает подмена. Тестируйте подмножество страниц, где есть include, и при необходимости применяйте sub_filter уже после сборки страницы (на итоговом location).
Диапазонные запросы Range лучше отключить для фильтруемых типов: частичная передача теряет смысл после модификации текста. Если нужна поддержка Range для больших файлов, читайте разбор: Диапазоны HTTP и кеш в Nginx/Apache. Для неподменяемых больших файлов (видео, архивы) оставляйте поддержку Range в отдельных location без sub_filter.
Диагностика: почему «не работает»
- Ответ сжат
br/gzip. ПроверьтеContent-Encoding, очиститеAccept-Encodingи включитеgunzipдля gzip. - Неверный
Content-Type. Добавьте соответствующие типы вsub_filter_types. - Ищете не ту строку. Проверьте исходный текст ответа через
curl -sSи убедитесь, что подстрока совпадает по регистру и кодировке. - Срабатывает только один раз. Установите
sub_filter_once off, чтобы заменить все вхождения. - Подмена ломает JSON/JS. Искомая подстрока попадает в литералы. Ограничьте типы и
location, избегайте подмены в скриптах без крайней необходимости.
Пример: частичная подмена только в админке
Иногда безопаснее начать с узкого сегмента — например, административной панели.
location ^~ /admin/ {
proxy_pass http://app;
proxy_set_header Accept-Encoding "";
gunzip on;
sub_filter "https://old.example.com/assets/" "/assets/";
sub_filter_once off;
sub_filter_types text/html text/css;
sub_filter_last_modified on;
proxy_hide_header ETag;
add_header ETag "" always;
}
Так вы проверите корректность замены на ограниченной аудитории, прежде чем включать фильтр на весь сайт.
Альтернативы и когда лучше не использовать sub_filter
- Для Location/Redirect правок —
proxy_redirectи настроенные редиректы на апстриме, а не подмена в теле. - Для смены домена в ссылках — долгосрочно лучше поправить шаблоны и конфиги приложения, чтобы убрать «технический долг».
- Для CDN — настройка генерации ссылок на стороне приложения или билд-стадия (asset host) надежнее и дешевле по CPU.
Чек-лист включения sub_filter
- Определите точную подстроку(и), которые нужно заменить. Избегайте слишком общих паттернов.
- Соберите список MIME-типов, в которых допустимо менять (обычно
text/html, иногдаtext/css). - Отключите компрессию апстрима (
Accept-Encodingпустой), включитеgunzipна фронте для совместимости с gzip. - Включите
sub_filter_last_modified on, уберитеETag, отключитеAccept-Rangesдля таких ответов. - Проверьте конкретные страницы через
curlи браузерные DevTools: тело, заголовки, кэш, отсутствие смешанного контента. - Нагрузочное тестирование: оцените рост CPU и памяти, при необходимости сузьте охват.
Расширенный пример с картой доменов и ограждением типов
map $sent_http_content_type $enable_sub {
default 0;
~*text/html 1;
~*text/css 1;
}
map $http_host $old_host {
default "old.example.com";
}
server {
listen 443 ssl http2;
server_name new.example.com;
location / {
proxy_pass http://app;
proxy_set_header Accept-Encoding "";
gunzip on;
if ($enable_sub) {
sub_filter "https://$old_host/" "https://new.example.com/";
sub_filter_once off;
sub_filter_types text/html text/css;
sub_filter_last_modified on;
proxy_hide_header ETag;
add_header ETag "" always;
add_header Accept-Ranges none always;
}
proxy_buffering on;
}
}
Итог
nginx sub_filter — удобный инструмент для миграций и быстрого подключения CDN, когда недоступны правки в приложении. Но это не серебряная пуля: используйте точечно, ограничивайте типы, снимайте конфликтующие заголовки (ETag, Accept-Ranges), следите за совместимостью с gzip/br и тестируйте производительность. Как только окно изменений позволит — переносите генерацию ссылок в код и сборку, оставляя sub_filter как временный мост, а не постоянный слой.


