Deployment guide

Mercure

Overview

Mercure is the open-source Mercure.rocks real-time hub. It is a single, self-contained Go binary that pushes live updates from your server to web browsers and other clients over Server-Sent Events (SSE). Your application publishes an update to a topic with a simple HTTP request, and every client subscribed to that topic receives it instantly over one long-lived connection, with no polling and no custom websocket code to maintain.

This guide covers the deployment and configuration of Mercure on Ubuntu 24.04 on Azure, using SecureAnyCloud Azure Marketplace images. The image installs a pinned, checksum-verified stable Mercure release (a Caddy build), baked reproducibly into the image. The exact version and its sha256 are recorded in /opt/mercure/VERSION.

Mercure runs 24/7 under systemd as the unit mercure.service, as a dedicated non-root mercure user, bound to the loopback interface. nginx terminates TLS in front of it.

JWT-authorised, secure by default. Mercure controls access with JSON Web Tokens. A publisher presents a token signed with the publisher key that grants a publish claim. A subscriber presents a token signed with the subscriber key that grants a subscribe claim. This image disables the demo and anonymous modes, so the hub never accepts an unauthenticated publish or subscribe, and the built-in demo UI is not exposed.

No default secret ships in the image. First boot generates the two signing keys per instance from the kernel random source, straight into /etc/mercure/mercure.env. The file is owned by root, is not world-readable, and systemd injects it into the service. The shipped image carries only placeholders, so no two VMs ever share a signing key.

First boot also writes ready-to-use example publisher and subscriber tokens to a root-only credentials file, so you can test immediately. It regenerates a fresh per-instance TLS certificate as well.

HTTPS out of the box. nginx terminates TLS on port 443 and reverse proxies to the hub on the loopback interface. The SSE-tuned configuration (buffering off, long read timeout) ensures the event stream is never held back or cut.

On first boot, the image generates a fresh per-instance self-signed certificate. Its Subject Alternative Names cover the VM public IP, hostname and loopback. In production, replace it with a CA-signed certificate for your own domain.

What you get:

  • A pinned stable Mercure release (single Go binary, a Caddy build), run under systemd as mercure.service as a dedicated non-root user, bound to 127.0.0.1:3000
  • nginx terminating TLS on :443 with a per-instance self-signed certificate regenerated on first boot, reverse proxying to the hub with an SSE-friendly configuration
  • An unauthenticated /healthz load balancer probe and a 301 redirect to HTTPS on :80
  • JWT-authorised publish and subscribe at /.well-known/mercure, with the demo and anonymous modes disabled so every request needs a valid token
  • A separate publisher signing key and subscriber signing key (HMAC HS256), each generated per instance on first boot into /etc/mercure/mercure.env from a placeholder template, so the shipped image carries no key
  • Ready-to-use example publisher and subscriber JWTs written to a root-only credentials file on first boot
  • Ubuntu 24.04 LTS base with the latest security patches applied at build time
  • Azure Linux Agent for cloud integration and SSH key injection
  • 24/7 SecureAnyCloud support with guaranteed 24 hour response SLA

Prerequisites

Before you deploy, prepare the following:

  • An active Azure subscription, an SSH public key, and a VNet with a subnet in the target region
  • A subscription to the Mercure listing on Azure Marketplace
  • An application that publishes updates, and browsers or clients that subscribe
  • For production, a DNS domain that you can point at the VM

Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is ample for a hub that serves many concurrent SSE subscribers. For very high fan-out, use Standard_D2s_v5 or larger. Mercure is efficient and lightweight, so it scales with CPU, memory and network.

1

Deploy from the Azure Portal

Search for Mercure in the Azure Marketplace, select the SecureAnyCloud publisher, and select Create.

Configure the NSG to allow the following inbound rules from the networks that your publishers and subscribers connect from:

  • TCP 22 for administration
  • TCP 443 for the hub over HTTPS
  • TCP 80 for the HTTP to HTTPS redirect and the health probe
2

Deploy from the Azure CLI

RG="mercure-prod"; LOCATION="eastus"; VM_NAME="mercure-01" IMAGE_URN="sac1647283583153:mercure-ubuntu-24-04:default:latest" SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" az group create --name "$RG" --location "$LOCATION" az network vnet create -g "$RG" --name mercure-vnet --address-prefix 10.100.0.0/16 --subnet-name mercure-subnet --subnet-prefix 10.100.1.0/24 az network nsg create -g "$RG" --name mercure-nsg az network nsg rule create -g "$RG" --nsg-name mercure-nsg --name allow-ssh --priority 100 \
  --source-address-prefixes "<your-mgmt-cidr>" --destination-port-ranges 22 --access Allow --protocol Tcp az network nsg rule create -g "$RG" --nsg-name mercure-nsg --name allow-https --priority 110 \
  --destination-port-ranges 443 --access Allow --protocol Tcp az network nsg rule create -g "$RG" --nsg-name mercure-nsg --name allow-http --priority 120 \
  --destination-port-ranges 80 --access Allow --protocol Tcp
