Mercure 1.0 alpha is available. Check out the new docs
Sponsored by Les-Tilleuls.coop
DocumentationSpecificationCloudDemos
Contribute!

Configuration

The Mercure.rocks Hub is a Caddy build with the Mercure module. Anything the Caddy docs describe also applies to this binary.

The most idiomatic way to configure it is a Caddyfile. Other formats (JSON, the admin API, env-var-driven config) work too; Mercure ships pre-wired for env vars in the official Docker image.

Minimal Caddyfile

# Caddyfile
hub.example.com {
  mercure {
    issuer https://example.com {
      publisher {
        jwt {env.MERCURE_PUBLISHER_JWT_KEY}
      }
      subscriber {
        jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
      }
    }
    resource_identifier https://hub.example.com/.well-known/mercure
    cors_origins        https://example.com
  }

  respond "Not Found" 404
}

Each issuer binds a trusted issuer (the value accepted in the token iss claim, RFC 9068 §4) to its own verification material, so a token is verified only with the key(s) of the issuer it claims. Repeat the block to trust several issuers with distinct keys.

The identifier is the stable identifier of whoever signs the tokens: your app's URL when it signs them itself, or the authorization server's issuer identifier. Add authorization_server inside the block to advertise that issuer in the protected resource metadata.

Inside publisher/subscriber, use jwt <key> [<algorithm>] for a shared secret or public key, or jwks_uri <url> [<algorithm>...] for a JWK Set. The two are mutually exclusive.

The algorithm defaults to HS256 only for a raw shared secret. A PEM-encoded key must state its algorithm, and that algorithm must not be an HMAC one: the hub refuses to start otherwise, because verifying with HS* would use the public key as the shared secret and let anyone holding it forge tokens.

resource_identifier is the OAuth 2.0 audience that access tokens must carry in their aud claim (see Authorization). Leave it unset and the hub derives it from each request (the public URL the client contacted), so a hub reachable through several domains needs no configuration; set it only to pin one canonical audience shared across every domain.

resource_identifier and public_urls answer different questions and are independent: resource_identifier sets the token audience, while public_urls restricts which origins the hub answers on (rejecting others with 421). A single resource_identifier is what lets one token work across several public URLs, since a per-request-derived audience is specific to the host the client contacted.

Caddy provisions a Let's Encrypt certificate for hub.example.com automatically. To disable HTTPS (when behind a reverse proxy that terminates TLS), prefix the site address with http://:

# Caddyfile
http://hub.example.com:80 {
  # ...
}

Setting the port to 80 also disables HTTPS implicitly.

Mercure directives

DirectiveDescriptionDefault
issuer <id> { … }Bind a trusted issuer to its verification material. Repeatable. See issuer blocks.
resource_identifier <id>Pin the OAuth 2.0 resource identifier (token aud); unset, it is derived per request. See Discovery.derived per request
public_urls <url...>Public URLs the hub answers on (scheme pinned); an unlisted origin gets 421 Misdirected Request. Set it on a catch-all site.site host matching
anonymousAllow subscribers without a token to receive public updates.off
publish_origins <origin...>Origins allowed to publish (cookie-based auth only).
cors_origins <origin...>CORS allowed origins. See CORS.
cookie_name <name>Cookie that carries the access token for browser clients. Use a name without the __Secure- prefix for plain-HTTP development.__Secure-mercure_access_token
protocol_version_compatibility <version>Accept 0.x behaviors (7 or 8). Requires the deprecated_topic / deprecated_claim build tags. See Upgrade.off
subscriptionsEnable subscription events and the subscription API.off
heartbeat <duration>Interval between SSE heartbeat comments. 0s to disable.40s
max_request_body_size <size>Maximum size of publish and QUERY subscribe request bodies (e.g. 512KB); larger requests get a 413. 0 delegates to a reverse proxy.1MiB
transport <name> [{ <options...> }]Transport configuration. See Transports.bolt
dispatch_timeout <duration>Max time to dispatch one update to one subscriber. 0s disables.5s
write_timeout <duration>Max duration of a subscriber connection. 0s disables. See Rolling updates.600s
topic_matcher_cache <maxEntries>Cache for topic matcher evaluations. 0 or negative disables it.100000
subscriber_list_cache_size <maxSize>Subscriber list cache size. 0 for unbounded.100000
debuggerServe the debugger UI at /.well-known/mercure/debug/ (no token, no playground endpoints). Safe in production.off
playgroundEnable debugger and the insecure playground: the /playground/ discovery endpoints, and a hub-minted all-access token prefilled in the UI. Dev only.off

