SSHKit: running the same command on many servers, once
A toolkit for deploying code and assets to servers in a repeatable, testable, reliable way.
At a glance
- What is it?
- SSHKit is a Ruby toolkit for running commands on one or more servers through a structured DSL, with parallel, grouped and sequential runners, file transfer, and context helpers for users, directories and environment. It suits a team writing its own deployment scripts, and it is a library rather than a deployment tool with a config file.
- Who is it for?
- Adopt SSHKit when you want to write deployment logic as Ruby instead of filling in a tool's configuration, because the DSL gives you a real control flow, a single synchronisation point per on block, and a runner you can change from parallel to grouped or sequential without rewriting the block. Do not adopt it if you want a batteries included deploy tool, since SSHKit deliberately has no notion of releases, stages or rollback.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 91 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One block, many hosts, one synchronisation point
SSHKit is a toolkit for running commands in a structured way on one or more servers, and the whole design is visible in a single example.
require 'sshkit'
require 'sshkit/dsl'
include SSHKit::DSL
on ["1.example.com", "2.example.com"], in: :sequence do |host|
puts "Now executing on #{host}"
within "/opt/sites/example.com" do
as :deploy do
with RAILS_ENV: 'production' do
execute :rake, "assets:precompile"
execute :rails, "runner", "S3::Sync.notify"
end
end
end
endThree things are happening at once in that snippet, and each is a deliberate choice. The on call names the hosts and takes an in: :sequence option, because by default SSHKit runs the commands on all hosts in parallel. The block runs per host and receives the host object, so a per-host value like the hostname is available. And the context helpers nest: within a directory, as a user, with an environment variable, and the innermost execute calls inherit all three.
The synchronisation rule is the part that matters most in practice. The on block is the unit of synchronisation, and one on block waits for all servers to complete before it returns. The README's backup example makes the guarantee explicit: by the time the second on block runs, all the tar commands have either succeeded or an exception has been raised. That is the property that lets you write a two-stage deploy without a mutex or a sleep.
The alternative to on with hosts is passing :local, which runs the commands locally instead of over SSH.
execute, test and capture, and the rule about the first argument
All backends support three ways of running a command, and the difference between them is what you get back.
execute runs a command and raises if it fails. test runs a command and gives you a boolean, which is how you make a decision on the server. capture runs a command and gives you its output as a string, with whitespace stripped by default, and you can pass strip: false to keep it:
on '1.example.com' do
if test("[ -f somefile.txt ]")
execute(:cp, 'somefile.txt', 'somewhere_else.txt')
end
ls_output = capture(:ls, '-l')
endThere is a subtlety in the first argument that is documented prominently and will bite you if you miss it. If the first parameter is not a Symbol, SSHKit assumes you want to execute the raw command, and in that case the as, within and with methods, SSHKit.config.umask and the command map have no effect.
That is a big behavioural fork. Passing :ls as a Symbol gives you the command map, the user and directory context, the umask, and the environment handling. Passing a string like "echo DEAD" bypasses all of it and runs the string as given. The rule exists because raw commands are sometimes genuinely what you want, and because a Symbol lets the toolkit assemble the command line correctly for the platform.
The error behaviour is also worth knowing. By default any command that exits with a status other than 0 raises an error, and the body of the message is whatever was written to stdout by the process. The README explains the 1>&2 redirect used in the guard checks, which exists so the diagnostic text lands on stderr and therefore ends up inside the raised error rather than being lost in the output stream.
Context helpers that check before they act
The context helpers are where SSHKit differs from a thin wrapper around system calls, and the mechanism is that each one guards its block with a check.
as(user: 'un', group: 'grp') { execute('cmd') } # Executes sudo -u un -- sh -c 'sg grp cmd'
within('/somedir') { execute('cmd') } # Executes cd /somedir && cmd
with(env_var: 'value') { execute('cmd') } # Executes ENV_VAR=value cmd
SSHKit.config.umask = '077' # All commands are executed with umask 077 && cmdas, within and with are nestable in any order, repeatable, and stackable. What makes them more than string concatenation is the guard. In the case of within, an error-raising check is made that the directory exists, and the check is implemented as:
if test ! -d <directory>; then echo "Directory doesn't exist" 2>&1; false; fi
For as, the implementation is a simple call to sudo -u with a check for success:
if ! sudo -u <user> whoami > /dev/null; then echo "Can't switch user" 2>&1; false; fi
So a within block fails with a clear message if the directory is missing, and an as block fails if the switch is not permitted, rather than letting the actual command fail with a less obvious error. For a deployment tool that runs unattended, that difference is the whole point: the failure names the precondition, not the symptom.
There are also convenience helpers for Ruby and Rails applications, where runner expands to execute(:rails, "runner", ...) and rake expands to execute(:rake, ...). And verbosity is controllable per command, so you can raise the level of a command with verbosity: :ERROR or hide a command from the output with verbosity: :DEBUG.
Three runners, and the case for not using the default
The runner is what decides how the hosts in an on block are worked through, and there are three of them.
on(in: :parallel) { ... }
on(in: :sequence, wait: 5) { ... }
on(in: :groups, limit: 2, wait: 5) { ... }The default is in: :parallel with no limit, and the README is direct about when that is wrong. If you have 400 servers this might be a problem, and you might be better off changing to groups or sequence.
The reason each mode exists is a specific operational concern. Groups were designed to relieve problems such as mass Git checkouts, where you rely on a contested resource that you do not want to overload by hitting it too hard. Sequential runs were intended for rolling restarts, among other similar use cases. The wait option adds a delay between steps, which in the rolling restart case is how you avoid replacing the whole fleet at once.
The default runner can be set globally rather than per call, through SSHKit.config.default_runner, and it accepts a symbol or a custom runner object:
SSHKit.config.default_runner = :parallel
SSHKit.config.default_runner = :sequence
SSHKit.config.default_runner = :groups
SSHKit.config.default_runner = MyRunner # A custom runnerAnd the configuration for the runner can be set separately, so you set the mode and its options in one place:
# Set the runner and then the config for the runner
SSHKit.config.default_runner = :sequence
SSHKit.config.default_runner_config = { wait: 5 }
# Or just set everything once
SSHKit.config.default_runner_config = { in: :sequence, wait: 5 }The ability to plug in a custom runner object is the escape hatch, and it is how a team implements something SSHKit has no opinion on, such as a runner that respects a per-host maintenance window.
File transfer, and what the repository says about its own direction
All backends support upload! and download! for transferring files. For the remote backend the transfer uses scp by default, with sftp also supported, and the download method takes a log_percent option for progress reporting:
on '1.example.com' do
upload! 'some_local_file.txt', '/home/some_user/somewhere'
download! '/home/some_user/some_remote_file.txt', 'somewhere_local', log_percent: 25
endThat is a small API, and it is worth noticing that the library is only the transport and the context. There is nothing in SSHKit about releases, stages, hooks or rollback, because the project is explicitly a toolkit for running commands rather than a deployment pipeline. The description in the repository says it is a toolkit for deploying code and assets to servers in a repeatable, testable, reliable way, and the word toolkit is doing the work.
The repository structure shows a project with real process around it: a CHANGELOG, a CONTRIBUTING guide, a RELEASING guide, a Dangerfile, an EXAMPLES.md and an FAQ.md, a Rakefile, a rubocop configuration with a todo file, yard documentation options, and a test/ directory. There is also BREAKING_API_WISHLIST.md, which is the file to read first if you are considering depending on the API, because a project that publishes a wishlist of planned breaking changes is telling you exactly what the next major version will change.
There is a docker-compose.yml with a single ssh_server service built from a.docker directory and exposed on port 2122, which is the test fixture for exercising the remote backend without a real server. The gemspec and Gemfile sit alongside, and the release history shows 1.25.1 on 2026-07-24, 1.25.0 in December 2025 and 1.24.0 in February 2025, with the last push on 2026-07-25. The licence is MIT.
Editorial conclusion
Adopt SSHKit when you want to write deployment logic as Ruby instead of filling in a tool's configuration, because the DSL gives you a real control flow, a single synchronisation point per on block, and a runner you can change from parallel to grouped or sequential without rewriting the block. Do not adopt it if you want a batteries included deploy tool, since SSHKit deliberately has no notion of releases, stages or rollback. Before you build on it, read BREAKING_API_WISHLIST.md, since the project tracks its own planned API changes, and check the runner defaults against your server count, because the default is unbounded parallel execution.
Frequently asked questions
How do I run a command on several servers with SSHKit?
Use the on method with the hosts and a block, for example `on ["1.example.com", "2.example.com"], in: :sequence do |host|`. By default SSHKit runs the commands on all hosts in parallel, and one on block waits for all servers to complete before it returns.
What is the difference between execute, test and capture in SSHKit?
All backends support them: execute runs a command and raises on failure, test returns a boolean, and capture returns the output as a string with whitespace stripped unless you pass strip: false. All three are called inside an on block, and the first argument should be a Symbol for the command name if you want the as, within, with and umask behaviour to apply.
How do I control how SSHKit runs commands across many hosts?
The in option on the on call selects the runner: :parallel with no limit by default, :sequence for rolling restarts, and :groups with a limit for contested resources such as mass Git checkouts. The default can be set globally with SSHKit.config.default_runner, and its options with SSHKit.config.default_runner_config.
Does SSHKit check the directory or user before running a command?
Yes. The as and within methods guard their block with a check, so within verifies the directory exists with a test on the directory, and as verifies the user switch with sudo -u and whoami, raising an error with a clear message if either fails.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/capistrano-sshkit)Was this useful?