# Accept the Marketplace terms once per subscription (required before the first CLI deploy)
az vm image terms accept --urn sac1647283583153:mercure-ubuntu-24-04:default:latest
az vm create -g "$RG" --name "$VM_NAME" --image "$IMAGE_URN" \
  --size Standard_B2s --storage-sku StandardSSD_LRS \
  --admin-username azureuser --ssh-key-values "$SSH_KEY" \
  --vnet-name mercure-vnet --subnet mercure-subnet --nsg mercure-nsg --public-ip-sku Standard
3

Verify the Mercure and nginx services

Connect over SSH, then confirm that both services are active and check which ports are bound. nginx listens on :443 (TLS) and :80 (redirect and health probe). The Mercure hub listens on 127.0.0.1:3000 (loopback).

sudo systemctl status mercure nginx --no-pager | head -14 sudo ss -tlnp | grep -E ':443|:80 |:3000' cat /opt/mercure/VERSION
4

See the pub/sub round-trip in action

This round trip is the core of Mercure. A subscriber opens a long-lived SSE connection to a topic, an application publishes an update to that topic, and the subscriber receives it instantly. Both sides authorise with a JWT.

First boot wrote ready-to-use example tokens into the root-only credentials file, so you can prove the round trip with a single command run on the VM over SSH. The commands below refer to the example subscriber and publisher tokens as <SUBSCRIBER_JWT> and <PUBLISHER_JWT>. Read yours with sudo cat /root/mercure-credentials.txt.

The commands target the hub on 127.0.0.1 over its per-instance TLS certificate, which is why they use -k. From a remote client, use this VM's public address or your own domain instead.

TOPIC="https://example.com/books/1"
# Subscribe in the background for a few seconds, capturing events to a file:
curl -sk -N -m 8 -G -H "Authorization: Bearer <SUBSCRIBER_JWT>" \
  --data-urlencode "topic=$TOPIC" "https://127.0.0.1/.well-known/mercure" > /tmp/sse.log & sleep 2
# Publish an update with the publisher token (returns the event id):
curl -sk -H "Authorization: Bearer <PUBLISHER_JWT>" \
  --data-urlencode "topic=$TOPIC" --data-urlencode "data=Hello from Mercure" \
  "https://127.0.0.1/.well-known/mercure"; echo
# The subscriber received the update over SSE: sleep 1; cat /tmp/sse.log

The publish request returns the new event's unique id, and the background subscriber's log shows the data: Hello from Mercure event delivered over Server-Sent Events.

That is a complete, JWT-authorised, real-time round trip. Publish once, and every subscriber connected to that topic is updated instantly.

5

Confirm authorisation is enforced

Because the demo and anonymous modes are disabled, the hub rejects any publish or subscribe that does not carry a valid token. It also rejects any token signed with the wrong key. Run the following commands to confirm it:

# Publish with NO token -> 401 Unauthorized:
curl -sk -o /dev/null -w 'no-token publish: HTTP %{http_code}\n' \
  --data-urlencode "topic=https://example.com/books/1" --data-urlencode "data=x" \
  "https://127.0.0.1/.well-known/mercure"
# Subscribe with NO token -> 401 Unauthorized (anonymous disabled):
curl -sk -o /dev/null -w 'no-token subscribe: HTTP %{http_code}\n' -m 5 -G \
  --data-urlencode "topic=https://example.com/books/1" \
  "https://127.0.0.1/.well-known/mercure"

Both requests return 401. The hub accepts only a request that carries a JWT signed with this VM's publisher or subscriber key, so your topics stay private to the clients you issue tokens to.

6

Understand the per instance keys and mint your own tokens

Mercure is configured through /etc/mercure/Caddyfile and the environment file /etc/mercure/mercure.env. The Caddyfile references the keys from the environment, so it holds no secret. systemd injects the environment file into the service.

This image generates the publisher and subscriber signing keys on first boot into that environment file, which is owned by root and not world-readable. The shipped image carries only placeholders. The root-only credentials file summarises the per-instance keys, the public hub URL and the ready-to-use example tokens:

sudo sed -n '1,40p' /etc/mercure/Caddyfile
sudo grep -E '^MERCURE_(PUBLISHER|SUBSCRIBER)_JWT_(KEY|ALG)=' /etc/mercure/mercure.env | sed -E 's/^(MERCURE_(PUBLISHER|SUBSCRIBER)_JWT_KEY=).*/\1<per-VM-secret-hidden>/'
sudo ls -l /root/mercure-credentials.txt
sudo openssl x509 -in /etc/nginx/certs/cert.pem -noout -subject -ext subjectAltName

The Caddyfile shows the mercure block, where publisher_jwt and subscriber_jwt read their keys from the environment, with no demo or anonymous directive. The environment file holds the two per-instance keys (shown masked) with algorithm HS256.

To mint your own token, sign a JWT with the relevant key. Read the publisher key with sudo cat /root/mercure-credentials.txt. A publisher token grants {"mercure":{"publish":["*"]}} and a subscriber token grants {"mercure":{"subscribe":["*"]}}. Narrow the topic selectors from * to the specific topics each client may use.

