fix(proxy): keep proxy status current and stop auto-start during restarts

- Refresh the navbar proxy status on the 30 s poll
- Skip Sentinel and server check auto-start while a proxy start or restart runs
- Use the StartProxy commands in RestartProxyJob, so Caddy restarts keep the
  maintenance blocks and remove the Traefik logrotate sidecar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 18:02:17 +02:00
co-authored by Claude Opus 5.5
parent e964c5de2d
commit 7e612422b6
10 changed files with 145 additions and 47 deletions
+28 -14
View File
@@ -7,6 +7,7 @@ use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Services\ProxyPortParser;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Spatie\Activitylog\Models\Activity;
@@ -40,12 +41,35 @@ class StartProxy
ProxyStatusChangedUI::dispatch($server->team_id);
}
SaveProxyConfiguration::run($server, $configuration);
$server->markProxyConfigurationApplied($configuration);
$commands = self::startCommands($server);
if ($async) {
$server->markProxyTransitionStarted();
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id, queue: deployment_queue());
} else {
instant_remote_process($commands, $server);
$server->proxy->set('type', $proxyType);
$server->save();
ProxyStatusChanged::dispatch($server->id);
return 'OK';
}
}
/**
* Commands that start the proxy. They first remove an existing proxy container.
* RestartProxyJob uses the same commands after its stop phase.
*/
public static function startCommands(Server $server): Collection
{
$commands = collect([]);
$proxy_path = $server->proxyPath();
// Absolute paths: a non-root SSH user may not be able to enter the proxy directory (#4255).
$compose_file = rtrim($proxy_path, '/').'/docker-compose.yml';
SaveProxyConfiguration::run($server, $configuration);
$server->markProxyConfigurationApplied($configuration);
if ($server->isSwarmManager()) {
$commands = $commands->merge([
@@ -79,7 +103,7 @@ class StartProxy
" echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi',
]);
if ($proxyType !== ProxyTypes::TRAEFIK->value) {
if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) {
// The sidecar belongs to the Traefik compose project, so --remove-orphans of another proxy keeps it.
$commands->push('docker rm -f '.TRAEFIK_LOGROTATE_CONTAINER.' 2>/dev/null || true');
}
@@ -93,16 +117,6 @@ class StartProxy
$commands = $commands->merge(connectProxyToNetworks($server));
}
if ($async) {
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id, queue: deployment_queue());
} else {
instant_remote_process($commands, $server);
$server->proxy->set('type', $proxyType);
$server->save();
ProxyStatusChanged::dispatch($server->id);
return 'OK';
}
return $commands;
}
}
+1 -1
View File
@@ -817,7 +817,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
private function updateProxyStatus()
{
// If proxy is not found, start it
if ($this->server->isProxyShouldRun()) {
if ($this->server->isProxyShouldRun() && ! $this->server->isProxyTransitionInProgress()) {
if ($this->foundProxy === false) {
try {
if (CheckProxy::run($this->server)) {
+8 -31
View File
@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Actions\Proxy\StartProxy;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
@@ -45,6 +46,8 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
}
ProxyPortParser::fromConfiguration($configuration);
$this->server->markProxyTransitionStarted();
// Set status to restarting
$this->server->proxy->status = 'restarting';
$this->server->proxy->force_stop = false;
@@ -68,8 +71,12 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id);
} catch (\InvalidArgumentException $e) {
$this->server->markProxyTransitionFinished();
return handleError($e);
} catch (\Throwable $e) {
$this->server->markProxyTransitionFinished();
// Set error status
$this->server->proxy->status = 'error';
$this->server->save();
@@ -91,9 +98,6 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
private function buildRestartCommands(string $configuration): array
{
$containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
$proxy_path = $this->server->proxyPath();
// Absolute paths: a non-root SSH user may not be able to enter the proxy directory (#4255).
$compose_file = rtrim($proxy_path, '/').'/docker-compose.yml';
$stopTimeout = 30;
SaveProxyConfiguration::run($this->server, $configuration);
@@ -127,34 +131,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
]);
// === START PHASE ===
if ($this->server->isSwarmManager()) {
$commands = $commands->merge([
"echo 'Starting proxy (Swarm mode)...'",
"mkdir -p $proxy_path/dynamic",
"echo 'Creating required Docker Compose file.'",
"echo 'Starting coolify-proxy.'",
"docker stack deploy --detach=true -c $compose_file coolify-proxy",
"echo 'Successfully started coolify-proxy.'",
]);
} else {
$caddyfile = 'import /dynamic/*.caddy';
$commands = $commands->merge([
"echo 'Starting proxy...'",
"mkdir -p $proxy_path/dynamic",
"echo '$caddyfile' | tee $proxy_path/dynamic/Caddyfile > /dev/null",
"echo 'Creating required Docker Compose file.'",
"echo 'Pulling docker image.'",
"docker compose -f $compose_file pull",
]);
// Ensure required networks exist BEFORE docker compose up
$commands = $commands->merge(ensureProxyNetworksExist($this->server));
$commands = $commands->merge([
"echo 'Starting coolify-proxy.'",
"docker compose -f $compose_file up -d --wait --remove-orphans",
"echo 'Successfully started coolify-proxy.'",
]);
$commands = $commands->merge(connectProxyToNetworks($this->server));
}
$commands = $commands->merge(StartProxy::startCommands($this->server));
return $commands->toArray();
}
+1 -1
View File
@@ -77,7 +77,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
$this->checkLogDrainContainer();
}
if ($this->server->proxySet() && ! $this->server->proxy->force_stop) {
if ($this->server->proxySet() && ! $this->server->proxy->force_stop && ! $this->server->isProxyTransitionInProgress()) {
$this->server->proxyType();
$foundProxyContainer = $this->containers->filter(function ($value, $key) {
if ($this->server->isSwarm()) {
@@ -24,6 +24,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
if (is_null($server)) {
return;
}
$server->markProxyTransitionFinished();
$proxyContainerName = 'coolify-proxy';
$status = getContainerStatus($server, $proxyContainerName);
$server->proxy->set('status', $status);
+1
View File
@@ -251,6 +251,7 @@ class Navbar extends Component
{
$this->server->refresh();
$this->server->load('settings');
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$this->dispatchProxyConfigurationState();
}
+24
View File
@@ -2124,6 +2124,30 @@ $siteAddress {
return true;
}
/**
* Marks a proxy start or restart as running, so status checks do not start a second proxy meanwhile.
* ProxyStatusChangedNotification clears the mark when the start or restart finishes.
*/
public function markProxyTransitionStarted(): void
{
Cache::put($this->proxyTransitionCacheKey(), true, now()->addMinutes(5));
}
public function markProxyTransitionFinished(): void
{
Cache::forget($this->proxyTransitionCacheKey());
}
public function isProxyTransitionInProgress(): bool
{
return Cache::has($this->proxyTransitionCacheKey());
}
private function proxyTransitionCacheKey(): string
{
return "proxy-transition:{$this->uuid}";
}
public function skipServer()
{
if ($this->hasPlaceholderIp()) {
@@ -3,6 +3,7 @@
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Jobs\RestartProxyJob;
use App\Livewire\Project\Shared\Maintenance;
use App\Models\Application;
use App\Models\Environment;
@@ -206,6 +207,22 @@ it('writes the maintenance blocks into the base Caddyfile on Caddy servers', fun
->toContain("@coolify-maintenance-{$application->uuid}-0 path /*");
});
it('keeps the maintenance blocks and removes the Traefik logrotate sidecar when Caddy restarts', function () {
$this->server->proxy->type = ProxyTypes::CADDY->value;
$this->server->save();
$application = maintenanceApplication(['is_maintenance_enabled' => true]);
$server = maintenanceServer();
$method = new ReflectionMethod(RestartProxyJob::class, 'buildRestartCommands');
$commands = $method->invoke(new RestartProxyJob($server), "services:\n caddy:\n container_name: coolify-proxy\n image: lucaslorentz/caddy-docker-proxy:2.13-alpine\n");
$caddyfile = base64_encode($server->caddyBaseCaddyfile());
expect(base64_decode($caddyfile))->toContain("@coolify-maintenance-{$application->uuid}-0 path /*")
->and($commands)
->toContain("echo '{$caddyfile}' | base64 -d | tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null")
->toContain('docker rm -f '.TRAEFIK_LOGROTATE_CONTAINER.' 2>/dev/null || true');
});
it('keeps the page container for maintenance when a redirect URL replaces the catch-all page', function () {
maintenanceApplication(['is_maintenance_enabled' => true]);
$server = maintenanceServer();
@@ -1,13 +1,18 @@
<?php
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Events\ProxyStatusChanged;
use App\Events\ServiceChecked;
use App\Jobs\PushServerUpdateJob;
use App\Jobs\ServerStorageCheckJob;
use App\Listeners\ProxyStatusChangedNotification;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@@ -173,6 +178,49 @@ it('marks a stale proxy status as running when sentinel reports a running proxy'
expect($server->fresh()->proxy->get('status'))->toBe('running');
});
it('does not start a second proxy while a proxy restart runs', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$server->proxy->set('type', 'TRAEFIK');
$server->proxy->set('status', 'restarting');
$server->save();
$server->markProxyTransitionStarted();
CheckProxy::shouldNotRun();
StartProxy::shouldNotRun();
Event::fake([ServiceChecked::class]);
(new PushServerUpdateJob($server, ['containers' => [], 'filesystem_usage_root' => ['used_percentage' => 10]]))->handle();
expect($server->fresh()->proxy->get('status'))->toBe('restarting');
});
it('starts a missing proxy when no proxy restart runs', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
CheckProxy::shouldRun()->once()->andReturn(true);
StartProxy::shouldRun()->once();
Event::fake([ServiceChecked::class]);
(new PushServerUpdateJob($server, ['containers' => [], 'filesystem_usage_root' => ['used_percentage' => 10]]))->handle();
});
it('ends the proxy restart when the proxy status listener runs', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$server->markProxyTransitionStarted();
Process::fake();
(new ProxyStatusChangedNotification)->handle(new ProxyStatusChanged($server->id));
expect($server->isProxyTransitionInProgress())->toBeFalse();
});
it('uses default queue for PushServerUpdateJob', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
@@ -133,3 +133,19 @@ it('shows the Traefik dashboard link without proxy actions to members who cannot
->assertSee('Traefik Dashboard')
->assertDontSee('Refresh Proxy Status');
});
it('shows the current proxy status after a background status update', function () {
[$server] = makeNavbarServer(isFunctional: true);
$server->forceFill(['proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'starting']])->save();
$component = Livewire::test('server.navbar', ['server' => $server->fresh()])
->assertSet('proxyStatus', 'starting');
// Sentinel or the server check job marks the proxy as running without a UI broadcast.
$server->proxy->set('status', 'running');
$server->save();
$component->call('refreshAgentStatus')
->assertSet('proxyStatus', 'running')
->assertDontSee('Starting');
});