# Production setup — Reverb (real-time WebSocket server)

**Scope:** how to run **Laravel Reverb** (the self-hosted WebSocket server that pushes live inbox updates) in production — process, TLS reverse-proxy, the env split, and how to **reload it on deploy**.

> This is the *production* runbook. For **how real-time actually works** (the event → queue → Reverb → browser path, the channels, the 6 s poll fallback) and **local dev** (Laragon + WSL), read [realtime.md](/docs/modules_handbook/manage/messages/whatsapp/realtime.md) first — this doc assumes it.

> **First, flip the master toggle.** Live broadcasting is gated by **`WHATSAPP_REALTIME` (off by default)** — each WhatsApp broadcast event's `broadcastWhen()` returns `config('whatsapp.realtime')`. Set **`WHATSAPP_REALTIME=true`** in the prod `.env` (then `php artisan config:clear`) or the three processes below run but nothing is ever broadcast. Leave it **off** on any server without Reverb so failed broadcast jobs never accumulate.

## The three things that must all be true

Real-time only works when `WHATSAPP_REALTIME=true` **and all** of these run; any one down (or the toggle off) ⇒ the inbox silently falls back to its 6 s poll:

1. **Horizon** is running — broadcast events (`ShouldBroadcast`) are dispatched as **queued jobs**, so a worker must deliver them. See [horizon.md](/docs/modules_handbook/production-setup/horizon.md). *(This is the most-missed one: "sends work but nothing is live" ⇒ Horizon is fine, Reverb is fine, but check both are up — the broadcast `BroadcastEvent` job is the link.)*
2. **Reverb** (`php artisan reverb:start`) is running, kept alive by Supervisor.
3. **Nginx** terminates TLS and proxies the WebSocket to Reverb, and the **browser** bundle was built with the **public** `VITE_REVERB_*` values.

## Required environment (`.env`) — the prod split (most important)

Locally everything is `127.0.0.1:8080` and `VITE_REVERB_*` just mirrors `REVERB_*`. **In production those two sides MUST differ** — the PHP broadcaster talks to Reverb *internally*, the browser talks to it *publicly over `wss://`*:

```dotenv
BROADCAST_CONNECTION=reverb

# Reverb app credentials (key is public, secret is server-only)
REVERB_APP_ID=petav3
REVERB_APP_KEY=…
REVERB_APP_SECRET=…

# (1) where the Reverb daemon BINDS — internal interface/port
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080

# (2) where the SERVER-SIDE broadcaster (Horizon worker) POSTs events — keep it
#     internal & plaintext (no TLS/proxy round-trip); this is NOT public.
REVERB_HOST=127.0.0.1
REVERB_PORT=8080
REVERB_SCHEME=http

# (3) where the BROWSER connects — the PUBLIC domain over wss (443). These are
#     baked into the JS bundle at `npm run build`. DO NOT mirror REVERB_* here.
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST=ws.yourdomain.com      # public host Nginx proxies to Reverb (a subdomain is cleanest)
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https                 # → the browser uses wss://
```

> ⚠️ The local `.env` has `VITE_REVERB_HOST="${REVERB_HOST}"` etc. (mirroring). **Override those in the production `.env`** to the public host / `443` / `https` — otherwise the browser tries to open `ws://127.0.0.1:8080` from your live site and never connects. `REVERB_SCHEME=http` (server side) is correct even on an HTTPS site: the worker→Reverb POST is a local, internal call.

## Run Reverb with Supervisor

`/etc/supervisor/conf.d/reverb.conf`:

```ini
[program:reverb]
process_name=%(program_name)s
command=php /var/www/petav3/artisan reverb:start --host=0.0.0.0 --port=8080
directory=/var/www/petav3
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/petav3/storage/logs/reverb.log
stopwaitsecs=10
numprocs=1               ; one server (unless you enable scaling — see below)
```

```bash
sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start reverb
```

> On **Laravel Forge**: add a **Daemon** with command `php artisan reverb:start --host=0.0.0.0 --port=8080` (Forge manages it with Supervisor). 8080 stays internal — **do not** open it in the firewall; only 443 is public.

## Nginx — TLS + WebSocket proxy

The browser must reach Reverb over `wss://` on 443; Nginx terminates TLS and upgrades the connection to the local Reverb on `:8080`. A **dedicated subdomain** avoids any path collision with the SPA:

