tooling

Self-hosting Sockudo: dropping Pusher with zero per-message cost

The full story of replacing Pusher with Sockudo, why protocol compatibility made this migration a config change, and what a year of running your own WebSocket server actually looks like.

We replaced Pusher with a self-hosted Sockudo instance about a year ago. It’s been running in production since then with no incidents that trace back to the move, and the per-message cost is now zero.

The reason this migration was easier than you’d expect: Sockudo speaks the Pusher protocol. Not “mostly compatible”, protocol-level compatible. Your Laravel broadcasting config, your pusher-js client, your channel subscriptions, none of it changes. You change one config block and the application keeps working.

That’s the thing I want to explain in detail, because it changes the entire risk calculus of the decision.

Why Pusher specifically

Plato, PickYourTrail’s internal CRM, has real-time features baked in. Chat between sellers and customers runs over WebSocket. Typing indicators, presence (who’s online), read receipts, live itinerary updates, all of it depends on pushed events rather than polling.

We were using Pusher through Laravel’s broadcasting layer. The setup was standard: Laravel Echo on the frontend, pusher-js as the client, laravel-echo-server or direct Pusher SDK calls on the backend to trigger events. It worked. The developer experience was good.

The cost was the friction. Not catastrophic, but recurring. Pusher’s pricing is message-based, and once you have chat with meaningful engagement volume plus internal tooling notifications plus presence channels across a sales team, “per message” starts to feel like a strange way to price something that’s just infrastructure.

The moment it became worth addressing was when we started thinking about adding more real-time features. Every new feature was a conversation about message volume first, product design second. That’s an annoying constraint to build around.

Finding Sockudo

Sockudo is a WebSocket server written in Rust that implements the Pusher protocol and HTTP API. It’s not trying to be a general WebSocket framework, it’s specifically a drop-in Pusher replacement.

The critical thing it implements: the Pusher HTTP API (the server-to-client broadcast endpoints), the Pusher WebSocket protocol (what pusher-js speaks), and the webhook system (if you use it). The authentication flow for private channels works the same way, your Laravel backend still handles /broadcasting/auth and Sockudo verifies it.

That’s different from “it has similar features.” Protocol compatibility means the existing code doesn’t need to change.

The migration

The entire server-side change was this:

// config/broadcasting.php, before
'pusher' => [
    'driver' => 'pusher',
    'key'    => env('PUSHER_APP_KEY'),
    'secret' => env('PUSHER_APP_SECRET'),
    'app_id' => env('PUSHER_APP_ID'),
    'options' => [
        'host'   => 'api.pusherapp.com',
        'port'   => 443,
        'scheme' => 'https',
    ],
],

// config/broadcasting.php, after (Sockudo)
'pusher' => [
    'driver' => 'pusher',
    'key'    => env('PUSHER_APP_KEY'),
    'secret' => env('PUSHER_APP_SECRET'),
    'app_id' => env('PUSHER_APP_ID'),
    'options' => [
        'host'   => env('SOCKUDO_HOST', 'sockudo.internal'),
        'port'   => 6001,
        'scheme' => 'http',
        'useTLS' => false,
    ],
],

The driver is still pusher. The credentials are the same format, we just define our own PUSHER_APP_KEY, PUSHER_APP_SECRET, and PUSHER_APP_ID values in the .env and configure Sockudo to expect those same values. The only thing that actually changed is the host URL and port.

The frontend JavaScript:

// Echo initialization, unchanged
const echo = new Echo({
    broadcaster: 'pusher',
    key: process.env.PUSHER_APP_KEY,
    wsHost: process.env.SOCKUDO_HOST,
    wsPort: 6001,
    forceTLS: false,
    enabledTransports: ['ws', 'wss'],
});

pusher-js doesn’t know or care whether it’s talking to Pusher’s infrastructure or ours. It’s speaking the Pusher WebSocket protocol. Sockudo speaks the Pusher WebSocket protocol. The client works.

The channel subscription code in every component, the presence channel listeners, the typing indicators, all unchanged. Zero client-side code changes.

Deploying Sockudo

We run Sockudo as a Kubernetes deployment behind an internal service. The Sockudo config lives in a ConfigMap:

# sockudo config (sockudo.json)
{
  "apps": [
    {
      "id": "app-id",
      "key": "app-key",
      "secret": "app-secret",
      "maxConnections": -1,
      "enableClientMessages": true,
      "enableUserAuthentication": false,
      "webhooks": []
    }
  ],
  "server": {
    "host": "0.0.0.0",
    "port": 6001
  },
  "adapter": {
    "driver": "redis",
    "redis": {
      "host": "redis.internal",
      "port": 6379
    }
  }
}

The adapter is set to Redis so that if we ever scale Sockudo horizontally, connections on different pods can communicate. With the memory adapter (the default), a client connected to pod A can’t receive a message published to pod B, an issue that doesn’t exist with a single pod but becomes a problem as soon as you scale.

The Kubernetes deployment is a standard Deployment with resource limits, a readiness probe on the HTTP API endpoint, and a NodePort service that exposes port 6001 internally. We terminate TLS at the Traefik ingress level, Sockudo sees plain HTTP/WS internally, HTTPS/WSS externally.

What we gained

The direct gain is obvious: no per-message billing. We can add real-time features without a cost conversation first.

The less obvious gain: observability. With Pusher, WebSocket connection data lives in Pusher’s dashboard. We can see counts and trends but we can’t correlate them with our own application metrics. With Sockudo, the connection data is in our own Prometheus metrics. We can see connection counts per channel, message rates, error rates, and correlate against application events. When something behaves unexpectedly on the real-time layer, we can debug it rather than just watching dashboards and filing support tickets.

The first time we had to diagnose a presence channel issue, users showing as online when they weren’t, we could actually look at the WebSocket server logs. That’s not a luxury you have with managed infrastructure.

What we genuinely lost

The honest part of any self-hosting story is being clear about what you gave up.

Pusher has a multi-region network. If a user is in Europe and your Sockudo instance is in us-east, their WebSocket connection has more latency than it would with a managed service that has an EU endpoint. For Plato, an internal tool used by the PickYourTrail team, almost all based in Chennai, this doesn’t matter. For a consumer app with international users, it might.

Pusher also handles SLA guarantees, DDoS protection, and a lot of the operational complexity that comes with running a service at serious scale. If your WebSocket traffic is massive and your team is small, managed services exist for a reason.

We’re running at a scale where one well-configured Sockudo pod handles the load comfortably. The operational overhead is real but manageable: keeping the container image current, monitoring the Redis adapter, occasionally adjusting resource limits as traffic grows.

A year in

Nothing has broken in a way that traces back to Sockudo specifically. The typing indicators work. The presence channels work. The live itinerary updates work. The migration is invisible to users in exactly the way a good infrastructure change should be.

The per-message billing is gone. The observability is ours. The decision to add new real-time features no longer involves a cost calculation.

The one thing I’d do differently: I should have set up the Redis adapter from the start rather than starting with the memory adapter in staging and needing to reconfigure when we wanted to test multi-pod scenarios. The Redis adapter configuration is straightforward, it was just an unnecessary step that could have been avoided with a bit more forethought in the initial setup.

Protocol compatibility was the thing that made this migration low-risk. When the self-hosted alternative speaks the same protocol as the managed service, the migration is a config change and a deployment. That’s the pattern I look for whenever we’re evaluating whether to self-host something.