Documentation
serversinc/ssh-runner

Overview

SSH Runner is a pipeline-based SSH runner for Laravel that executes commands on remote servers. It provides a fluent API for building SSH command pipelines using the Spatie SSH library under the hood.

The package is built around a few core ideas:

  • Actions are reusable, testable units of SSH work.
  • Pipelines chain multiple actions together with configurable failure behavior.
  • Scripts group related commands into a single action with step-by-step execution and per-step rollback.
  • Failure strategies control what happens when an action fails: stop, continue, or rollback.
  • Execution logging records every pipeline and action run to the database automatically.

Requirements

PHP ^8.4, Laravel ^8.0, Spatie SSH ^1.13

License

MIT License

Installation

Install the package via Composer:

terminal
composer require serversinc/ssh-runner

The package uses Laravel's auto-discovery to register the service provider and facade automatically.

Configuration

Publish the configuration file and migrations, then run them:

terminal
php artisan vendor:publish --provider="Serversinc\SshRunner\SshRunnerServiceProvider" --tag="ssh-runner-config"
php artisan vendor:publish --provider="Serversinc\SshRunner\SshRunnerServiceProvider" --tag="ssh-runner-migrations"
php artisan migrate

Config Options

The published config file at config/ssh-runner.php contains the following options:

config/ssh-runner.php
<?php

return [
    'default' => [
        'timeout' => 60,
        'port' => 22,
    ],

    'logging' => [
        'enabled' => true,
        'connection' => env('DB_CONNECTION', 'mysql'),
    ],
];

Database Migrations

The migrations create two tables for execution logging:

  • ssh_pipeline_logs — One row per pipeline execution
  • ssh_action_logs — One row per action within a pipeline

SshServer Interface

Your server model must implement the SshServer contract. This tells the runner how to connect to the remote server.

app/Models/Server.php
use Serversinc\SshRunner\Contracts\SshServer;

class Server extends Model implements SshServer
{
    public function getSshHost(): string
    {
        return $this->ip_address;
    }

    public function getSshPort(): int
    {
        return $this->ssh_port ?? 22;
    }

    public function getSshUser(): string
    {
        return $this->ssh_user;
    }

    public function getSshKeyPath(): ?string
    {
        return $this->ssh_key_path;
    }

    public function getSshKeyContents(): ?string
    {
        return $this->ssh_key_contents;
    }
}

Connection Methods

The package supports two authentication methods:

MethodHow it works
getSshKeyPath() Path to a private key file on disk. The runner passes this path directly to the SSH command.
getSshKeyContents() Raw private key contents as a string. The runner writes this to a temporary file with 0600 permissions and cleans it up on shutdown.

Localhost: Connections to localhost or 127.0.0.1 use a custom wrapper that forces SSH instead of letting Spatie SSH bypass it with a local process.

Actions

Actions are the building blocks of SSH Runner. An action is a reusable, testable class that encapsulates a single unit of SSH work. Each action implements two methods: handle() to run the command, and optionally undo() for rollback.

Creating an Action

Extend BaseSshAction and implement the handle() method:

app/Actions/InstallPackage.php
use Serversinc\SshRunner\Actions\BaseSshAction;
use Serversinc\SshRunner\Contracts\SshServer;
use Serversinc\SshRunner\Results\ActionResult;
use Spatie\Ssh\Ssh;

class InstallPackage extends BaseSshAction
{
    public function __construct(private string $packageName)
    {
    }

    public function handle(SshServer $server, Ssh $ssh): ActionResult
    {
        return $this->run($ssh, ["apt-get install -y {$this->packageName}"]);
    }

    public function undo(SshServer $server, Ssh $ssh): void
    {
        $ssh->execute(["apt-get remove -y {$this->packageName}"]);
    }
}

The run() Helper

BaseSshAction provides a run() helper that executes commands via Spatie SSH and returns an ActionResult:

src/Actions/BaseSshAction.php
protected function run(Ssh $ssh, array $commands): ActionResult
{
    $process = $ssh->execute($commands);

    return new ActionResult(
        success: $process->isSuccessful(),
        output: $process->getOutput(),
        errorOutput: $process->getErrorOutput(),
        exitCode: $process->getExitCode(),
        action: static::class,
        executedAt: now()->toImmutable(),
    );
}

ActionResult

Every action returns an ActionResult object with the following properties:

