Me
Streaming & Messaging Azure

Mercure on Ubuntu 24.04 on Azure User Guide

| Product: Mercure on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and configuration of Mercure on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. Mercure is the open source Mercure.rocks real-time hub: a single self contained Go binary that pushes live updates from your server to web browsers and other clients in real time 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.

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, with nginx terminating 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 and granting a publish claim; a subscriber presents a token signed with the subscriber key and granting 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. The two signing keys are generated per instance on the first boot from the kernel random source, straight into /etc/mercure/mercure.env (owned by root, not world readable, injected into the service by systemd). 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, and regenerates a fresh per instance TLS certificate.

HTTPS out of the box. nginx terminates TLS on port 443 and reverse proxies to the hub on the loopback interface with an SSE tuned configuration (buffering off, long read timeout) so the event stream is never held back or cut. On the first boot the image generates a fresh per instance self signed certificate (its Subject Alternative Names cover the VM public IP, hostname and loopback). You replace it with a CA signed certificate for your own domain in production.

What is included:

  • 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, rendered 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 latest security patches applied at build time

  • Azure Linux Agent for seamless cloud integration and SSH key injection

  • 24/7 cloudimg support with guaranteed 24 hour response SLA

Prerequisites

  • Active Azure subscription, SSH public key, VNet + subnet in target region

  • Subscription to the Mercure listing on Azure Marketplace

  • An application that will publish updates, and browsers or clients that will subscribe; a DNS domain you can point at the VM for production

Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is ample for a hub serving 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.

Step 1: Deploy from the Azure Portal

Search Mercure in Marketplace, select the cloudimg publisher, click Create. NSG rules: TCP 22 (admin), TCP 443 (the hub over HTTPS) and TCP 80 (HTTP to HTTPS redirect and health probe) from the networks your publishers and subscribers connect from.

Step 2: Deploy from the Azure CLI

RG="mercure-prod"; LOCATION="eastus"; VM_NAME="mercure-01"
GALLERY_IMAGE_ID="/subscriptions/<sub-id>/resourceGroups/azure-cloudimg/providers/Microsoft.Compute/galleries/cloudimgGallery/images/mercure-ubuntu-24-04/versions/<version>"
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
az vm create -g "$RG" --name "$VM_NAME" --image "$GALLERY_IMAGE_ID" \
  --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

Step 3: Verify the Mercure and nginx services

Connect over SSH, then confirm both services are active and see which ports are bound. nginx listens on :443 (TLS) and :80 (redirect and health probe), and 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

mercure.service and nginx.service both active (running); the pinned Mercure version and sha256 baked into the image; ss shows nginx listening on :443 for TLS and :80 for the redirect and health probe, and the Mercure hub on 127.0.0.1:3000 on the loopback interface

Step 4: See the pub/sub round-trip in action

This 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 in a single command run on the VM over SSH. The example subscriber and publisher tokens are referenced below 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 (hence -k), and from a remote client you 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 connected subscriber to that topic is updated instantly.

A terminal showing an SSE subscriber opened with a subscriber JWT, an update published to the same topic with a publisher JWT returning the new event id, and the subscriber log receiving the data Hello from Mercure event delivered over Server-Sent Events, proving the JWT-authorised publish and subscribe round-trip

Step 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, and any token signed with the wrong key. Prove 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 return 401. Only a request carrying a JWT signed with this VM's publisher or subscriber key is accepted, so your topics are private to the clients you issue tokens to.

A terminal showing a publish with no token returning HTTP 401 and a subscribe with no token returning HTTP 401, proving the hub rejects unauthenticated publish and subscribe because the demo and anonymous modes are disabled

Step 6: Understand the per instance keys and mint your own tokens

Mercure is configured through /etc/mercure/Caddyfile (which references the keys from the environment, so it holds no secret) and the environment file /etc/mercure/mercure.env, which systemd injects into the service. This image generates the publisher and subscriber signing keys on first boot into that env file (owned by root, not world readable); the shipped image carries only placeholders. The per instance keys, the public hub URL and ready-to-use example tokens are summarised in the root only credentials file:

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 with publisher_jwt and subscriber_jwt reading their keys from the environment and no demo or anonymous directive. The env file holds the two per instance keys (shown masked) with algorithm HS256. To mint your own token, sign a JWT with the relevant key. For example, with the publisher key (read it 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. You can mint tokens at jwt.io for a quick test, or in your application with any JWT library using the HS256 algorithm and this VM's key.

The Caddyfile mercure block with publisher_jwt and subscriber_jwt reading keys from the environment and no demo or anonymous directive, the mercure.env showing the per instance publisher and subscriber keys masked with HS256 algorithm, the root only credentials file listed with 0600 permissions, and the self signed certificate Subject Alternative Names covering this VM

Step 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 and 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 you can use to mint the token; the payload can be any string, and JSON is the common choice.

Step 8: Subscribe from a browser

In a web page, subscribe with the native EventSource API. You pass a subscriber token; in production your application issues short lived subscriber tokens to authenticated users (often via 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. Because this image sets permissive CORS by default, you can start from any origin; narrow cors_origins in the Caddyfile to your own domains for production.

Step 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:

  • Drop a CA signed certificate and key into /etc/nginx/certs/cert.pem and /etc/nginx/certs/key.pem (or point the nginx ssl_certificate directives at your own paths), then 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.

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)

Step 11: Managing the Mercure service

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

Apply a configuration change to the Caddyfile with sudo systemctl restart mercure, reload the nginx helper with sudo systemctl reload nginx, and 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.

Step 12: Security recommendations

  • Restrict the NSG. Allow TCP 443 (and 22 for admin) only from the networks 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 rather than *, and 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.

Step 13: Support and Licensing

Mercure is distributed under the GNU Affero General Public License v3.0 (AGPL-3.0). This cloudimg image bundles the unmodified official open source release; the complete corresponding source is available at the upstream project, github.com/dunglas/mercure (also recorded in /opt/mercure/VERSION). cloudimg 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 and this image is not affiliated with or endorsed by the Mercure project.

Deploy on Azure

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

Need Help?

Email support@cloudimg.co.uk for deployment help, configuration questions, or licensing enquiries.