```nginx
# ws.yourdomain.com  → Laravel Reverb
server {
    listen 443 ssl http2;
    server_name ws.yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/ws.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ws.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host               $host;
        proxy_set_header X-Forwarded-For    $remote_addr;
        proxy_set_header X-Forwarded-Proto  $scheme;
        proxy_set_header Upgrade            $http_upgrade;   # the WebSocket upgrade
        proxy_set_header Connection         "Upgrade";
        proxy_read_timeout 3600s;                            # keep long-lived sockets open
    }
}
```

Then `VITE_REVERB_HOST=ws.yourdomain.com`, `VITE_REVERB_PORT=443`. (Path-based on the main domain also works — proxy `location ^~ /app { … }` — but `/app` must not clash with an app route; the subdomain is simpler.) The `Upgrade`/`Connection` headers are the critical bit — without them the handshake fails with `502`/`400`.

## Deploying — reload Reverb with new code

Like Horizon, Reverb is a **long-running PHP process** that holds compiled code — it won't pick up a deploy until restarted. After building assets and caching config:

```bash
npm ci && npm run build          # bakes the public VITE_REVERB_* into the bundle (set them FIRST)
php artisan config:cache
php artisan reverb:restart        # gracefully restarts running Reverb servers (Supervisor relaunches)
```

`php artisan reverb:restart` signals the running server to finish and exit; Supervisor (`autorestart=true`) starts it again on the new code. Connected browsers **auto-reconnect** (Echo/pusher-js retries), so clients blip and recover. Put `reverb:restart` next to `horizon:terminate` in the deploy script (see [horizon.md](/docs/modules_handbook/production-setup/horizon.md) for the full sequence).

> **Asset rebuild gotcha:** `VITE_REVERB_*` are compiled into the JS at `npm run build`. Changing them in `.env` and only restarting Reverb does nothing — you must **rebuild** the frontend (and bust any CDN/browser cache).

## Scaling (only if you outgrow one process/server)

A single Reverb process handles thousands of connections — fine for one customer. To run **multiple** Reverb instances (or multiple app servers) sharing channels, enable Redis pub/sub fan-out:

```dotenv
REVERB_SCALING_ENABLED=true       # config/reverb.php → uses the REDIS_* connection
```

Leave it `false` for the single-server setup.

## Verify & triage

- **Browser console (dev build):** `[echo] connecting wss://ws.yourdomain.com:443 … -> connected — stable ✅`. The `[echo]` boot line prints the exact resolved target — if it shows `ws://127.0.0.1…` on the live site, the bundle was built with the local (mirrored) `VITE_REVERB_*` → rebuild with the prod values.
- **Daemon up?** `supervisorctl status reverb`; `curl -I http://127.0.0.1:8080` from the server.

| Symptom | Cause → fix |
|---|---|
| `WebSocket connection to 'wss://…' failed` (502 / handshake) | Nginx missing the `Upgrade`/`Connection "Upgrade"` headers, or Reverb daemon down. |
| Browser connects, **nothing arrives** | The broadcast jobs aren't delivered → **Horizon not running** (or Redis down). The socket being up ≠ events flowing. |
| Worker log: `cURL … Failed to connect to 127.0.0.1 port 8080: Connection refused` | **Reverb daemon is down** — `supervisorctl start reverb`. (This is exactly the broadcaster→Reverb POST failing.) |
| Mixed-content / `wss` forced wrongly | `VITE_REVERB_SCHEME` not `https` **in the built bundle** → rebuild. |
| `/broadcasting/auth` 403 | The signed-in user isn't an admin ([routes/channels.php](/routes/channels.php)) — private channels are admin-gated. |
| Everything dead but inbox updates ~6 s | The fallback poll — fix the socket per the rows above (or `WHATSAPP_REALTIME` is off); nothing is *broken*, just not live. |

More (incl. the IPv4/IPv6 `localhost` gotcha) in [realtime.md](/docs/modules_handbook/manage/messages/whatsapp/realtime.md)'s Troubleshooting.

## Related files
- [config/reverb.php](/config/reverb.php) — the Reverb server (bind host/port) + app credentials + scaling.
- [config/broadcasting.php](/config/broadcasting.php) — the `reverb` broadcaster the server-side POST uses (`REVERB_HOST`/`PORT`/`SCHEME`).
- [resources/js/echo.js](/resources/js/echo.js) — the browser client (env-driven; no-ops without `VITE_REVERB_APP_KEY`).
- [routes/channels.php](/routes/channels.php) — private-channel authorization (admins).
- [horizon.md](/docs/modules_handbook/production-setup/horizon.md) — the queue worker that **delivers** broadcasts · [realtime.md](/docs/modules_handbook/manage/messages/whatsapp/realtime.md) — the full real-time flow + local dev.
