# Production setup: the websocket, the cache and the opening request

What has to be configured and supervised for the back office to work the way it now does —
pushed to rather than polling, with one opening request and conditional reads.

Everything here is required. The dashboard degrades quietly rather than loudly when a piece
is missing, which is kinder to users and much harder to notice, so the failure modes are
spelled out beside each part.

---

## 1. The backend `.env`

Only the keys this behaviour depends on. Secrets are placeholders — generate your own and
keep them in the secrets store, never in the repository.

### Broadcasting

```dotenv
BROADCAST_CONNECTION=reverb

# What the Reverb PROCESS binds to. Local only: nginx is what the world talks to.
REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080

# What PHP and BROWSERS connect to — the public address, through TLS.
REVERB_HOST=api.kapitano.shop
REVERB_PORT=443
REVERB_SCHEME=https

# `php artisan reverb:install` generates a set. The KEY reaches browsers and is public by
# design; the SECRET and the ID must not leave the server.
REVERB_APP_ID=<generated>
REVERB_APP_KEY=<generated>
REVERB_APP_SECRET=<generated>
```

> **These are two different pairs and mixing them is the classic failure.** `REVERB_SERVER_*`
> is the bind address; `REVERB_HOST`/`REVERB_PORT`/`REVERB_SCHEME` is the address everything
> *else* dials, including PHP when it publishes an event. Set the first to a public interface
> and you have exposed the websocket server directly; set the second to `localhost` and every
> browser is refused.

**If it is wrong:** the dashboard opens, shows data, and never updates. No error appears
anywhere a user would look.

### Cache — must be Redis

```dotenv
CACHE_STORE=redis

# predis is a pure-PHP client and is already a dependency, so this works with no extension.
# phpredis (the C extension) is faster and worth installing, but only change this once
# `php -m | grep redis` actually lists it.
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=<set one, and bind Redis to localhost>
```

Not a preference. The file store serialises concurrent requests on disk locks, and it cannot
do tagged caching — which is why several repositories carry comments explaining that they do
not cache at all. Redis removes both limits.

**If Redis is down:** requests fail rather than slow down, because the cache is now on the
critical path. Supervise it like the database.

### Queue — Redis, not the database

```dotenv
QUEUE_CONNECTION=redis
```

Every broadcast is a queued job. On the `database` driver each worker poll is a write-locking
query against one table, and the table the dashboard's liveliness depends on is then the
busiest table you own. Redis is the right shape for a queue; you are already running it for
the cache.

**If no worker is running:** the socket connects, the dashboard looks healthy, and nothing
ever moves. This is the single most likely cause of "realtime stopped working".

### Sessions

```dotenv
SESSION_DRIVER=database
```

Unchanged, and fine: the APIs are token-authenticated and the session table is barely touched.

---

## 2. The dashboard's `.env.production`

Next reads these **at build time** and bakes them into the bundle — changing one means
rebuilding and redeploying, not restarting.

```dotenv
NEXT_PUBLIC_API_URL=https://api.kapitano.shop/api/dashboard

# The public app key only. There is no secret here, and there must not be.
NEXT_PUBLIC_REVERB_APP_KEY=<the same REVERB_APP_KEY>
NEXT_PUBLIC_REVERB_HOST=api.kapitano.shop
NEXT_PUBLIC_REVERB_PORT=443
NEXT_PUBLIC_REVERB_SCHEME=https
```

`NEXT_PUBLIC_REVERB_HOST` must be a name **browsers** can resolve to the server. Never
`localhost` or `127.0.0.1` outside a developer's machine.

Leave `NEXT_PUBLIC_REVERB_APP_KEY` empty and no socket is opened at all — nothing throws, and
the dashboard behaves as it did before any of this. That is deliberate: a missing variable
should degrade, not break.

---

## 3. Processes to supervise

Five, and each one fails differently:

| Process | Command | When it stops |
| --- | --- | --- |
| PHP-FPM | your usual pool | the API is down; obvious |
| Queue worker | `php artisan queue:work --tries=1` | **socket connects, nothing updates** |
| Reverb | `php artisan reverb:start` | browsers get `ERR_CONNECTION_REFUSED`; screens go static |
| Redis | `redis-server` | requests fail — it is the cache and the queue |
| Scheduler | the cron line below | `push:prune`, `push:health` and the fleet sweep stop |

