coollabsio/coolify · error · RuntimeException

SSH connection failed{$contextInfo}{$attemptInfo}

Error message

SSH connection failed{$contextInfo}{$attemptInfo}

What it means

The SshRetryable trait wraps SSH callbacks in a retry loop (configurable via constants.ssh.max_retries with exponential backoff), re-attempting only when the error text matches known retryable SSH patterns (connection reset/refused/timeout, auth failures, etc.). After the loop, if throwError is true and the last caught Throwable has a completely empty message, it throws this generic RuntimeException ('SSH connection failed[ to server X][ after N attempts]') carrying only the original error code. It is a last-resort message for failures where the underlying exception said nothing at all.

Source

Thrown at app/Traits/SshRetryable.php:126

                // Not retryable or max retries reached
                break;
            }
        }

        // All retries exhausted
        if ($attempt >= $maxRetries) {
            Log::error('SSH operation failed after all retries', array_merge($context, [
                'attempts' => $attempt,
                'error' => $lastErrorMessage,
            ]));
        }

        if ($throwError && $lastError) {
            // If the error message is empty, provide a more meaningful one
            if (empty($lastErrorMessage) || trim($lastErrorMessage) === '') {
                $contextInfo = isset($context['server']) ? " to server {$context['server']}" : '';
                $attemptInfo = $attempt > 1 ? " after {$attempt} attempts" : '';
                throw new \RuntimeException("SSH connection failed{$contextInfo}{$attemptInfo}", $lastError->getCode());
            }
            throw $lastError;
        }

        return null;
    }
}

View on GitHub (pinned to 70b9acc424)

Solutions

  1. Verify the network path from the Coolify host to the target server on port 22: nc -vz <host> 22 / ping, and check firewall/security-group rules.
  2. Reproduce manually with verbose SSH from the Coolify host (ssh -vvv ...) to surface the real failure the empty message hid.
  3. Check the Laravel log for 'SSH operation failed after all retries' (it includes the context array with server/command) and correlate with the timestamp.
  4. Inspect sshd-side limits on the target: MaxStartups, fail2ban bans, MaxAuthTries — bursts of parallel Coolify SSH commands frequently trip connection resets.
  5. For flaky links, raise retry tuning in config (constants.ssh.max_retries, retry_base_delay, retry_max_delay) so transient resets are absorbed.
  6. If you control the calling code, ensure exceptions thrown inside the callback always carry a message so the generic fallback (which drops $lastError) is never used.

Example fix

// before (library code drops the cause: no message, no previous)
throw new \RuntimeException("SSH connection failed{$contextInfo}{$attemptInfo}", $lastError->getCode());

// after (preserve the original exception for diagnosis)
throw new \RuntimeException(
    "SSH connection failed{$contextInfo}{$attemptInfo}: " . ($lastErrorMessage !== '' ? $lastErrorMessage : 'unknown SSH error'),
    $lastError->getCode(),
    $lastError
);
Defensive patterns

Strategy: try-catch

Validate before calling

use App\Models\Server;

function sshReachable(Server $server): bool
{
    try {
        $out = instant_remote_process(['echo connection-ok'], $server, false);

        return trim((string) $out) === 'connection-ok';
    } catch (\Throwable) {
        return false;
    }
}

// before dispatching long SSH work:
if (! sshReachable($server)) {
    // flag server unreachable, notify, and skip instead of burning retries
}

Type guard

function isGenericSshConnectionFailure(\Throwable $e): bool
{
    return $e instanceof \RuntimeException
        && str_starts_with($e->getMessage(), 'SSH connection failed');
}

Try / catch

try {
    $this->executeWithSshRetry(fn () => $callback(), ['server' => $server->name], true);
} catch (\RuntimeException $e) {
    if (isGenericSshConnectionFailure($e)) {
        // empty-message failure: the real cause was swallowed — log context and surface a clear, actionable error
        Log::error('SSH unreachable', ['server' => $server->id, 'code' => $e->getCode()]);
        // mark the server as unreachable / queue a later re-attempt instead of failing hard
    } else {
        throw $e; // meaningful errors keep their original message
    }
}

Prevention

When it happens

Trigger: executeWithSshRetry($callback, $context, true) throws this when the callback's exception message is empty ('' or whitespace): an empty message never matches isRetryableSshError(), so the loop breaks immediately (typically attempt 0, hence no 'after N attempts' suffix and no 'SSH operation failed after all retries' log entry). Common producers: SSH/process failures that exit with code 255 but write nothing to stderr, network drops where the SSH process dies silently, or any wrapped call (e.g. instant_remote_process / remote_process) raising an exception constructed without a message. The 'to server {name}' suffix appears only when $context['server'] was passed.

Common situations: Firewalls or fail2ban silently dropping packets so ssh exits with no output; sshd MaxStartups exceeded during parallel Coolify deployments (connections reset before banner); servers rebooting or DNS resolving but routing black-holed; PHP/dependency upgrades where a Process exception is created with an empty message; Docker network hiccups on the Coolify host. Because the original message is empty and this RuntimeException is not chained to $lastError as previous, the root cause is invisible except via the context logged at failure time.

Related errors


AI-assisted analysis of coollabsio/coolify@70b9acc424 (2026-08-17). Data as JSON: /api/errors/3695af2b40c402f7. Report an issue: GitHub.