Reverse proxies
Mercure works behind any HTTP reverse proxy that can keep a streaming response open. Two configurations matter, and most defaults get them wrong:
- Don't buffer the response. SSE pushes events as they're written; buffering holds them until the buffer fills, which delays everything by seconds.
- Long read timeouts. A typical 30s or 60s read timeout closes every SSE connection that goes idle.
Below are working configurations for the proxies people use most. Adapt for your setup.
#NGINX
# /etc/nginx/conf.d/mercure.conf
server {
listen 443 ssl http2;
server_name hub.example.com;
ssl_certificate /etc/ssl/hub.example.com.crt;
ssl_certificate_key /etc/ssl/hub.example.com.key;
location / {
proxy_pass http://mercure-upstream;
proxy_http_version 1.1;
# Don't buffer the response, SSE relies on immediate flush
proxy_buffering off;
proxy_cache off;
# Long read timeout, SSE connections live for hours
proxy_read_timeout 24h;
# Forwarded headers (only enable USE_FORWARDED_HEADERS=1 on the hub
# if NGINX is the only thing in front and these are sanitized)
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
upstream mercure-upstream {
server 127.0.0.1:8080;
}
Key directives:
proxy_buffering off: without this, NGINX may hold events for seconds until the buffer fills. The single most common cause of "events arrive in batches."proxy_read_timeout 24h: NGINX's default is 60 seconds. Anything less than yourheartbeatsetting (40s default on the hub) will drop connections regularly.proxy_http_version 1.1andConnection "": tell NGINX not to add aConnection: closeand not to downgrade from HTTP/2 between the client and itself.
#Traefik
Traefik is well-behaved out of the box for SSE, no buffering, sensible timeouts. A working compose.yaml:
# compose.yaml
services:
reverse-proxy:
image: traefik:v3
command:
- "--providers.docker"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.le.acme.email=ops@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- traefik_letsencrypt:/letsencrypt
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
SERVER_NAME: ":80" # let Traefik handle TLS
MERCURE_PUBLISHER_JWT_KEY: "!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY: "!ChangeThisMercureHubJWTSecretKey!"
volumes:
- mercure_data:/data
- mercure_config:/config
labels:
- "traefik.enable=true"
- "traefik.http.routers.mercure.rule=Host(`hub.example.com`)"
- "traefik.http.routers.mercure.entrypoints=websecure"
- "traefik.http.routers.mercure.tls.certresolver=le"
- "traefik.http.services.mercure.loadbalancer.server.port=80"
volumes:
traefik_letsencrypt:
mercure_data:
mercure_config:
Disable the hub's own TLS (SERVER_NAME=:80). Traefik handles it.
For long write timeouts on Traefik (the default per-router timeout is generous, but worth pinning):
# Traefik
labels:
- "traefik.http.services.mercure.loadbalancer.responseforwarding.flushinterval=1ms"
flushinterval controls how often Traefik flushes streaming responses to the client. The default is fine for most cases; lower it if you observe events queuing.
#Caddy
If you're terminating TLS in another Caddy instance (or fronting Mercure with a separate Caddy reverse proxy):
# Caddyfile
hub.example.com {
reverse_proxy mercure:80 {
flush_interval -1 # flush every write
transport http {
versions 1.1 2
read_timeout 24h
response_header_timeout 24h
}
}
}
flush_interval -1 tells Caddy to flush immediately, never buffer. The default is generally good for SSE, but -1 is explicit and safe.
In practice you don't need a separate Caddy in front of the Mercure hub, the Mercure binary is a Caddy build. You can mount your existing site and the hub on the same Caddy instance with one config:
# Caddyfile
example.com {
route /api/* {
reverse_proxy api:8080
}
route /.well-known/mercure* {
mercure {
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY}
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
}
}
reverse_proxy frontend:3000
}
This sidesteps CORS entirely (everything's same-origin) and is the recommended pattern when you don't already have an existing reverse proxy.
#HAProxy
# /etc/haproxy/haproxy.cfg
frontend https
bind *:443 ssl crt /etc/ssl/hub.example.com.pem alpn h2,http/1.1
http-request set-header X-Forwarded-Proto https
default_backend mercure
backend mercure
option http-server-close
timeout server 24h # long-lived SSE
timeout tunnel 24h
server mercure 127.0.0.1:8080
The tunnel timeout is HAProxy's term for how long it lets an idle connection stay open after WebSocket-style upgrade or long-poll. SSE connections look the same way to it.
#AWS ALB
ALBs work with SSE if you bump the idle timeout, default is 60 seconds, which is too short:
- Idle timeout: raise to several minutes (e.g.
300). - Connection re-use: ALBs already do HTTP/2 to clients and HTTP/1.1 to targets, which is fine for SSE.
- Health checks: point them at
/.well-known/mercure(returns 405: pass any 4xx/5xx as healthy). Or expose the admin health endpoints on a separate listener.
#Cloudflare
Cloudflare proxies SSE, but be aware:
- The free tier has a 100-second proxy timeout for streaming responses on Free, Pro, Business plans. Heartbeats below that interval keep connections alive.
- Cloudflare Workers cannot proxy SSE for arbitrary durations either; use a regular hostname proxy if you can.
- Disable the Rocket Loader optimization for the hub hostname; it can interfere with
EventSource.
For long-lived SSE without a 100s cap, Mercure Cloud terminates connections directly without a proxy in between.
#CORS via reverse proxy
If your hub is on a different origin from your app, you can either configure CORS on the hub (cors_origins) or rewrite the request through the proxy so the hub appears same-origin:
# Caddyfile
app.example.com {
route /.well-known/mercure* {
reverse_proxy hub.internal:80
}
route /* {
reverse_proxy frontend:3000
}
}
Same-origin sidesteps CORS entirely. Same-origin also means the cookie can be Domain=app.example.com without any subdomain juggling.
#Common SSE reverse-proxy gotchas with Mercure
| Symptom | Likely cause |
|---|---|
| Events arrive in batches every few seconds | Proxy is buffering. Disable it. |
| Connections drop every 30 or 60 seconds | Proxy idle timeout. Raise it. |
| 502 Bad Gateway after a while | Proxy thinks the upstream is dead because no bytes flowed. Lower heartbeat on the hub or raise the proxy's read timeout. |
EventSource never connects from the browser | CORS misconfiguration. Check cors_origins and the response headers. |
#Set USE_FORWARDED_HEADERS carefully on the Mercure hub
The hub can read X-Forwarded-* and the RFC 7239 Forwarded header to know the original client IP and scheme:
# Set USEFORWARDEDHEADERS Carefully on the Mercure Hub
{
servers {
trusted_proxies static 10.0.0.0/8 172.16.0.0/12
}
}
Only trust these headers when the proxy in front of the hub strips or replaces them on every request. If clients can send their own X-Forwarded-For and the hub trusts it, your IP-based logic is wrong.
#Next steps for Mercure reverse proxies
- Configuration:
cors_originsand friends. - Docker: running the hub in a container.
- Health monitoring: what your proxy's health check should hit.