fix(reverb): route broadcasts through bundled server and validate health

This commit is contained in:
Andras Bacsai
2026-09-22 10:14:32 +02:00
parent e3ab63e7d8
commit 97b6203fe1
10 changed files with 118 additions and 20 deletions
+4 -3
View File
@@ -12,7 +12,7 @@ For UI/UX design specifications, principles, and visual standards, consult the l
## Development Environment
Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio.
Docker Compose-based dev setup with services: coolify (app, which also runs Reverb WebSockets and the terminal server), postgres, redis, vite, testing-host, mailpit, minio.
```bash
# Start dev environment (uses docker-compose.dev.yml)
@@ -127,7 +127,7 @@ Because the "server" and the test share one PHP process, they share the phpunit
### Backend Structure (app/)
- **Actions/** — Domain actions organized by area (Application, Database, Docker, Proxy, Server, Service, Shared, Stripe, User, CoolifyTask, Fortify). Uses `lorisleiva/laravel-actions` with `AsAction` trait — actions can be called as objects, dispatched as jobs, or used as controllers.
- **Livewire/** — All UI components (Livewire 3). Pages organized by domain: Server, Project, Settings, Security, Notifications, Terminal, Subscription, SharedVariables. This is the primary UI layer — no traditional Blade controllers. Components listen to private team channels for real-time status updates via Soketi.
- **Livewire/** — All UI components (Livewire 3). Pages organized by domain: Server, Project, Settings, Security, Notifications, Terminal, Subscription, SharedVariables. This is the primary UI layer — no traditional Blade controllers. Components listen to private team channels for real-time status updates via Laravel Reverb.
- **Jobs/** — Queue jobs for deployments (`ApplicationDeploymentJob`), backups, Docker cleanup, server management, proxy configuration. Uses Redis queue with Horizon for monitoring.
- **Models/** — Eloquent models extending `BaseModel` which provides auto-CUID2 UUID generation. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). Common traits: `HasConfiguration`, `HasMetrics`, `HasSafeStringAttribute`, `ClearsGlobalSearchCache`.
- **Services/** — Business logic services (ConfigurationGenerator, DockerImageParser, ContainerStatusAggregator, HetznerService, etc.). Use Services for complex orchestration; use Actions for single-purpose domain operations.
@@ -155,7 +155,8 @@ Because the "server" and the test share one PHP process, they share the phpunit
- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data.
### Event Broadcasting
- Soketi WebSocket server for real-time updates (ports 6001-6002 in dev)
- Laravel Reverb WebSocket server for real-time updates (port 6001) and a Node terminal WebSocket server (port 6002), both run inside the `coolify` container as s6 services
- Server-side broadcasts use `PUSHER_BACKEND_HOST`/`PUSHER_BACKEND_PORT` (defaults `127.0.0.1:6001`); `PUSHER_HOST`/`PUSHER_PORT` are browser-facing only
- Status change events: `ApplicationStatusChanged`, `ServiceStatusChanged`, `DatabaseStatusChanged`, `ProxyStatusChanged`
- Livewire components subscribe to private team channels via `getListeners()`
+1 -1
View File
@@ -13,7 +13,7 @@
- Laravel 11 (PHP Framework)
- PostgreSQL 15 (Database)
- Redis 7 (Caching & Real-time features)
- Soketi (WebSocket Server)
- Laravel Reverb (WebSocket Server)
## DevOps & Infrastructure
+19 -7
View File
@@ -1,5 +1,15 @@
<?php
/*
* Server-side host for the bundled Reverb server. Older installs could set
* PUSHER_BACKEND_HOST to the removed "coolify-realtime" container, so that
* value is treated as unset. The browser-facing PUSHER_HOST must never be used here.
*/
$backendHost = env('PUSHER_BACKEND_HOST');
if (blank($backendHost) || $backendHost === 'coolify-realtime') {
$backendHost = '127.0.0.1';
}
return [
/*
@@ -36,11 +46,11 @@ return [
'secret' => env('PUSHER_APP_SECRET', 'coolify'),
'app_id' => env('PUSHER_APP_ID', 'coolify'),
'options' => [
'host' => env('PUSHER_HOST', 'coolify'),
'host' => $backendHost,
'port' => env('PUSHER_BACKEND_PORT', 6001),
'scheme' => env('PUSHER_SCHEME', 'http'),
'scheme' => env('PUSHER_BACKEND_SCHEME', 'http'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'http') === 'https',
'useTLS' => env('PUSHER_BACKEND_SCHEME', 'http') === 'https',
'path' => '',
],
'client_options' => [
@@ -48,17 +58,19 @@ return [
],
],
// Legacy connection name (BROADCAST_DRIVER=pusher). Reverb speaks the Pusher
// protocol, so this keeps pointing at the bundled server like it did before.
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY', 'coolify'),
'secret' => env('PUSHER_APP_SECRET', 'coolify'),
'app_id' => env('PUSHER_APP_ID', 'coolify'),
'options' => [
'host' => env('PUSHER_HOST'),
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'host' => $backendHost,
'port' => env('PUSHER_BACKEND_PORT', 6001),
'scheme' => env('PUSHER_BACKEND_SCHEME', 'http'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
'useTLS' => env('PUSHER_BACKEND_SCHEME', 'http') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
+1 -1
View File
@@ -32,7 +32,7 @@ services:
- "6001"
- "${TERMINAL_PORT:-6002}"
healthcheck:
test: curl --fail http://127.0.0.1:8080/api/health || exit 1
test: curl --fail http://127.0.0.1:8080/api/health && curl --fail http://127.0.0.1:${PUSHER_BACKEND_PORT:-6001}/up || exit 1
interval: 5s
retries: 24
start_period: 1m
+1 -1
View File
@@ -58,7 +58,7 @@ services:
- "6001"
- "${TERMINAL_PORT:-6002}"
healthcheck:
test: curl --fail http://localhost:8080/api/health || exit 1
test: curl --fail http://localhost:8080/api/health && curl --fail http://localhost:${PUSHER_BACKEND_PORT:-6001}/up || exit 1
interval: 5s
retries: 10
timeout: 2s
+1 -1
View File
@@ -32,7 +32,7 @@ services:
- "6001"
- "${TERMINAL_PORT:-6002}"
healthcheck:
test: curl --fail http://127.0.0.1:8080/api/health || exit 1
test: curl --fail http://127.0.0.1:8080/api/health && curl --fail http://127.0.0.1:${PUSHER_BACKEND_PORT:-6001}/up || exit 1
interval: 5s
retries: 24
start_period: 1m
+1 -1
View File
@@ -57,7 +57,7 @@ services:
- "6001"
- "${TERMINAL_PORT:-6002}"
healthcheck:
test: curl --fail http://localhost:8080/api/health || exit 1
test: curl --fail http://localhost:8080/api/health && curl --fail http://localhost:${PUSHER_BACKEND_PORT:-6001}/up || exit 1
interval: 5s
retries: 10
timeout: 2s
+6 -1
View File
@@ -152,6 +152,10 @@ update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
update_env_var "PUSHER_BACKEND_PORT" "6001"
# The realtime container no longer exists; point older installs at the bundled Reverb server
if grep -q '^PUSHER_BACKEND_HOST=coolify-realtime$' "$ENV_FILE"; then
set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1"
fi
log "Environment variables check complete"
echo " Done."
@@ -249,7 +253,8 @@ nohup bash -c "
}
# Stop and remove containers
for container in coolify coolify-db coolify-redis; do
# coolify-realtime is kept for upgrades from versions that still ran the separate realtime container
for container in coolify coolify-db coolify-redis coolify-realtime; do
if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then
log \"Stopping container: \${container}\"
docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
+6 -1
View File
@@ -152,6 +152,10 @@ update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
update_env_var "PUSHER_BACKEND_PORT" "6001"
# The realtime container no longer exists; point older installs at the bundled Reverb server
if grep -q '^PUSHER_BACKEND_HOST=coolify-realtime$' "$ENV_FILE"; then
set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1"
fi
log "Environment variables check complete"
echo " Done."
@@ -262,7 +266,8 @@ nohup bash -c "
}
# Stop and remove containers
for container in coolify coolify-db coolify-redis; do
# coolify-realtime is kept for upgrades from versions that still ran the separate realtime container
for container in coolify coolify-db coolify-redis coolify-realtime; do
if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then
log \"Stopping container: \${container}\"
docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
@@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Str;
it('uses Reverb as the first-party broadcast server', function () {
expect(file_get_contents(base_path('composer.json')))
->toContain('"laravel/reverb"')
@@ -9,11 +11,84 @@ it('uses Reverb as the first-party broadcast server', function () {
->toContain("'key' => env('PUSHER_APP_KEY', 'coolify')")
->toContain("'secret' => env('PUSHER_APP_SECRET', 'coolify')")
->toContain("'app_id' => env('PUSHER_APP_ID', 'coolify')")
->toContain("'host' => env('PUSHER_HOST', 'coolify')")
->toContain("'host' => \$backendHost")
->toContain("'port' => env('PUSHER_BACKEND_PORT', 6001)")
->toContain("'scheme' => env('PUSHER_BACKEND_SCHEME', 'http')")
->and(file_exists(config_path('reverb.php')))->toBeTrue();
});
it('does not send server-side broadcasts to the browser-facing Pusher host', function () {
$reverbConnection = Str::between(file_get_contents(config_path('broadcasting.php')), "'reverb' => [", "'pusher' => [");
expect($reverbConnection)
->not->toContain('PUSHER_HOST')
->not->toContain("env('PUSHER_SCHEME'");
});
it('keeps working with environment variables from installs that used the realtime container', function (array $environment, string $connection, string $expectedHost) {
$previous = [];
foreach ($environment as $key => $value) {
$previous[$key] = $_SERVER[$key] ?? null;
$_SERVER[$key] = $value;
}
try {
$broadcasting = require config_path('broadcasting.php');
} finally {
foreach ($previous as $key => $value) {
if ($value === null) {
unset($_SERVER[$key]);
} else {
$_SERVER[$key] = $value;
}
}
}
$options = $broadcasting['connections'][$connection]['options'];
expect($options['host'])->toBe($expectedHost)
->and($options['port'])->toBe(6001)
->and($options['scheme'])->toBe('http')
->and($options['useTLS'])->toBeFalse();
})->with([
'legacy realtime backend host' => [['PUSHER_BACKEND_HOST' => 'coolify-realtime'], 'reverb', '127.0.0.1'],
'legacy pusher driver' => [['BROADCAST_DRIVER' => 'pusher', 'PUSHER_BACKEND_HOST' => 'coolify-realtime'], 'pusher', '127.0.0.1'],
'public browser host and scheme' => [['PUSHER_HOST' => 'coolify.example.com', 'PUSHER_SCHEME' => 'https', 'PUSHER_PORT' => '443'], 'reverb', '127.0.0.1'],
'custom backend host' => [['PUSHER_BACKEND_HOST' => 'coolify'], 'reverb', 'coolify'],
]);
it('rewrites the legacy realtime backend host during upgrades', function (string $script) {
expect(file_get_contents(base_path($script)))
->toContain('if grep -q \'^PUSHER_BACKEND_HOST=coolify-realtime$\' "$ENV_FILE"; then')
->toContain('set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1"');
})->with([
'upgrade script' => ['scripts/upgrade.sh'],
'nightly upgrade script' => ['other/nightly/upgrade.sh'],
]);
it('includes Reverb but not the terminal server in the Coolify container healthcheck', function (string $composeFile) {
$composeContents = file_get_contents(base_path($composeFile));
expect($composeContents)
->toContain('/api/health && curl --fail http://')
->toContain(':${PUSHER_BACKEND_PORT:-6001}/up || exit 1')
->not->toContain('6002/ready');
})->with([
'production compose' => ['docker-compose.prod.yml'],
'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
'windows compose' => ['docker-compose.windows.yml'],
'nightly windows compose' => ['other/nightly/docker-compose.windows.yml'],
]);
it('removes the legacy realtime container during upgrades', function (string $script) {
expect(file_get_contents(base_path($script)))
->toContain('for container in coolify coolify-db coolify-redis coolify-realtime; do');
})->with([
'upgrade script' => ['scripts/upgrade.sh'],
'nightly upgrade script' => ['other/nightly/upgrade.sh'],
]);
it('runs Reverb and terminal websocket services inside the Coolify containers', function (string $dockerfile, string $dependencyService) {
$dockerfileContents = file_get_contents(base_path($dockerfile));
@@ -48,7 +123,7 @@ it('removes the dedicated realtime service from bundled compose files', function
->not->toContain('SOKETI_DEFAULT_APP_ID')
->toContain('6001')
->toContain('6002')
->not->toContain('REVERB_');
->not->toMatch('/REVERB_(?!PORT\b)/');
if ($hasRuntimeEnvironment) {
expect($composeContents)->toContain('PUSHER_BACKEND_PORT');
@@ -108,7 +183,7 @@ it('uses Pusher environment keys for self-hosted Reverb compatibility', function
foreach ($files as $file) {
expect(file_get_contents(base_path($file)))
->toContain('PUSHER_')
->not->toContain('REVERB_');
->not->toMatch('/REVERB_(?!PORT\b)/');
}
});