How Centrifugo works
Centrifugo sits between your application and its users. Your backend — Laravel, Django, Express, Rails or anything else — keeps no connection open: it signs a token for each user, then publishes messages with a plain HTTP call. Centrifugo maintains the WebSocket connections of browsers and mobile apps, and delivers each message to the subscribers of the relevant channel.
This split has a concrete benefit: your application stays stateless and easy to deploy, and the real-time part fits in a single Go binary that uses a few tens of MB of RAM at rest.
What Centrifugo brings
- WebSocket connections — with official client SDKs for JavaScript, Dart/Flutter, Swift, Java/Android, Go and Python.
- Publish API — an HTTP POST with the
X-API-Keyheader, from any language. - JWT tokens — your backend decides who connects and to which channels, Centrifugo checks the signature.
- Server-side subscriptions — a user's channel list travels inside their token.
- Admin console — node status, connected clients and API commands from the browser.
- Careful defaults — no anonymous connection, no publishing from the browser without explicit permission.
Centrifugo versus a hosted service
Scroll the table
| Centrifugo on a VPS | Pusher, Ably | |
|---|---|---|
| Billing | VPS price, no message limit | By connections and messages |
| Where data goes | On your server | Through the provider |
| Client protocol | WebSocket and official SDKs | WebSocket and proprietary SDKs |
| Authentication | JWT signed by your backend | Service-specific signature |
| Operations | Your responsibility | Handled by the provider |
Prerequisites
An Ubuntu 24.04 VPS with 1 GB of RAM and 10 GB of disk, Docker and the Compose plugin installed, and a subdomain pointing to the VPS, for example rt.example.com. HTTPS is not optional: a site served over HTTPS can only open wss:// connections, and your API key must never travel in clear text.
Install Centrifugo with Docker Compose
Generate the secrets
Create the application directory and a
.envfile holding four distinct secrets: the console password, the key that signs its sessions, the server API key and the HMAC key for client tokens.mkdir -p /opt/centrifugo && cd /opt/centrifugo { echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" echo "SECRET_KEY=$(openssl rand -hex 24)" echo "ENCRYPTION_KEY=$(openssl rand -hex 16)" echo "ENCRYPTION_KEY_HEX64=$(openssl rand -hex 32)" } > .env chmod 600 .envWrite the docker-compose.yml file
The port is published on the loopback interface only: only nginx reaches it. Replace
https://app.example.comwith your site's address.services: centrifugo: image: centrifugo/centrifugo:v6.10.2 restart: unless-stopped command: ["centrifugo"] ports: - "127.0.0.1:8000:8000" environment: CENTRIFUGO_ADMIN_ENABLED: "true" CENTRIFUGO_ADMIN_PASSWORD: "${ADMIN_PASSWORD}" CENTRIFUGO_ADMIN_SECRET: "${SECRET_KEY}" CENTRIFUGO_HTTP_API_KEY: "${ENCRYPTION_KEY}" CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY: "${ENCRYPTION_KEY_HEX64}" CENTRIFUGO_CLIENT_ALLOWED_ORIGINS: "https://app.example.com" CENTRIFUGO_HEALTH_ENABLED: "true" ulimits: nofile: soft: 65535 hard: 65535Start and check
Start the container and query the health endpoint; it should answer
{}.docker compose up -d curl -fsS http://127.0.0.1:8000/health
Never publish port 8000 on the public IP
The same port serves client connections, the server API and the admin console. Keep it on 127.0.0.1 and always go through the HTTPS reverse proxy. And never put ENCRYPTION_KEY or ENCRYPTION_KEY_HEX64 in code sent to the browser: the first lets anyone publish to any channel, the second lets anyone forge tokens for any user.
Put Centrifugo behind nginx
A WebSocket connection starts with an HTTP request asking to switch protocols. nginx must forward that request, and must not cut a connection that stays quiet for a few minutes. In the server block of your subdomain, with a Let's Encrypt certificate:
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
}After nginx -t and a reload, https://rt.example.com shows the console login screen.
Publish from your backend
Publishing means sending a JSON object to a channel. Any language that can make an HTTP request will do:
curl -X POST https://rt.example.com/api/publish \
-H "X-API-Key: $ENCRYPTION_KEY" \
-d '{"channel": "commandes", "data": {"id": 1042, "statut": "payee"}}'The {"result":{}} response confirms the message was accepted. Without the X-API-Key header, or with a wrong key, Centrifugo answers 401.
Sign connection tokens
A browser only connects with a JWT signed by your backend with ENCRYPTION_KEY_HEX64. The sub claim carries the user ID, exp the expiry date, and channels the list of channels the user is subscribed to automatically. Example in Python:
import time, jwt # pip install pyjwt
def centrifugo_token(user_id: str) -> str:
claims = {"sub": user_id, "exp": int(time.time()) + 3600, "channels": ["commandes"]}
return jwt.encode(claims, ENCRYPTION_KEY_HEX64, algorithm="HS256")Expose this function behind an authenticated route of your application: only a user logged in to your app should be able to get a token.
What a client is allowed to do
The settings of this installation are deliberately closed. A browser can neither subscribe to a channel on its own nor publish to it: it only receives the channels listed in the channels claim of its token. Your backend decides, each time it issues a token, who listens to what — a customer only sees their own orders, a support agent only their team's queue.
For a chat, publishing therefore goes through your backend as well: the browser sends the message to your application, which validates it, stores it in the database, then publishes it through the API. This detour costs a few milliseconds and keeps you in control of moderation and history.
Finally, give tokens a short lifetime, one hour for example. Instead of passing a fixed token, let the SDK ask your application for a new one when it expires:
const client = new Centrifuge('wss://rt.example.com/connection/websocket', {
getToken: async () => {
const res = await fetch('/realtime/token', { credentials: 'include' });
return (await res.json()).token;
},
});The /realtime/token route belongs to your application; it checks the user's session before signing anything.
Connect a browser
On the client side, the official centrifuge SDK (npm) opens the connection and delivers the messages:
import { Centrifuge } from 'centrifuge';
const client = new Centrifuge('wss://rt.example.com/connection/websocket', {
token: tokenFromYourBackend,
});
client.on('publication', (ctx) => console.log(ctx.channel, ctx.data));
client.connect();If the connection fails, check the logs first (docker compose logs centrifugo). The message “request Origin is not authorized” means your site's origin is missing from CENTRIFUGO_CLIENT_ALLOWED_ORIGINS; “invalid token” points to a badly signed or expired token.
The admin console
Open https://rt.example.com and enter ADMIN_PASSWORD: the console does not ask for a username. It shows the node version, the number of connected clients and users, and lets you send API commands — publish a test message, list active channels — without writing code. It is the fastest way to check that a message goes out before looking for the fault in the browser.
Scaling and updates
This guide uses the memory engine: a single node, with no dependency. It suits most applications, and a modest VPS already holds a large number of simultaneous connections, hence the raised open-file limit in docker-compose.yml. The day one node is no longer enough, Centrifugo can spread the load across several nodes linked by Redis. To update, change the image tag after reading the release notes, then run docker compose pull && docker compose up -d; connections come back on their own, as the client SDKs reconnect automatically.