Docker
The official image is dunglas/mercure. Built on top of the Caddy image, so anything Caddy's image supports works here too.
#Run the Mercure Docker image
Production mode:
# Run the Mercure Docker Image
docker run \
-e MERCURE_PUBLISHER_JWT_KEY='!ChangeThisMercureHubJWTSecretKey!' \
-e MERCURE_SUBSCRIBER_JWT_KEY='!ChangeThisMercureHubJWTSecretKey!' \
-p 80:80 -p 443:443 \
dunglas/mercure
The hub binds to :80 and :443. Caddy issues a Let's Encrypt cert for SERVER_NAME automatically. Don't set SERVER_NAME=localhost in production: it can't be issued a public certificate.
Behind a reverse proxy that handles TLS, set SERVER_NAME=:80 and skip :443:
# Run the Mercure Docker Image
docker run \
-e SERVER_NAME=':80' \
-e MERCURE_PUBLISHER_JWT_KEY='...' \
-e MERCURE_SUBSCRIBER_JWT_KEY='...' \
-p 80:80 \
dunglas/mercure
#Mercure Docker development mode
# Mercure Docker Development Mode
docker run \
-e MERCURE_PUBLISHER_JWT_KEY='!ChangeThisMercureHubJWTSecretKey!' \
-e MERCURE_SUBSCRIBER_JWT_KEY='!ChangeThisMercureHubJWTSecretKey!' \
-p 80:80 -p 443:443 \
dunglas/mercure caddy run --config /etc/caddy/dev.Caddyfile
The dev Caddyfile turns on:
- the debug UI at
/.well-known/mercure/ui/, - anonymous subscribers,
- demo endpoints,
- a permissive CORS config (
cors_origins *).
Don't expose this to the internet.
#Compose
# compose.yaml
services:
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
MERCURE_PUBLISHER_JWT_KEY: "!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY: "!ChangeThisMercureHubJWTSecretKey!"
ports:
- "80:80"
- "443:443"
volumes:
- mercure_data:/data
- mercure_config:/config
volumes:
mercure_data:
mercure_config:
| Volume | What's in it |
|---|---|
/data | BoltDB history (mercure.db) and Caddy data (autosave, cert cache). |
/config | Caddy autosaved configuration. |
Persist both. Losing /data means losing replay history; losing /config means re-issuing certificates on next boot.
#Mercure Docker healthcheck
The image's built-in healthcheck queries localhost:2019/mercure/health/ready: the transport-aware readiness endpoint, not just "is the process up."
For Compose, override or extend it:
# Mercure Docker Healthcheck
services:
mercure:
# ...
healthcheck:
test:
[
"CMD",
"wget",
"-q",
"--spider",
"http://localhost:2019/mercure/health/ready",
]
timeout: 5s
retries: 5
start_period: 60s
The start_period matters: BoltDB takes a moment to open on first boot, so the first probe may fail; treat that as "not unhealthy" for the first minute.
#Rootless Mercure on Docker
The image runs as root by default. Recent Docker (20.10+) sets net.ipv4.ip_unprivileged_port_start=0 inside the container, so an unprivileged process can still bind 80/443 directly.
To run as a non-root user:
# compose.yaml
services:
mercure:
image: dunglas/mercure
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
environment:
MERCURE_PUBLISHER_JWT_KEY: "!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY: "!ChangeThisMercureHubJWTSecretKey!"
ports:
- "80:80"
- "443:443"
volumes:
- mercure_data:/data
- mercure_config:/config
The volumes must be writable by UID 1000. For fresh named volumes, set ownership once:
# Rootless Mercure on Docker
docker run --rm -v mercure_data:/data -v mercure_config:/config alpine chown 1000:1000 /data /config
For bind mounts, chown 1000:1000 the host directory.
#Custom Caddyfile
Ship your own Caddyfile:
# compose.yaml
services:
mercure:
image: dunglas/mercure
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- mercure_data:/data
- mercure_config:/config
A custom Caddyfile is the right move once you need:
- Multiple sites or hostnames on the same hub.
- Reverse proxying alongside the hub (Caddy handling both).
- Per-route rate limiting, request transformation, or custom auth.
- Reading secrets from files (
{file./run/secrets/jwt_key}) instead of environment variables.
#Mercure hub Docker logs
Caddy logs to stdout in JSON by default. Pipe to whatever your platform expects (Loki, Datadog, CloudWatch). Useful fields:
mercure.subscribers_*: connection lifecycle.mercure.update_*: publish events.caddy.error_*: TLS, listener, and transport errors.
Bump verbosity with GLOBAL_OPTIONS=debug (don't leave it on in prod: it logs update payloads).
#Mercure Docker image variants
dunglas/mercure: Alpine-based, statically linked.dunglas/mercure:<version>: pin to a specific release.- Self-Hosted ships its own image with the multi-node transports: see High availability.
#Behind a reverse proxy
If you're already running Traefik or NGINX, terminate TLS there and let the hub speak HTTP. See Reverse proxies.
#Next steps for Mercure on Docker
- Configuration: directives and env vars.
- Kubernetes: same image, Helm chart.
- Health monitoring: what the probes actually check.