```cron
* * * * * cd /var/www/kapitano && php artisan schedule:run >> /dev/null 2>&1
```

Supervisor, as a starting point:

```ini
[program:kapitano-worker]
command=php /var/www/kapitano/artisan queue:work --tries=1 --max-time=3600
autostart=true
autorestart=true
numprocs=2
user=www-data
stopwaitsecs=3600

[program:kapitano-reverb]
command=php /var/www/kapitano/artisan reverb:start
autostart=true
autorestart=true
user=www-data
```

`--max-time=3600` recycles the worker hourly so a slow leak cannot accumulate.
`stopwaitsecs` must exceed your longest job, or a deploy kills work mid-flight.

---

## 4. nginx: the websocket has to be proxied

Reverb speaks the Pusher protocol over two paths. Without the upgrade headers the connection
is refused after the TLS handshake, which looks like a certificate problem and is not.

```nginx
location ~ ^/(app|apps) {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # Without these two the socket is an ordinary request and is closed immediately.
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";

    # A websocket is idle by nature. The default 60s read timeout drops healthy
    # connections roughly once a minute, which reads as a flapping dashboard.
    proxy_read_timeout  3600s;
    proxy_send_timeout  3600s;
}
```

Also raise the open-file limit for the Reverb process — one connection per open dashboard tab
adds up faster than the default 1024.

```ini
[program:kapitano-reverb]
...
# /etc/security/limits.conf, or LimitNOFILE under systemd
```

---

## 5. More than one application server

Reverb holds its connections in memory, so two servers each know only their own. An event
published on server A never reaches a browser attached to server B.

```dotenv
REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb
REDIS_URL=redis://:<password>@127.0.0.1:6379
```

With scaling on, Reverb nodes relay through Redis. **Run exactly one Reverb process per
node**, and point the load balancer at them with sticky sessions for the websocket path.

On a single server this stays off.

---

## 6. Deploy sequence

Order matters in two places.

```bash
php artisan migrate --force

php artisan config:cache
php artisan route:cache
php artisan event:cache          # the dashboard listeners are auto-discovered

php artisan queue:restart        # ALWAYS — a running worker holds the old code in memory
php artisan reverb:restart       # only when the broadcasting config changed
```

- `queue:restart` on **every** deploy. A worker that has been up since before the release
  will happily run new jobs against old code.
- `event:cache` matters here specifically: the order events reach the dashboard through a
  discovered listener, and a stale cache silently drops it.

The dashboard is a separate build: set its `.env.production`, `npm ci && npm run build`, then
deploy. Changing a `NEXT_PUBLIC_*` value requires a rebuild — restarting the node process is
not enough.

---

## 7. Checking it actually works

In order; each one isolates a different failure.

```bash
# 1. Reverb is accepting connections (expect a 404 from the HTTP endpoint, not a refusal)
curl -i https://api.kapitano.shop/app/<REVERB_APP_KEY>

# 2. PHP can publish to it — no exception means the whole server side is wired
php artisan tinker
>>> broadcast(new App\Events\Dashboard\DashboardOrderTouched('probe','ORD-0','pending',null,'probe'));

# 3. A worker is actually draining the queue
php artisan queue:monitor redis:default --max=100

# 4. Channel authorisation answers for a real admin token
curl -X POST https://api.kapitano.shop/api/broadcasting/auth \
  -H "Accept: application/json" -H "Accept-Language: en" \
  -H "Authorization: Bearer <admin token>" \
  -d 'socket_id=123.456&channel_name=private-dashboard.orders'
```

Step 4 returning `403` for `private-admin.<someone else's uuid>` is the security boundary
working, not a fault.

---

## 8. What this buys, measured

Against the running application, before and after:

| | before | after |
| --- | --- | --- |
| Polling timers in the dashboard | 6 | 1 (the live map, deliberately) |
| API calls to open the dashboard | 6 | 4 |
| Profile + unread + tiles | 3 calls, 2.28s | 1 call, 0.74s |
| Re-reading an unchanged list | 200, full body | 304, zero bytes |
| Cost of an idle open tab | ~6 requests/minute, forever | zero until something changes |

The last line is the one that matters under load: cost now scales with **real changes**, not
with how many tabs are open.
