Ahnii!
Mercure is a real-time push protocol built on server-sent events (SSE). It ships as a standalone binary that embeds its own Caddy server. If you already run Caddy as your web server, you now have two Caddy processes fighting over ports. This post covers how to deploy both on the same VPS using Ansible, with solutions for every gotcha that came up.
Prerequisites
- A VPS with Caddy already serving your sites
- Ansible for deployment automation
- The Mercure binary installed on the server
- A domain with DNS pointed at your VPS
Resolving the port conflict
Mercure's embedded Caddy wants to bind to port 443 and run its own admin API on port 2019. Your main Caddy already owns both. The fix is to disable auto-HTTPS on Mercure and bind it to a localhost-only port:
{
auto_https off
admin localhost:2039
}
http://localhost:3080 {
mercure {
publisher_jwt {env.MERCURE_JWT_SECRET}
subscriber_jwt {env.MERCURE_JWT_SECRET}
cors_origins https://minoo.live
publish_origins *
anonymous
}
respond /healthz 200
}
auto_https off prevents Mercure's Caddy from requesting certificates. admin localhost:2039 moves the admin API off port 2019 where your main Caddy is already listening. Mercure listens on localhost:3080 where only your main Caddy can reach it.
Pick a port that is not already in use on your server. Port 3080 is a safe default since common tools like Docker tend to claim ports in the 3000 range.
Proxying SSE through your main Caddy
Your main Caddy reverse-proxies the Mercure hub URL to the local Mercure instance. The critical detail is flush_interval -1, which tells Caddy to flush response bytes immediately instead of buffering:
@mercure_hub {
path /.well-known/mercure*
}
handle @mercure_hub {
reverse_proxy localhost:3080 {
flush_interval -1
header_up X-Forwarded-For {remote_host}
}
}
Without flush_interval -1, Caddy buffers the SSE stream and your clients never receive events. This is the single most common issue when proxying SSE through any reverse proxy.
Excluding SSE routes from gzip
Caddy's encode directive compresses responses. Compressed SSE streams break because the client cannot decompress a stream that never ends. Exclude the Mercure routes from compression:
@not_mercure {
not path /.well-known/mercure*
}
encode @not_mercure gzip zstd
This applies gzip to everything except the SSE endpoint. If you do not have Mercure configured, the template falls back to the simpler encode gzip zstd without the matcher.
JWT secret configuration
Both the publisher (your PHP app) and the subscriber (your frontend) authenticate with Mercure using JWTs signed with a shared secret. Store the secret in Ansible Vault:
# defaults/main.yml
mercure_jwt_secret: "{{ vault_mercure_jwt_secret }}"
The environment file that Mercure reads at startup is minimal:
MERCURE_JWT_SECRET=your-secret-here
Both publisher_jwt and subscriber_jwt in the Caddyfile reference this same environment variable. Your PHP publisher generates JWTs with the same secret:
$header = base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64UrlEncode(json_encode([
'mercure' => ['publish' => ['*']],
'iat' => time(),
'exp' => time() + 3600,
]));
$signature = base64UrlEncode(
hash_hmac('sha256', "{$header}.{$payload}", $jwtSecret, true)
);
return "{$header}.{$payload}.{$signature}";
This generates a JWT that grants publish access to all topics. The exp claim means each token is valid for one hour.
BoltDB data directory
Mercure uses BoltDB for persistence. The Ansible role creates the data directory under the deploy user's home:
- name: Create Mercure BoltDB data directory
ansible.builtin.file:
path: "/home/{{ deploy_user }}/.local/share/caddy"
state: directory
mode: "0755"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
Mercure's embedded Caddy writes its BoltDB files here. If the directory does not exist, Mercure fails silently on startup and events are not persisted.
Running as a systemd service
The Ansible role deploys a systemd unit that reads the environment file:
[Service]
Type=simple
ExecStart=/usr/local/bin/mercure run --config /etc/mercure/Caddyfile
EnvironmentFile=/etc/mercure/mercure.env
User={{ deploy_user }}
Restart=always
Set the User to your deploy user so Mercure can access its BoltDB directory.
Health check
The Mercure Caddyfile includes a /healthz endpoint that returns 200. Use this for monitoring:
curl -s -o /dev/null -w "%{http_code}" http://localhost:3080/healthz
If you get anything other than 200, Mercure is down. Wire this into your monitoring tool of choice.
Baamaapii
Top comments (0)