The debugger UI (debugger) is a client-side tool: it opens streams and publishes with a token you paste, exposing nothing the hub's API doesn't already, so it is safe to enable in production. The playground directive is not: it mints a token granting publish and subscribe on every topic, registers endpoints that echo whatever they are sent, and turns on anonymous for you. Keep playground off on any hub that serves real users. anonymous itself is a normal, production-ready opt-in for topics you deliberately want readable without a token — safe to enable when it's your own explicit choice, not a side effect of the playground. To try the debugger against a protected hub, mint yourself a scoped token with caddy mercure-token.

Issuer blocks

An issuer block binds a trusted issuer to its own verification material:

issuer https://issuer-a.example {
  authorization_server            # advertise in the protected resource metadata
  publisher {
    jwt !ChangeThisSecret! HS256  # shared secret or PEM public key + algorithm
  }
  subscriber {
    jwks_uri https://issuer-a.example/jwks RS256  # JWK Set URL + allowed algorithms
  }
}

issuer https://issuer-b.example {
  publisher {
    jwks_uri https://issuer-b.example/jwks
  }
  subscriber {
    jwks_uri https://issuer-b.example/jwks
  }
}
Sub-directiveDescription
authorization_serverAdvertise this issuer in the protected resource metadata. Off by default.
publisher { … }Verification material for publisher tokens. Omit to reject publishing for this issuer.
subscriber { … }Verification material for subscriber tokens. Omit to reject subscribing for this issuer.
jwt <key> [<algorithm>]Shared secret or PEM public key, plus algorithm. A PEM key must set a non-HMAC one (see above).
jwks_uri <url> [<algorithm>...]JWK Set URL and its allowed algorithms (defaults to the asymmetric allowlist). Accepts file:// URLs.

jwt and jwks_uri are mutually exclusive within a publisher/subscriber block.

[!WARNING] The pre-1.0 top-level directives publisher_jwt, subscriber_jwt, publisher_jwks_url and subscriber_jwks_url are deprecated. They map to a single implicit issuer and only work in compatibility mode; modern mode requires an issuer block.

Mercure hub environment variables

The Docker image and the official Caddyfile read these:

VariableDescriptionDefault
SERVER_NAMESite address. Use :80 to bind without a hostname.localhost
MERCURE_PUBLISHER_JWT_KEYPublisher signing key.
MERCURE_PUBLISHER_JWT_ALGPublisher algorithm.HS256
MERCURE_SUBSCRIBER_JWT_KEYSubscriber signing key.
MERCURE_SUBSCRIBER_JWT_ALGSubscriber algorithm.HS256
MERCURE_TRUSTED_ISSUERSSets the issuer block identifier (the token iss).
MERCURE_EXTRA_DIRECTIVESAdditional Mercure directives. One per line.
GLOBAL_OPTIONSCaddy global options.
CADDY_EXTRA_CONFIGSnippets / named routes.
CADDY_SERVER_EXTRA_DIRECTIVESCaddyfile directives outside the mercure block.
MERCURE_LICENSELicense key for Self-Hosted Mercure.

MERCURE_EXTRA_DIRECTIVES is convenient for quick tweaks but don't put credentials there (transport passwords, JWKS URLs with tokens). Write a custom Caddyfile and use {env.MY_SECRET} for those.

Mercure hub transports

The transport stores history and (in clustered builds) synchronizes between nodes.

Bolt transport (default, single-node)

# Bolt transport (default, single-node)
mercure {
  transport bolt {
    path /data/mercure.db
    size 0
    cleanup_frequency 0.3
  }
  # ...
}
OptionDescription
pathPath to the BoltDB file. Default: mercure.db.
bucket_nameBucket name. Default: updates.
cleanup_frequencyProbability per publish of running history cleanup. 0 (never) to 1 (always).
sizeMaximum number of events to keep. 0 for unlimited (default; bound only by disk size).

The open-source build keeps history forever by default. Set size if you want a cap.

Local transport (no history)

transport local disables history entirely. Use it when reconnect replay isn't needed and you want the lowest possible memory footprint.

Redis / Postgres / Kafka / Pulsar

These ship with Self-Hosted Mercure. They enable multi-node deployments and queryable history.

Pro tip. The open-source hub runs on a single node. For redundancy across nodes, low-latency multi-region deploys, or storing events in Redis or Postgres for SQL-backed queries, Self-Hosted Mercure ships those transports starting at €1,500/year.

CORS

If the page that opens the SSE connection is on a different origin than the hub, you must list it in cors_origins:

# CORS
mercure {
  cors_origins https://app.example.com https://admin.example.com
}

* is allowed only if the hub is fully anonymous (no JWT, no cookie). Browsers refuse credentialed requests from a wildcard origin.