PropertyTypeDescription
$successboolWhether the command exited with code 0
$outputstringStandard output from the command
$errorOutputstringStandard error from the command
$exitCodeintThe process exit code
$actionstringThe fully-qualified class name of the action
$executedAtCarbonImmutableWhen the action was executed

The ActionResult also provides a failed(): bool method as a convenience.

Pipelines

Pipelines chain multiple actions together and execute them in sequence. You can configure how the pipeline responds to failures using failure strategies.

Using the Facade

The recommended way to build and execute pipelines is via the SshRunner facade:

routes/web.php
use SshRunner;
use Serversinc\SshRunner\Enums\FailureStrategy;

$result = SshRunner::pipeline($server)
    ->run(new UpdatePackageList)
    ->run(new InstallPackage('nginx'))
    ->run(new RestartService('nginx'))
    ->execute();

if ($result->success) {
    echo "Pipeline completed in {$result->duration()} seconds";
} else {
    foreach ($result->failedActions() as $action) {
        echo "Failed: {$action->action}\n";
        echo "Error: {$action->errorOutput}\n";
    }
}

Using SshConnection

For connection reuse across multiple pipelines:

routes/web.php
use Serversinc\SshRunner\SshConnection;

$connection = SshConnection::for($server);

$result = $connection->pipeline()
    ->run(new UpdatePackageList)
    ->run(new InstallPackage('nginx'))
    ->execute();

Using the Factory

The SshRunner factory class provides the same API as the facade:

routes/web.php
use Serversinc\SshRunner\SshRunner;

$result = SshRunner::pipeline($server)
    ->run(new UpdatePackageList)
    ->run(new InstallPackage('nginx'))
    ->execute();

PipelineResult

After execution, a PipelineResult is returned:

Property / MethodDescription
$successTrue if every action in the pipeline succeeded
$resultsA Collection of every ActionResult
$startedAtWhen the pipeline started
$completedAtWhen the pipeline finished
failedActions()Collection of only the failed action results
duration()Total execution time in seconds

Scripts

Scripts allow you to group multiple related commands into a single action with built-in step-by-step execution, optional rollback per step, and critical/non-critical step handling.

Creating a Script

Extend BaseScript and define your steps. Each step is a ScriptStep with a name, command, optional rollback, and critical flag:

app/Scripts/DeployWordPressSite.php
use Serversinc\SshRunner\Scripts\BaseScript;
use Serversinc\SshRunner\Scripts\ScriptStep;

class DeployWordPressSite extends BaseScript
{
    public function __construct(
        private string $path,
        private string $domain,
        private string $dbName,
        private string $dbUser,
        private string $dbPassword,
    ) {}

    public function steps(): array
    {
        return [
            new ScriptStep(
                name: 'Create application directory',
                command: "mkdir -p {$this->path}",
                rollback: "rm -rf {$this->path}",
            ),
            new ScriptStep(
                name: 'Download WordPress',
                command: "cd {$this->path} && wget https://wordpress.org/latest.tar.gz",
                rollback: "rm -f {$this->path}/latest.tar.gz",
            ),
            new ScriptStep(
                name: 'Extract archive',
                command: "cd {$this->path} && tar -xzf latest.tar.gz",
            ),
            new ScriptStep(
                name: 'Create database',
                command: "mysql -e \"CREATE DATABASE IF NOT EXISTS {$this->dbName};\"",
                rollback: "mysql -e \"DROP DATABASE IF EXISTS {$this->dbName};\"",
            ),
            new ScriptStep(
                name: 'Set permissions',
                command: "chown -R www-data:www-data {$this->path}",
                critical: false, // Non-critical: failure here won't stop the script
            ),
        ];
    }

    public function validate(): void
    {
        if (empty($this->path) || empty($this->domain)) {
            throw new \InvalidArgumentException('Path and domain are required');
        }
    }
}

ScriptStep Constructor

ParameterTypeRequiredDescription
namestringYesHuman-readable name for the step
commandstringYesThe shell command to execute
rollback?stringNoCommand to run if this or a later critical step fails
criticalboolNoIf false, failure is logged but execution continues. Default: true

Using Scripts in Pipelines

Scripts are treated as single actions within pipelines, so they compose naturally:

routes/web.php
use SshRunner;

$result = SshRunner::pipeline($server)
    ->run(new UpdatePackageList)
    ->script(new DeployWordPressSite(
        path: '/var/www/example.com',
        domain: 'example.com',
        dbName: 'wordpress_example',
        dbUser: 'wp_example',
        dbPassword: 'secure-password',
    ))
    ->run(new RestartService('nginx'))
    ->onFailure(FailureStrategy::ROLLBACK)
    ->execute();

Executing a Script Directly

You can also run a script without a pipeline:

routes/web.php
$result = SshRunner::script($server, new DeployWordPressSite(
    path: '/var/www/example.com',
    domain: 'example.com',
    dbName: 'wordpress_example',
    dbUser: 'wp_example',
    dbPassword: 'secure-password',
));

if ($result->success) {
    echo $result->output;
} else {
    echo $result->errorOutput;
}

Script Behavior

  • SSH connection reuse — Scripts automatically enable SSH multiplexing so all steps share the same underlying TCP connection.
  • Critical steps (critical: true, the default) trigger automatic rollback of all previously completed steps on failure.
  • Non-critical steps (critical: false) log a failure but continue to the next step.
  • Step-level rollback commands are executed in reverse order when a critical step fails or when the pipeline's ROLLBACK failure strategy is triggered.
  • Filesystem state persists between steps. Files created in one step are available in the next.

While the SSH network connection is reused between steps, each ScriptStep still runs in its own shell process. Environment variables set in one step are not available in subsequent steps. If you need to share data between steps, use files on the remote filesystem.

ssh:action #

The ssh:action Artisan command generates a new SSH action class under App\SSH\Actions that extends BaseSshAction.

terminal
php artisan ssh:action InstallNginx

This creates app/SSH/Actions/InstallNginx.php extending BaseSshAction:

app/SSH/Actions/InstallNginx.php
namespace App\SSH\Actions;

use Serversinc\SshRunner\Actions\BaseSshAction;
use Serversinc\SshRunner\Contracts\SshServer;
use Serversinc\SshRunner\Results\ActionResult;
use Spatie\Ssh\Ssh;

class InstallNginx extends BaseSshAction
{
    public function handle(SshServer $server, Ssh $ssh): ActionResult
    {
        //
    }
}

ssh:script #

The ssh:script Artisan command generates a new SSH script class under App\SSH\Scripts that extends BaseScript.

terminal
php artisan ssh:script DeployApp

This creates app/SSH/Scripts/DeployApp.php extending BaseScript:

app/SSH/Scripts/DeployApp.php
namespace App\SSH\Scripts;

use Serversinc\SshRunner\Scripts\BaseScript;

class DeployApp extends BaseScript
{
    public function steps(): array
    {
        return [];
    }
}

Failure Strategies

Control what happens when an action fails. Set the strategy on a pipeline before calling execute().

STOP (default)

Stop execution on the first failure. This is the default behavior if you don't call onFailure().

routes/web.php
use Serversinc\SshRunner\Enums\FailureStrategy;

$pipeline->onFailure(FailureStrategy::STOP);

CONTINUE

Keep executing remaining actions even if one fails. Useful when you want to collect all errors at once, such as running multiple independent health checks.

routes/web.php
$pipeline->onFailure(FailureStrategy::CONTINUE);

ROLLBACK

When an action fails, call the undo() method on every previously completed action in reverse order. This is useful for transactional operations where you want to leave the server in a clean state.

routes/web.php
$pipeline->onFailure(FailureStrategy::ROLLBACK)
    ->run(new CreateDatabase)
    ->run(new CreateUser) // If this fails, CreateDatabase->undo() is called
    ->execute();

Note: Rollback only calls undo() on actions that have already completed. If the third action fails, only the first and second actions are rolled back. Actions that have not yet started are simply skipped.

Execution Logging

All pipeline runs are automatically logged to the database when the SshServer model has a primary key. Logging is controlled by the logging.enabled config option.

Pipeline Logs

The SshPipelineLog model stores one record per pipeline execution:

routes/web.php
use Serversinc\SshRunner\Models\SshPipelineLog;

// Get all runs for a server
$runs = SshPipelineLog::where('server_id', $server->id)->get();

// Check if a specific run failed
$run = SshPipelineLog::find(1);
if ($run->failed()) {
    foreach ($run->actionLogs as $log) {
        echo "{$log->action}: exit code {$log->exit_code}\n";
    }
}

Action Logs

The SshActionLog model stores one record per action within a pipeline. It includes the output, error output, exit code, and timestamp for each action.

Log Schema

TableKey Columns
ssh_pipeline_logs server_id, server_type, success, stopped_early, started_at, completed_at
ssh_action_logs ssh_pipeline_log_id, action, success, output, error_output, exit_code, executed_at

Single Action Execution

Sometimes you only need to run one action without building a pipeline. The API is the same, just without the chain.

routes/web.php
// Using the Facade
$result = SshRunner::run($server, new UpdatePackageList);

// Or using SshConnection
$connection = SshConnection::for($server);
$result = $connection->execute(new UpdatePackageList);

if ($result->success) {
    echo $result->output;
} else {
    echo $result->errorOutput;
}

Example Actions

Here are some real-world actions you can use as starting points for your own.

MakeDirectory

Creates a directory, with optional recursive flag and rollback:

app/Actions/MakeDirectory.php
use Serversinc\SshRunner\Actions\BaseSshAction;
use Serversinc\SshRunner\Contracts\SshServer;
use Serversinc\SshRunner\Results\ActionResult;
use Spatie\Ssh\Ssh;

class MakeDirectory extends BaseSshAction
{
    public function __construct(
        private readonly string $path,
        private readonly bool $recursive = true
    ) {}

    public function handle(SshServer $server, Ssh $ssh): ActionResult
    {
        $flags = $this->recursive ? '-p' : '';

        return $this->run($ssh, ["mkdir {$flags} {$this->path}"]);
    }

    public function undo(SshServer $server, Ssh $ssh): void
    {
        $ssh->execute(["rm -rf {$this->path}"]);
    }
}

ListDirectory

Lists the contents of a remote directory:

app/Actions/ListDirectory.php
use Serversinc\SshRunner\Actions\BaseSshAction;
use Serversinc\SshRunner\Contracts\SshServer;
use Serversinc\SshRunner\Results\ActionResult;
use Spatie\Ssh\Ssh;

class ListDirectory extends BaseSshAction
{
    public function __construct(private readonly string $path = '~') {}

    public function handle(SshServer $server, Ssh $ssh): ActionResult
    {
        return $this->run($ssh, ["ls -la {$this->path}"]);
    }
}

CheckCommandExists

Verifies that a command is available on the remote server:

app/Actions/CheckCommandExists.php
use Serversinc\SshRunner\Actions\BaseSshAction;
use Serversinc\SshRunner\Contracts\SshServer;
use Serversinc\SshRunner\Results\ActionResult;
use Spatie\Ssh\Ssh;

class CheckCommandExists extends BaseSshAction
{
    public function __construct(private readonly string $command) {}

    public function handle(SshServer $server, Ssh $ssh): ActionResult
    {
        return $this->run($ssh, ["which {$this->command}"]);
    }
}

RestartService

Restarts a system service and verifies it is running:

app/Actions/RestartService.php
use Serversinc\SshRunner\Actions\BaseSshAction;
use Serversinc\SshRunner\Contracts\SshServer;
use Serversinc\SshRunner\Results\ActionResult;
use Spatie\Ssh\Ssh;

class RestartService extends BaseSshAction
{
    public function __construct(private readonly string $service) {}

    public function handle(SshServer $server, Ssh $ssh): ActionResult
    {
        return $this->run($ssh, [
            "systemctl restart {$this->service}",
            "systemctl is-active {$this->service}",
        ]);
    }
}

UpdatePackageList

Updates the package list on a Debian-based server:

app/Actions/UpdatePackageList.php
use Serversinc\SshRunner\Actions\BaseSshAction;
use Serversinc\SshRunner\Contracts\SshServer;
use Serversinc\SshRunner\Results\ActionResult;
use Spatie\Ssh\Ssh;

class UpdatePackageList extends BaseSshAction
{
    public function handle(SshServer $server, Ssh $ssh): ActionResult
    {
        return $this->run($ssh, ['apt-get update']);
    }
}

Example Scripts

Scripts are great for multi-step operations where each step needs individual rollback and you want to reuse the same SSH connection.

Deploy Laravel Application

A script that deploys a Laravel application by pulling code, installing dependencies, running migrations, and clearing caches:

app/Scripts/DeployLaravelApp.php
use Serversinc\SshRunner\Scripts\BaseScript;
use Serversinc\SshRunner\Scripts\ScriptStep;

class DeployLaravelApp extends BaseScript
{
    public function __construct(
        private readonly string $path,
        private readonly string $branch = 'main',
    ) {}

    public function steps(): array
    {
        return [
            new ScriptStep(
                name: 'Pull latest code',
                command: "cd {$this->path} && git fetch origin && git reset --hard origin/{$this->branch}",
            ),
            new ScriptStep(
                name: 'Install composer dependencies',
                command: "cd {$this->path} && composer install --no-dev --optimize-autoloader",
            ),
            new ScriptStep(
                name: 'Run database migrations',
                command: "cd {$this->path} && php artisan migrate --force",
                rollback: "cd {$this->path} && php artisan migrate:rollback",
            ),
            new ScriptStep(
                name: 'Clear caches',
                command: "cd {$this->path} && php artisan config:clear && php artisan cache:clear && php artisan route:clear",
                critical: false,
            ),
            new ScriptStep(
                name: 'Optimize',
                command: "cd {$this->path} && php artisan optimize",
                critical: false,
            ),
        ];
    }

    public function validate(): void
    {
        if (empty($this->path)) {
            throw new \InvalidArgumentException('Application path is required');
        }
    }
}

Provision Node.js Server

A script that provisions a fresh server with Node.js, PM2, and Nginx:

app/Scripts/ProvisionNodeServer.php
use Serversinc\SshRunner\Scripts\BaseScript;
use Serversinc\SshRunner\Scripts\ScriptStep;

class ProvisionNodeServer extends BaseScript
{
    public function steps(): array
    {
        return [
            new ScriptStep(
                name: 'Update package list',
                command: 'apt-get update',
            ),
            new ScriptStep(
                name: 'Install Node.js',
                command: 'curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs',
            ),
            new ScriptStep(
                name: 'Install PM2 globally',
                command: 'npm install -g pm2',
            ),
            new ScriptStep(
                name: 'Install Nginx',
                command: 'apt-get install -y nginx',
            ),
            new ScriptStep(
                name: 'Enable Nginx on boot',
                command: 'systemctl enable nginx',
                critical: false,
            ),
        ];
    }
}

Advanced Usage

Connection Reuse

Create a connection once and execute multiple pipelines on it. This reuses the same underlying SSH connection:

routes/web.php
use Serversinc\SshRunner\SshConnection;

$connection = SshConnection::for($server);

$result1 = $connection->pipeline()
    ->run(new Action1())
    ->execute();

$result2 = $connection->pipeline()
    ->run(new Action2())
    ->execute();

All Factory Methods

The SshRunner class provides several factory methods:

routes/web.php
use Serversinc\SshRunner\SshRunner;

// Create a connection
$connection = SshRunner::connect($server);

// Create a pipeline directly
$pipeline = SshRunner::pipeline($server);

// Execute a single action
$result = SshRunner::run($server, new SomeAction());

// Execute a script directly
$result = SshRunner::script($server, new DeployWordPressSite(...));

Testing

The package ships with a full test suite built on Orchestra Testbench. Tests are split into unit tests (no SSH required) and feature tests (require a live SSH target).

Running the Test Suite

To run all tests on the host machine:

terminal
composer test

Docker Test Environment

Feature tests SSH into a real server. The repository includes a Docker Compose stack that spins up an Alpine-based SSH server locally on port 2222. The stack is defined in docker-compose.yml and uses three services:

ServicePurpose
ssh-keygen Generates an RSA keypair once in docker/ssh-keys/ before the server starts
ssh-server Alpine container running sshd on port 22, mapped to host port 2222
dev Optional PHP development container with dependencies pre-installed

Start the SSH Server

terminal
docker compose up -d ssh-server

On first run, the ssh-keygen service creates docker/ssh-keys/id_rsa and docker/ssh-keys/id_rsa.pub. The SSH server copies the public key into /home/testuser/.ssh/authorized_keys and disables password authentication.

SSH Server Image

The server image is built from the included Dockerfile.ssh-server:

  • Base image: alpine:latest
  • Packages installed: openssh-server, openssh-client, netcat-openbsd, sudo, bash
  • User: testuser (shell: /bin/sh)
  • Auth: key-based only, no password auth, root login disabled
  • Exposed port: 22 (mapped to host 2222)

Test Environment Variables

Integration tests read connection details from environment variables:

.env.testing
SSH_TEST_HOST=127.0.0.1
SSH_TEST_PORT=2222
SSH_TEST_USER=testuser
SSH_TEST_KEY_PATH=docker/ssh-keys/id_rsa

The test suite uses an IntegrationTestServer fixture that implements SshServer and reads these values via getenv() with sensible defaults. If the SSH server is not running, feature tests that require a live connection will fail.

Verify the Server

Before running tests, confirm the container is healthy:

terminal
docker compose ps
ssh -p 2222 -i docker/ssh-keys/id_rsa -o StrictHostKeyChecking=no testuser@localhost echo "SSH is working"

Stop the Environment

terminal
docker compose down