Акция Панель управления ispmanager для VDS — первый месяц бесплатно
до 31.07.2026 Подробнее
Выберите продукт

Nginx sub_filter: переписываем ссылки на лету при переезде домена или подключении CDN

Разбираем, как применять Nginx sub_filter для прозрачной подмены ссылок в HTML/CSS/JS при миграции на новый домен и подключении CDN. Покажу конфиг, работу с gzip/br и gunzip, корректные заголовки кеширования, совместимость с HTTP/2/3, типичные ошибки и диагностику.
Nginx sub_filter: переписываем ссылки на лету при переезде домена или подключении CDN

Когда вы мигрируете сайт на новый домен или подключаете 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 — стандартный набор.

Фрагмент конфига Nginx с sub_filter, gunzip и proxy-заголовками

Случай 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.

FastFox VDS
Регистрация доменов от 99 руб.
Каждый проект заслуживает идеального доменного имени, выберите один из сотни, чтобы начать работу!

Случай 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 способен работать только с распакованным телом. Есть три подхода:

  1. Выключить компрессию на апстриме, прислав пустой Accept-Encoding.
  2. Оставить компрессию включенной, но использовать gunzip on; чтобы распаковать gzip (не br!).
  3. Фильтровать статику с диска (которую отдает сам 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-кеш.

Схема взаимодействия заголовков ETag, Last-Modified, Vary и Accept-Ranges при подмене тела ответа

Производительность и масштабирование

sub_filter увеличивает нагрузку на CPU и память, особенно при больших страницах и шаблонах с длинными строками. Рекомендации:

  • Ограничивайте sub_filter_types только необходимыми типами.
  • Сужайте область действия до нужных location, не включайте фильтр на API и бинарные загрузки.
  • Держите proxy_buffering on, подберите буферы под средний размер страниц: proxy_buffers, proxy_buffer_size, proxy_busy_buffers_size.
  • Используйте краткосрочно: как временную «заплатку» на период миграции/катовера, параллельно исправляя первоисточники в коде.
FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Совместимость: 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

  1. Определите точную подстроку(и), которые нужно заменить. Избегайте слишком общих паттернов.
  2. Соберите список MIME-типов, в которых допустимо менять (обычно text/html, иногда text/css).
  3. Отключите компрессию апстрима (Accept-Encoding пустой), включите gunzip на фронте для совместимости с gzip.
  4. Включите sub_filter_last_modified on, уберите ETag, отключите Accept-Ranges для таких ответов.
  5. Проверьте конкретные страницы через curl и браузерные DevTools: тело, заголовки, кэш, отсутствие смешанного контента.
  6. Нагрузочное тестирование: оцените рост 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 как временный мост, а не постоянный слой.

Поделиться статьей

Вам будет интересно

Debian/Ubuntu: mount: wrong fs type, bad option, bad superblock — как быстро найти и исправить причину OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: mount: wrong fs type, bad option, bad superblock — как быстро найти и исправить причину

Ошибка mount: wrong fs type, bad option, bad superblock в Debian/Ubuntu может означать и простую опечатку в имени раздела, и пробл ...
Debian/Ubuntu: XFS metadata corruption и emergency read-only — пошаговое восстановление OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: XFS metadata corruption и emergency read-only — пошаговое восстановление

Если XFS-раздел внезапно стал доступен только для чтения, а сервер ушёл в emergency mode, главное — не спешить. Разберём безопасны ...
Debian/Ubuntu: как исправить Failed to fetch при apt update OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: как исправить Failed to fetch при apt update

Ошибка Failed to fetch при apt update в Debian и Ubuntu обычно связана не с самим APT, а с DNS, сетью, зеркалом, прокси, временем ...