For a quick test, mint tokens at jwt.io. In your application, use any JWT library with the HS256 algorithm and this VM's key.

7

Publish from your application

Any client that can make an HTTP request and sign a JWT can publish. From your backend, mint a publisher token with this VM's publisher key, then POST the update:

curl -k -H "Authorization: Bearer <PUBLISHER_JWT>" \
  --data-urlencode "topic=https://your-app.example/orders/42" \
  --data-urlencode "data={\"status\":\"shipped\"}" \
  "https://127.0.0.1/.well-known/mercure"

Every browser or client subscribed to https://your-app.example/orders/42 receives the JSON payload instantly. Most languages have a Mercure client library or a JWT library that you can use to mint the token. The payload can be any string, and JSON is the common choice.

8

Subscribe from a browser

In a web page, subscribe with the native EventSource API and pass a subscriber token. In production, your application issues short-lived subscriber tokens to authenticated users (often through an authorisation cookie), so the key never reaches the browser.

const url = new URL("https://127.0.0.1/.well-known/mercure"); url.searchParams.append("topic", "https://your-app.example/orders/42"); const es = new EventSource(url, { withCredentials: true }); es.onmessage = (e) => console.log("update:", e.data);

The browser holds one connection open, and your onmessage handler fires the instant an update is published. This image sets permissive CORS by default, so you can start from any origin. For production, narrow cors_origins in the Caddyfile to your own domains.

9

Use your own domain and a trusted certificate (production)

The self-signed certificate is a convenience for getting started. For production, point a DNS name you control at the VM public IP and replace the certificate.

Copy a CA-signed certificate and key to /etc/nginx/certs/cert.pem and /etc/nginx/certs/key.pem, or point the nginx ssl_certificate directives at your own paths. Then run sudo systemctl reload nginx.

Alternatively, front the VM with a managed certificate from Azure Application Gateway or a CDN, and forward to nginx on :443.

Subscribers and publishers then reach the hub at your own domain with no certificate trust step.

10

Server components

Component Version / Detail

Real-time hub Mercure (pinned stable release, a Caddy build, baked reproducibly, see /opt/mercure/VERSION)

TLS terminated by nginx on :443 (self signed per VM), certificate at /etc/nginx/certs/

Hub listener 127.0.0.1:3000 (loopback), pub/sub endpoint /.well-known/mercure, /healthz

Front helper nginx (:80 -> 301 https, unauthenticated /healthz)

Signing keys per instance publisher + subscriber HMAC keys generated first boot into /etc/mercure/mercure.env

Authorisation JWT publish/subscribe (HS256); demo and anonymous modes disabled

Service user dedicated non root mercure user

Operating system Ubuntu 24.04 LTS (patched at build)

License AGPL-3.0 (Mercure)

11

Managing the Mercure service

sudo systemctl status mercure --no-pager | head -12 sudo systemctl is-active mercure nginx

Apply a change to the Caddyfile with sudo systemctl restart mercure. Reload the nginx helper with sudo systemctl reload nginx. Follow the logs live with sudo journalctl -u mercure -f.

The hub configuration lives at /etc/mercure/Caddyfile and the keys at /etc/mercure/mercure.env.

12

Security recommendations

  • Restrict the NSG. Allow TCP 443 (and 22 for administration) only from the networks that your publishers and subscribers connect from.
  • Use a trusted certificate and your own domain (Step 9), so clients validate the hub without a manual trust step.
  • Issue narrow, short-lived tokens. Grant publishers and subscribers only the specific topic selectors they need instead of *. Give browser subscribers short-lived tokens issued by your application.
  • Protect the keys. /etc/mercure/mercure.env holds the signing keys and is not world-readable. Keep it that way, and regenerate the keys if a VM is ever exposed.
  • Narrow CORS for production. Set cors_origins in the Caddyfile to your own domains once you know them.
  • Keep the OS patched. Unattended security upgrades remain enabled on the running VM.
13

Support and Licensing

Mercure is distributed under the GNU Affero General Public License v3.0 (AGPL-3.0). This SecureAnyCloud image bundles the unmodified official open-source release. The complete corresponding source is available at the upstream project, github.com/dunglas/mercure, and is also recorded in /opt/mercure/VERSION.

SecureAnyCloud provides the packaging, hardening, per-instance signing keys and TLS automation, the SSE-tuned reverse proxy, and 24/7 support with a guaranteed 24 hour response SLA.

Mercure is an independent open-source project by Kévin Dunglas. This image is not affiliated with or endorsed by the Mercure project.

Deploy on Azure

Find Mercure on Ubuntu 24.04 in the Azure Marketplace, published by SecureAnyCloud. Deploy from the Portal or the Azure CLI as shown above.

Need Help?

Email sales@secureanycloud.com for deployment help, configuration questions or licensing enquiries.

Deploy on Azure

Launch Mercure on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure