spatie/scotty
Scotty is a beautiful SSH task runner for running scripted tasks on remote servers. Define tasks and macros in a simple Scotty.sh (bash + annotations), then run them with clear output. Fully compatible with Laravel Envoy as a drop-in replacement.
The Scotty.sh format is plain bash with annotation comments. Every line is real bash, so your editor highlights it correctly and all your existing shell tooling works.
At the top of your file, define which servers you want to connect to:
# [@servers](https://github.com/servers) local=127.0.0.1 remote=deployer@your-server.com
You can define as many as you need:
# [@servers](https://github.com/servers) local=127.0.0.1 web-1=deployer@1.1.1.1 web-2=deployer@2.2.2.2
If your server listens on a non-default SSH port, append it to the host with a colon:
# [@servers](https://github.com/servers) remote=deployer@your-server.com:2222
For more complex SSH options (identity files, jump hosts, ProxyCommand, etc.), put them in ~/.ssh/config under a Host block and reference that host name from [@servers](https://github.com/servers).
A task is just a bash function with a # [@task](https://github.com/task) annotation above it. The on: parameter tells Scotty which server to run it on:
# [@task](https://github.com/task) on:remote
deploy() {
cd /var/www/my-app
git pull origin main
php artisan migrate --force
}
That's the core concept. Everything else builds on this.
You can target multiple servers by separating their names with commas:
# [@task](https://github.com/task) on:web-1,web-2
deploy() {
cd /var/www/my-app
git pull origin main
}
By default, the task runs on each server one after the other.
If you want to run on all servers at the same time, add parallel:
# [@task](https://github.com/task) on:web-1,web-2 parallel
restartWorkers() {
sudo supervisorctl restart all
}
This is handy for things like restarting workers across a cluster, where you don't need to wait for one to finish before starting the next.
For dangerous tasks (like deploying to production), you can require confirmation:
# [@task](https://github.com/task) on:remote confirm="Are you sure you want to deploy to production?"
deploy() {
cd /var/www/my-app
git pull origin main
}
Scotty will ask before running the task. If you say no, it stops.
A macro groups multiple tasks together so you can run them with a single command:
# [@macro](https://github.com/macro) deploy pullCode runComposer clearCache restartWorkers
If the list gets long, you can use the multi-line format:
# [@macro](https://github.com/macro) deploy
# pullCode
# runComposer
# generateAssets
# updateSymlinks
# clearCache
# restartWorkers
# [@endmacro](https://github.com/endmacro)
Run it with scotty run deploy. The tasks execute in the order you listed them. If any task fails, execution stops immediately.
You can define variables at the top of your file, right after the server and macro lines:
BRANCH="main"
REPOSITORY="your/repo"
APP_DIR="/var/www/my-app"
RELEASES_DIR="$APP_DIR/releases"
NEW_RELEASE_NAME=$(date +%Y%m%d-%H%M%S)
These are plain bash variables, so computed values like $(date) work naturally. All variables are available in all tasks.
Top-level variable assignments are evaluated once locally before the first task runs. The captured values are then injected into every task's script. This means a value like NEW_RELEASE_NAME=$(date +%Y%m%d-%H%M%S) produces the same timestamp in every task of the same run, which is what you want for zero-downtime deploys where multiple tasks need to agree on a release directory.
Helper functions defined at the top of the file still run inside each task on the remote server. Side effects in top-level assignments (mkdir, rm, etc.) happen on your local machine, not on the remote. If you need work to happen remotely, put it in a task.
You can also accept variables from the command line by declaring them with # [@option](https://github.com/option). Three forms are supported:
# [@option](https://github.com/option) staging # boolean flag — $STAGING='1' when --staging is passed
# [@option](https://github.com/option) branch=main # value with default — $BRANCH='main' unless overridden
# [@option](https://github.com/option) release-name= # required value — scotty errors if --release-name=... is missing
scotty run deploy --branch=develop --release-name=v42 --staging
The key gets uppercased and dashes become underscores, so --release-name=v42 sets $RELEASE_NAME. Value options also fall back to an environment variable of the same (uppercased) name before using the declared default. See Dynamic options for the full precedence rules.
Any function without a # [@task](https://github.com/task) annotation is treated as a helper. Helpers are available in all tasks:
log() {
echo -e "\033[32m$1\033[0m"
}
# [@task](https://github.com/task) on:remote
deploy() {
log "Deploying..."
cd /var/www/my-app
git pull origin main
}
You can run code at different points during execution. This is useful for things like sending Slack notifications:
# [@before](https://github.com/before)
beforeEachTask() {
echo "Starting task..."
}
# [@after](https://github.com/after)
afterEachTask() {
echo "Task done."
}
# [@success](https://github.com/success)
onSuccess() {
curl -X POST https://hooks.slack.com/... \
-d '{"text": "Deploy succeeded!"}'
}
# [@error](https://github.com/error)
onError() {
curl -X POST https://hooks.slack.com/... \
-d '{"text": "Deploy failed!"}'
}
# [@finished](https://github.com/finished)
onFinished() {
echo "Deploy process complete."
}
[@before](https://github.com/before) and [@after](https://github.com/after) run around each task. [@success](https://github.com/success) and [@error](https://github.com/error) run once at the end depending on whether everything passed. [@finished](https://github.com/finished) always runs, regardless of the outcome.
Here's a full deploy script using all the concepts above:
#!/usr/bin/env scotty
# [@servers](https://github.com/servers) local=127.0.0.1 remote=deployer@your-server.com
# [@macro](https://github.com/macro) deploy
# startDeployment
# cloneRepository
# runComposer
# blessNewRelease
# cleanOldReleases
# [@endmacro](https://github.com/endmacro)
BRANCH="main"
REPOSITORY="your/repo"
APP_DIR="/var/www/my-app"
RELEASES_DIR="$APP_DIR/releases"
CURRENT_DIR="$APP_DIR/current"
NEW_RELEASE_NAME=$(date +%Y%m%d-%H%M%S)
NEW_RELEASE_DIR="$RELEASES_DIR/$NEW_RELEASE_NAME"
# [@task](https://github.com/task) on:local
startDeployment() {
git checkout $BRANCH
git pull origin $BRANCH
}
# [@task](https://github.com/task) on:remote
cloneRepository() {
cd $RELEASES_DIR
git clone --depth 1 git@github.com:$REPOSITORY --branch $BRANCH $NEW_RELEASE_NAME
}
# [@task](https://github.com/task) on:remote
runComposer() {
cd $NEW_RELEASE_DIR
ln -nfs $APP_DIR/.env .env
composer install --prefer-dist --no-dev -o
}
# [@task](https://github.com/task) on:remote
blessNewRelease() {
ln -nfs $NEW_RELEASE_DIR $CURRENT_DIR
sudo service php8.4-fpm restart
}
# [@task](https://github.com/task) on:remote
cleanOldReleases() {
cd $RELEASES_DIR
ls -dt $RELEASES_DIR/* | tail -n +4 | xargs rm -rf
}
If you're coming from Laravel Envoy, here's a quick reference. For the full Blade format documentation, see the Envoy compatibility page.
| Blade format | Scotty.sh format |
|---|---|
[@servers](https://github.com/servers)(['remote' => '1.1.1.1']) |
# [@servers](https://github.com/servers) remote=1.1.1.1 |
[@task](https://github.com/task)('deploy', ['on' => 'remote']) |
# [@task](https://github.com/task) on:remote |
[@endtask](https://github.com/endtask) |
} (end of function) |
[@story](https://github.com/story)('deploy') ... [@endstory](https://github.com/endstory) |
# [@macro](https://github.com/macro) deploy task1 task2 |
{{ $variable }} |
$VARIABLE |
[@setup](https://github.com/setup) PHP block |
Shell $(command) substitution |
[@if](https://github.com/if)($condition) |
Bash if [ condition ] |
How can I help you explore Laravel packages today?