- Introduction to Mercure
- System Architecture
- Mercure Installation and Configuration
- Apache and SSL Configuration
- JWT Keys Generation
- Backend Implementation Example
- Frontend Implementation Example
1. Introduction to Mercure
Mercure is a real-time communication protocol based on Server-Sent Events (SSE). Unlike WebSockets, Mercure uses the standard HTTP protocol, making it simpler to implement and compatible with HTTP caching.
Advantages of Mercure
- Simple: Uses HTTP/1.1 and HTTP/2
- Efficient: Fewer resources than WebSockets
- Compatible: Works behind proxies
- Automatic reconnection: Handles disconnections
- Native support in most browsers
2. System Architecture
- The client (browser, mobile app, etc.) opens an
HTTP/HTTPS
connection to the Mercure hub (via the domain managed by the Apache vhost, e.g.,messages.habeuk.com
). - The Apache vhost acts solely as a proxy: it redirects the request to the Mercure container (often on
127.0.0.1:3000
). - The Symfony application publishes a message to Mercure (via the
HubInterface
and anUpdate
). - The Mercure hub then sends this message in real-time to all subscribed clients on the relevant topic (via the SSE connection opened initially).
3. Mercure Installation and Configuration
3.1. Docker Compose Configuration
Create a folder where you want to manage the Mercure server (e.g., 'mercure_habeuk'), navigate to this folder and create the Mercure configuration file (docker-compose.yml).
# compose.yaml
services:
mercure:
image: dunglas/mercure
restart: unless-stopped
container_name: mercure_habeuk
environment:
# Au niveau de mercure on ecoute sur le port 80.( mercure est dans un docker ).
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_PUBLISHER_JWT_KEY}"
MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_SUBSCRIBER_JWT_KEY}"
MERCURE_EXTRA_DIRECTIVES: |
cors_origins https://www.habeuk.com https://habeuk.com
anonymous
ports:
- "127.0.0.1:3000:80"
# configuration avec volumes nommés gerer par docker ( /var/lib/docker/volumes/... )
volumes:
- mercure_data:/data
- mercure_config:/config
volumes:
mercure_data:
mercure_config:
Explanations:
SERVER_NAME: ':80' → the number 80
indicates the port on which the Mercure server will listen inside the Docker container. Since Mercure runs in an isolated container, this port does not conflict with the host machine's port. We could have chosen another internal port if necessary.
MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_PUBLISHER_JWT_KEY}" and MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_SUBSCRIBER_JWT_KEY}" → these are the JWT keys used respectively to publish and subscribe to Mercure events. They are defined in the environment file mercure_habeuk/.env.
MERCURE_EXTRA_DIRECTIVES → allows adding additional instructions to the Mercure hub configuration (it's like a small internal configuration block).
cors_origins https://www.habeuk.com https://habeuk.com → defines the allowed origins (CORS) that can connect to the Mercure hub. This means that only web pages served from https://www.habeuk.com and https://habeuk.com will have permission to:
- subscribe to events (via EventSource
);
- and send publication requests (if JWT permissions allow).
anonymous → allows connections without JWT tokens for subscription. In other words, clients can subscribe to certain topics without needing to authenticate, which is useful for public feeds (such as messages visible to everyone or global notifications).
ports:127.0.0.1:3000:80
→ instructs Docker to redirect port 80 of the Mercure container to port 3000 on the host machine, but only accessible from 127.0.0.1 (localhost).
In other words: - The Mercure service listens on port 80
inside the container. - This port is exposed locally on port 3000
of the host machine. - The address 127.0.0.1
limits access to the local machine only (no one can reach port 3000 from outside).
3.2. Starting the Service
Install and start Mercure
docker compose up -d
Stop and restart the service
docker compose down && docker compose up -d
3.3. Testing the Mercure Service
First, we check if the Mercure server is running on the defined IP address and port. (directly on the VPS)
curl http://127.0.0.1:3000/.well-known/mercure?topic=test
If everything is correct, the command should open an SSE connection.
We want the Mercure server to be accessible via the subdomain messages.habeuk.com.
Modify the /etc/hosts file by adding the following:
127.0.0.1 messages.habeuk.com
Then perform another test:
curl http://messages.habeuk.com:3000/.well-known/mercure?topic=test
If everything is correct, the command should open an SSE connection.
4. Apache and SSL Configuration
We want a clean address, meaning messages.habeuk.com
without the port number. Apache will intervene to transfer requests received on messages.habeuk.com
to the address 127.0.0.1:3000
transparently for the user.
We need to add a virtual host in /etc/apache2/sites-available/messages.habeuk.com.conf:
<VirtualHost *:80>
ServerName messages.habeuk.com
# Proxy all HTTP requests to Mercure (port 3000)
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
#
ErrorLog /var/www/habeuk/logs/error-mercure.log
CustomLog /var/www/habeuk/logs/access-mercure.log combined
</VirtualHost>
Activation of Apache 2 modules:
sudo a2enmod proxy
sudo a2enmod proxy_http
sudo a2enmod headers
sudo a2enmod rewrite
Activation of the virtual host:
sudo a2ensite messages.habeuk.com.conf
Restart Apache 2:
sudo systemctl restart apache2
Then, perform a test:
curl http://messages.habeuk.com/.well-known/mercure?topic=test
If everything is correct, the command should open an SSE connection.
You can also check directly in your browser: http://messages.habeuk.com
should display the Mercure homepage.
Enable SSL on your subdomain messages.habeuk.com
by following this documentation.
Update the virtual host file /etc/apache2/sites-available/messages.habeuk.com.conf:
<VirtualHost *:80>
ServerName messages.habeuk.com
Redirect permanent / https://messages.habeuk.com/
</VirtualHost>
<VirtualHost *:443>
ServerName messages.habeuk.com
# Proxy toutes les requêtes HTTPS vers Mercure (port 3000)
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
#
ErrorLog /var/www/habeuk/logs/error-mercure.log
CustomLog /var/www/habeuk/logs/access-mercure.log combined
# Activation SSL
SSLEngine On
SSLCertificateFile /etc/letsencrypt/live/habeuk.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/habeuk.com/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
</VirtualHost>
Finally, repeat the previous test, this time with HTTPS:https://messages.habeuk.com
5. JWT Keys Generation
Mercure's security relies on JWT (JSON Web Tokens) signed with cryptographic keys. Generate sufficiently long random secrets using the following command:
openssl rand -base64 32
Then create the mercure_habeuk/.env file and add the following content:
MERCURE_PUBLISHER_JWT_KEY=your-secure-publisher-key-from-openssl
MERCURE_SUBSCRIBER_JWT_KEY=your-secure-subscriber-key-from-openssl
6. Backend Implementation Example (via Symfony)
<?php
// src/Service/ChatService.php
namespace App\Service;
use App\Entity\ {
Notification,
NotificationReceipt
};
use App\Enum\NotificationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
class SendNotificationByMercureService {
public function __construct(private EntityManagerInterface $em, private HubInterface $hub, private SerializerInterface $serializer, private LoggerInterface $logger) {
}
public function publishViaMercure(Notification $notification): void {
$receipts = $notification->getReceipts();
$topics = [];
if ($receipts->count() > 0) {
foreach ($receipts->getValues() as $receipt) {
/**
*
* @var NotificationReceipt $receipt
*/
$topics[] = 'notification/' . $receipt->getRecipient()->getId();
}
try {
$result = $this->serializer->serialize($notification, 'json',
[
AbstractNormalizer::GROUPS => [
'notification:read'
],
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => function ($object) {
return $object->getId();
}
]);
$type = $notification->getType() == NotificationType::NEW_MESSAGE ? "new_message" : "notification";
$update = new Update($topics, $result, true, null, $type);
$this->hub->publish($update);
}
catch (\Exception $e) {
$this->logger->error($e->getMessage());
}
}
}
}
7. Frontend Implementation Example
// mercureClient.js
class MercureClient {
constructor(url) {
this.eventSource = new EventSource(url, { withCredentials: true });
this.eventSource.onerror = (err) => {
console.error("Mercure connection error:", err);
};
}
build() {
const events = ["chat", "read_message", "notification", "new_message"];
events.forEach((event_name) => {
this.Listerner(event_name);
});
this.eventSource.onmessage = (event) => {
console.log("MercureClient event onmessage : ", event);
alert("ezsd");
};
}
Listerner(event_name) {
this.eventSource.addEventListener(event_name, (event) => {
const data = JSON.parse(event.data);
this.dispatchCustomEvent(event_name, data);
});
}
dispatchCustomEvent(event_name, data) {
console.log("dispatchCustomEvent event_name : ", event_name, data);
document.dispatchEvent(
new CustomEvent(`mercure-${event_name}`, {
detail: data,
})
);
}
}
export default MercureClient;