Avoid listing the literal null origin: browsers send Origin: null for sandboxed iframes, data: URLs, and local files, so allowlisting it would send credentialed responses to any such opaque context.

If your app and hub run on the same registrable domain (e.g. example.com and hub.example.com), the hub can be reached without CORS at all by going through a reverse proxy that mounts the hub on the app's origin. See Reverse proxies.

JWT validation via JWKS

When tokens are minted by an external IdP (Keycloak, Cognito, Auth0):

# JWT validation via JWKS
mercure {
  issuer https://idp.example.com {
    authorization_server
    publisher {
      jwks_uri https://idp.example.com/.well-known/jwks.json
    }
    subscriber {
      jwks_uri https://idp.example.com/.well-known/jwks.json
    }
  }
}

The hub fetches and caches the keys, validates each token's kid against them, and rotates automatically when the IdP rotates. Token issuance stays with the IdP; the hub only verifies.

jwks_uri also accepts file:// URLs, read once at provision time, for keys mounted as files. Append algorithms to pin the allowlist (e.g. jwks_uri <url> RS256 ES256); it defaults to the asymmetric algorithms.

OAuth 2.0 protected resource metadata

When the hub validates tokens, it serves protected resource metadata (RFC 9728) at /.well-known/oauth-protected-resource/.well-known/mercure. Advertise the authorization servers that issue tokens so clients can discover where to obtain one:

# OAuth 2.0 protected resource metadata
mercure {
  resource_identifier https://hub.example.com/.well-known/mercure
  issuer https://auth.example.com {
    authorization_server
    publisher {
      jwks_uri https://auth.example.com/jwks
    }
    subscriber {
      jwks_uri https://auth.example.com/jwks
    }
  }
}

Keeping tokens out of logs

The hub accepts no token in the URL (RFC 9700 forbids it), so modern clients never put one there. Only 0.x clients did, in the authorization query parameter, and only in compatibility mode. If you run compatibility mode, redact it from access logs with a log field filter:

# Keeping tokens out of logs
log {
  format filter {
    fields {
      request>uri query {
        replace authorization REDACTED
      }
    }
  }
}

RSA / ECDSA keys

# RSA / ECDSA keys
ssh-keygen -t rsa -b 4096 -m PEM -f publisher.key
openssl rsa -in publisher.key -pubout -outform PEM -out publisher.key.pub

Start the hub with the public key for verification and the algorithm:

# RSA / ECDSA keys
MERCURE_PUBLISHER_JWT_KEY="$(cat publisher.key.pub)" \
MERCURE_PUBLISHER_JWT_ALG=RS256 \
MERCURE_SUBSCRIBER_JWT_KEY="$(cat subscriber.key.pub)" \
MERCURE_SUBSCRIBER_JWT_ALG=RS256 \
./mercure run

Mercure hub health check endpoints

The Caddy admin API (default localhost:2019) exposes:

EndpointDescription
GET /mercure/health/ready200 if all transports can serve traffic, 503 otherwise.
GET /mercure/health/live200 if all transports are fundamentally operational.
GET /mercure/health/{name}/readyPer-hub readiness (when running multiple).
GET /mercure/health/{name}/livePer-hub liveness.

The endpoints bind to localhost for security. Probes from outside the container should use kubectl exec or docker exec (see Health monitoring). Binding the admin API to 0.0.0.0:2019 works but exposes /stop and /load to the pod network. Almost never what you want.

Mercure hub performance tuning

A few knobs that move the needle:

  • dispatch_timeout: too low and slow subscribers get cut off; too high and a stuck dispatch ties up resources. The 5s default is a reasonable starting point.

  • write_timeout: controls how often each subscriber rotates its connection in steady state. Higher values mean fewer reconnects but worse drain pacing on shutdown. See Rolling updates.

  • topic_matcher_cache and subscriber_list_cache_size: increase if your hub has many distinct matchers and you see CPU spent in matcher evaluation. Decrease if memory is tight.

  • File descriptors: every subscriber takes one. ulimit -n 100000 on the host (or the equivalent in your orchestrator) for high-fanout hubs.

Load testing and Debugging cover the rest.

Mercure hub configuration reload

Caddy hot-reloads on signal: kill -USR1 <pid> or caddy reload. Active SSE connections are preserved across reloads as long as the listening sockets don't change.

Mercure hub runtime introspection

The Caddy admin API also exposes:

  • /config/: the current effective config (JSON).

  • /metrics: Prometheus metrics (when metrics is in GLOBAL_OPTIONS).

  • /debug/pprof/: Go profiler endpoints (when debug is in GLOBAL_OPTIONS). See Debugging.