Deployment guide

Centrifugo on a VPS: Self-Hosted Real-Time Server

Deploy on a VPS Cloud →

Tutorial

Centrifugo on a VPS: Self-Hosted Real-Time Server

Self-hosting7 min read3 steps

Showing a notification the moment an order is paid, running a chat, updating a dashboard without reloading the page: all of this needs a connection kept open between the server and the browser. Services such as Pusher or Ably do it very well, but they bill every connection and every message, and your data goes through them. Centrifugo is an open-source real-time messaging server (Apache-2.0 license), written in Go, that does the same job on your own VPS. This guide installs it with Docker Compose, puts it behind nginx over HTTPS, then shows how to publish from your backend and connect a browser with a JWT.

Contents· How Centrifugo works1/13
  1. 01How Centrifugo works
  2. 02What Centrifugo brings
  3. 03Centrifugo versus a hosted service
  4. 04Prerequisites
  5. 05Install Centrifugo with Docker Compose
  6. 06Never publish port 8000 on the public IP
  7. 07Put Centrifugo behind nginx
  8. 08Publish from your backend
  9. 09Sign connection tokens
  10. 10What a client is allowed to do
  11. 11Connect a browser
  12. 12The admin console
  13. 13Scaling and updates

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-Key header, 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 VPSPusher, Ably
BillingVPS price, no message limitBy connections and messages
Where data goesOn your serverThrough the provider
Client protocolWebSocket and official SDKsWebSocket and proprietary SDKs
AuthenticationJWT signed by your backendService-specific signature
OperationsYour responsibilityHandled 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

  1. Generate the secrets

    Create the application directory and a .env file 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 .env
  2. Write the docker-compose.yml file

    The port is published on the loopback interface only: only nginx reaches it. Replace https://app.example.com with 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: 65535
  3. Start 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.

Your real-time server on your VPS

A ServOrbit Ubuntu 24.04 VPS with 1 GB of RAM is enough to start Centrifugo. One-click install from the Marketplace, HTTPS and WebSocket on your domain or on the VPS's free subdomain, password and keys generated for you.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab