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:
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:
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:
<?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 executionssh_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.
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:
| Method | How 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:
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:
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:
| Property | Type | Description |
|---|---|---|
$success | bool | Whether the command exited with code 0 |
$output | string | Standard output from the command |
$errorOutput | string | Standard error from the command |
$exitCode | int | The process exit code |
$action | string | The fully-qualified class name of the action |
$executedAt | CarbonImmutable | When 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:
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:
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:
use Serversinc\SshRunner\SshRunner;
$result = SshRunner::pipeline($server)
->run(new UpdatePackageList)
->run(new InstallPackage('nginx'))
->execute();
PipelineResult
After execution, a PipelineResult is returned:
| Property / Method | Description |
|---|---|
$success | True if every action in the pipeline succeeded |
$results | A Collection of every ActionResult |
$startedAt | When the pipeline started |
$completedAt | When 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:
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
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Human-readable name for the step |
command | string | Yes | The shell command to execute |
rollback | ?string | No | Command to run if this or a later critical step fails |
critical | bool | No | If 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:
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:
$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
ROLLBACKfailure 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.
php artisan ssh:action InstallNginx
This creates app/SSH/Actions/InstallNginx.php extending BaseSshAction:
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.
php artisan ssh:script DeployApp
This creates app/SSH/Scripts/DeployApp.php extending BaseScript:
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().
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.
$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.
$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:
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
| Table | Key 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.
// 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
| Service | Purpose |
|---|---|
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
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 host2222)
Test Environment Variables
Integration tests read connection details from environment variables:
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:
docker compose ps
ssh -p 2222 -i docker/ssh-keys/id_rsa -o StrictHostKeyChecking=no testuser@localhost echo "SSH is working"
Stop the Environment
docker compose down