Mercure on Ubuntu 24.04 on Azure User Guide
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.serviceas a dedicated non root user, bound to127.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
/healthzload balancer probe and a301redirect 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

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.

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.

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.

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.pemand/etc/nginx/certs/key.pem(or point the nginxssl_certificatedirectives at your own paths), thensudo 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.envholds 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_originsin 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.