almdev Технический блог

Mercure за nginx: хаб, ключи и отладка потока
Локально работает, а за прокси поток обрывается примерно через минуту. Разбираем почему.

Настройка хаба Mercure в Docker: Caddyfile, два ключа JWT, проксирование потока событий через nginx с отключённой буферизацией, CORS и что не оставлять включённым на проде.

Подкатегория: Docker

Чтение
5 мин
Технологии / версии
Docker · nginx · Mercure · SSE
16 июл 2026 · 5 мин · 3 просмотра · Ops
Поток Mercure проходит через защищённый прокси-шлюз с двумя ключами
Docker 07/2026

Хаб поднялся, приложение публикует, локально всё приходит. Выкатываем за обратный прокси — поток обрывается примерно через минуту. Или не начинается вовсе, а в консоли браузера бесконечные переподключения без единой ошибки.

Ниже — четыре частые причины внутри nginx. Если они исключены, проверять нужно следующий слой: внешний прокси, авторизацию, CORS и сам хаб.

Место хаба в схеме

браузер ──▶ Traefik ──▶ nginx проекта ──▶ хаб Mercure
                            │
                            └──▶ PHP-FPM

Хаб — отдельный контейнер, наружу напрямую не смотрит. Браузер обращается к нему по тому же домену, что и к приложению, по пути /.well-known/mercure, а nginx решает, куда направить.

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

Сервис в compose

  mercure:
    image: dunglas/mercure:${MERCURE_VERSION}
    container_name: ${PROJECT_NAME}_mercure
    volumes:
      - ./.docker/mercure/Caddyfile:/etc/caddy/Caddyfile
      - mercure_data:/data
    environment:
      MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_PUBLISHER_JWT_KEY}
      MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_SUBSCRIBER_JWT_KEY}
    networks:
      internal:
        aliases:
          - mercure-upstream

MERCURE_VERSION у меня содержит проверенный тег, а не latest: обновление хаба тогда остаётся отдельным видимым изменением. Псевдоним в сети нужен, чтобы конфигурация nginx не знала имени проекта и переносилась между проектами без правок.

Том для данных — под журнал событий, из которого догоняются пропущенные обновления при переподключении. Без него после перезапуска хаба история теряется.

Caddyfile

Образ построен на Caddy, и его конфигурация выглядит непривычно:

{
	{$GLOBAL_OPTIONS}
}

{$CADDY_EXTRA_CONFIG}

:80 {
	log

	mercure {
		publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY}
		subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
		cors_origins {$MERCURE_CORS_ORIGINS}
		anonymous
		subscriptions
		transport bolt {
			path /data/mercure.db
			size 1000
			cleanup_frequency 0.3
		}
	}

	respond /healthz 200
	respond "Mercure hub"
}

anonymous разрешает подписку без токена. Нужен только для публичных топиков; если все топики приватные, эту директиву надо убрать — иначе любой может подписаться на публичную часть, о существовании которой вы не знали.

subscriptions включает web API подписок и служебные события об открытии и закрытии подписок. В этом проекте они нужны только при отладке, поэтому на проде директиву убираю.

transport bolt настраивает журнал событий. Параметр size=1000 хранит до тысячи обновлений; сколько это во времени, зависит от фактической частоты публикаций. Когда нужное событие уже вытеснено, догнать поток через Last-Event-ID не получится.

Слушает хаб на 80 без TLS, потому что снаружи он закрыт nginx. Шифровать трафик внутри сети Docker незачем.

Два ключа

Ключ издателя проверяет токены приложения, ключ подписчика — токены браузера. Mercure допускает один общий ключ, но на проде я разделяю их.

Сам подписной JWT не раскрывает секрет и без claim mercure.publish не даёт права публикации. Риск в другом: компрометация общего HMAC-ключа позволяет подписывать токены обеих ролей. С разными ключами утечка ключа подписчика не открывает публикацию.

Генерация:

openssl rand -base64 48

Оба ключа лежат в .env и никогда не попадают в репозиторий. При компрометации ключа подписчика меняется он один, и это не требует перевыпуска токенов публикации.

Проксирование потока в nginx

Вот четыре настройки nginx, с которых я начинаю проверку обрывов.

location /.well-known/mercure {
    proxy_pass http://mercure-upstream;

    proxy_http_version 1.1;
    proxy_set_header Connection "";

    proxy_buffering off;
    proxy_cache off;
    gzip off;

    proxy_read_timeout 24h;
    proxy_send_timeout 24h;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

Буферизация. Главная причина. Nginx по умолчанию копит ответ и отдаёт его целиком. Для потока это означает, что события накапливаются и не доходят, пока буфер не заполнится. Симптом: события приходят пачками с задержкой в минуты или не приходят вовсе.

Тайм-аут чтения. По умолчанию 60 секунд. Поток, в котором ничего не происходит минуту, обрывается. Отсюда классическое «работает, пока кликаешь, отваливается, когда отвернулся».

Сжатие. Фильтр gzip может задерживать небольшие порции потока в своих буферах. Для SSE я отключаю его на этом location, чтобы не добавлять ещё один источник задержки поверх прокси.

Версия HTTP и заголовок соединения. Nginx по умолчанию ходит к upstream по HTTP/1.0. SSE может работать и так, но для долгого проксируемого соединения я явно включаю HTTP/1.1 и убираю hop-by-hop заголовок Connection, чтобы результат не зависел от значений по умолчанию.

Я держу этот блок целиком: отдельные директивы отвечают за разные симптомы, а явная конфигурация проще проверки догадок о настройках по умолчанию.

Проверка потока из консоли

Первое, что нужно уметь, когда что-то не работает:

curl -N -H "Authorization: Bearer $TOKEN" \
  "https://app.test/.well-known/mercure?topic=https://app.test/projects/1/issues"

Флаг -N отключает буферизацию вывода на стороне curl. Без него небольшое событие может появиться с задержкой и отправить диагностику не в ту сторону.

Соединение должно повиснуть и молчать. Это правильное поведение. Публикуем событие из другого терминала:

curl -X POST https://app.test/.well-known/mercure \
  -H "Authorization: Bearer $PUBLISHER_TOKEN" \
  -d 'topic=https://app.test/projects/1/issues' \
  -d 'data={"hello":"world"}'

В первом терминале должно появиться событие. Если появилось — хаб и nginx в порядке, проблема в приложении или в браузере.

CORS и куки

Если хаб доступен с того же origin, что и приложение, CORS не участвует и настраивать его для браузера не нужно.

На отдельном поддомене нужны список разрешённых origin в конфигурации хаба и передача учётных данных в клиенте. Кука должна охватывать хост хаба: host-only cookie приложения на соседний поддомен не отправится, поэтому область Domain задают осознанно.

SameSite=Strict само по себе не мешает запросу между HTTPS-поддоменами одного registrable domain: для браузера это один site. Если хаб находится уже на другом site, тогда для cookie нужны SameSite=None; Secure. Публичные топики работают и без cookie, поэтому ошибку в её области легко принять за частичную поломку хаба.

Я выбрал общий домен именно чтобы не разбираться в этом каждый раз.

Что не оставлять на проде

Директива anonymous, если все топики приватные. С ней достаточно знать имя топика, чтобы читать его без токена.

Директива subscriptions, если приложению не нужны web API и события подписок: лишнюю поверхность лучше не открывать.

Пустой список разрешённых источников или звёздочка в нём.

Ключи из примеров. Строка !ChangeThisMercureHubJWTSecretKey! встречается в проде чаще, чем хочется думать.

И режим разработки Caddy, если он был включён для самоподписанных сертификатов — на проде TLS терминируется прокси, и хабу он не нужен вовсе.

Итог

Настройка занимает час, из которого сорок минут уходит на четыре группы параметров nginx. Написать их сразу правильно можно только один раз посмотрев на список — я держу его в шаблоне конфигурации и копирую между проектами, потому что помнить, почему именно proxy_set_header Connection "", нет никакого желания.

#docker #nginx #mercure #sse