cuonggt / bosun
Provision servers and deploy Laravel applications with zero downtime, straight from artisan.
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- laravel/prompts: ^0.1.24|^0.2|^0.3
- phpseclib/phpseclib: ^3.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
This package is auto-updated.
Last update: 2026-07-11 15:44:30 UTC
README
A bosun (boatswain) is the crew member who provisions, maintains, and readies the ship before it sails. This package does the same for your Laravel app.
Provision servers and deploy Laravel applications with zero downtime — straight from artisan.
This package gives you two commands:
| Command | What it does |
|---|---|
php artisan setup |
Provisions a fresh Ubuntu server: PHP-FPM, Nginx, Composer, Node, Redis, Supervisor, Certbot, a firewall and a non-root deploy user. |
php artisan deploy |
Deploys your app with zero downtime using timestamped releases, shared .env/storage, atomic symlink swaps and automatic rollback-ready release pruning. |
php artisan rollback |
Instantly reverts to the previous release (or a specific one) with the same atomic symlink swap. |
php artisan secure |
Enables HTTPS by obtaining a Let's Encrypt certificate (Certbot) and adding an HTTP→HTTPS redirect. Certs auto-renew. |
php artisan status |
Shows the deployed release and commit, .env presence, service health, queue workers and disk usage. |
php artisan logs |
Tails a log from the server — Laravel, Nginx, queue worker or PHP-FPM — with optional live --follow. |
php artisan ssh |
Opens an interactive SSH shell on the server, dropped into the current release. |
It talks to your servers over SSH using phpseclib — so provisioning and deploys need no local ssh binary, and the whole thing is unit-tested against a fake connection. (The one exception is php artisan ssh, which shells out to your real ssh client for an interactive terminal.)
Requirements
- PHP 8.1+
- Laravel 10, 11, 12 or 13
- A target server running Ubuntu 22.04 or 24.04 that you can reach over SSH as
root(most cloud providers give you this on a fresh box).setuprefuses to run on anything else.
Installation
composer require cuonggt/bosun
The service provider is auto-discovered. Publish the config file:
php artisan vendor:publish --tag=bosun-config
This creates config/bosun.php.
Configuration
Everything is driven by config/bosun.php, which reads from your .env. A minimal setup:
DEPLOY_HOST=203.0.113.10 DEPLOY_USER=deployer DEPLOY_KEY=~/.ssh/id_rsa DEPLOY_DOMAIN=example.com DEPLOY_REPOSITORY=git@github.com:acme/app.git DEPLOY_BRANCH=main DEPLOY_PATH=/home/deployer/app
Each server is an entry under servers in the config file, so you can define production, staging, and more. Key options:
| Option | Description | Default |
|---|---|---|
host / port |
Where to connect. | — / 22 |
username |
The deploy user — created during setup, connected as during deploy. |
deployer |
key / passphrase / password |
Auth. A readable key file wins; otherwise the password is used. | ~/.ssh/id_rsa |
domain |
Server name for the generated Nginx site. | — |
ssl_email |
Email for the Let's Encrypt certificate (secure). |
— |
deploy_path |
Where the app lives on the server. {application} is substituted. |
/home/deployer/{application} |
php / node |
Stack to provision. | 8.3 / 20 |
App-wide options (outside servers): repository, branch, database, cloudflare, shared_files, shared_dirs, keep_releases, build_assets, queue, and hooks. Set cloudflare to true if the site sits behind Cloudflare, so Nginx logs the real visitor IP.
Provisioning a server
Point the package at a fresh server and run:
php artisan setup production
By default it connects as root (override with --user). It will:
- Update and upgrade apt, then install base tooling (a build toolchain for native npm/pecl modules, git,
jq,cron, etc.). - Harden SSH to key-only authentication (password login is disabled).
- Install PHP (with the common Laravel extensions, including the MySQL/Postgres/SQLite PDO drivers), Composer, Nginx, Redis, Supervisor, Node and Certbot.
- Create the unprivileged deploy user, authorizing the same SSH key you connected with (add another with
--key). - Generate an SSH deploy key for that user and trust your Git host, so it can clone a private repo (see below).
- Grant that user passwordless permission to reload PHP-FPM/Nginx and control Supervisor — nothing more.
- Configure the firewall (UFW: SSH + HTTP/HTTPS), install fail2ban, and enable automatic security updates.
- Lay out the deploy directory, write the Nginx site and the Supervisor queue worker, and register Laravel's scheduler (
schedule:runevery minute via/etc/cron.d), then start everything. When a domain is set, a catch-all server rejects requests that don't match it (so the app never answers on the bare IP or a spoofedHost).
Every step is idempotent, so re-running setup to add an extension or change a setting is safe. On a server bosun has already provisioned, it tells you when and asks before proceeding (skipped with --no-interaction).
Private repositories
Use an SSH repository URL (git@github.com:you/app.git). At the end of setup, bosun prints the deploy user's public key:
Add this read-only deploy key to your Git repository, then deploy:
ssh-ed25519 AAAA… bosun-app@203.0.113.10
Register it as a read-only deploy key (GitHub: Settings → Deploy keys; GitLab: Settings → Repository → Deploy keys), then php artisan deploy. The key is per-server, read-only, and regenerated only if absent — so re-running setup never invalidates a key you've already registered.
Databases are out of scope — with one exception. bosun installs the MySQL/Postgres/SQLite PDO drivers but does not provision a database server; point your app at a managed or external database via
DB_*in the server'sshared/.env. For SQLite, setdatabaseto"sqlite"(orDEPLOY_DATABASE=sqlite) and bosun keepsdatabase/database.sqliteinshared/— symlinked into each release and writable by the web server — so it persists across deploys.
php artisan setup production --user=root --key=~/.ssh/deploy_key.pub
HTTPS
Certbot is installed during setup. Once your domain's DNS points at the server (the Let's Encrypt challenge needs it), turn on HTTPS:
php artisan secure production # obtains the cert + enables HTTP→HTTPS redirect php artisan secure production --www # also cover www.<domain>
Set ssl_email (or DEPLOY_SSL_EMAIL) first — Let's Encrypt requires it for expiry notices. The command is idempotent, and Certbot's timer renews the certificate automatically thereafter. Re-running setup rewrites the Nginx site but re-applies an existing certificate, so it won't drop HTTPS.
Deploying
php artisan deploy production
This performs a zero-downtime deploy:
/home/deployer/app/
├── current -> releases/20260627T120000 # atomic symlink
├── releases/
│ ├── 20260627T120000/
│ └── … # previous releases kept for rollback
└── shared/
├── .env # persists across deploys
└── storage/ # persists across deploys
The sequence:
- Clone the repo into a fresh, timestamped release and record the commit in a
REVISIONfile. - Symlink shared files/dirs (
.env,storage) into the release, and validateAPP_KEY— a missing or malformed key fails the deploy immediately instead of surfacing as a runtime 500. composer install --no-dev, then (unless--no-build) build front-end assets with the package manager the repo's lockfile calls for — npm, yarn, pnpm or bun.storage:link, cache config/routes/views/events, and runphp artisan migrate --force.- Atomically swap the
currentsymlink to the new release. - Reload PHP-FPM (to refresh OPcache) and restart queue workers.
- Prune old releases beyond
keep_releases. - Probe the app through Nginx from the server itself and report the HTTP status — a non-2xx/3xx answer prints a warning with the
rollbackcommand ready to paste (the deploy itself still succeeds; you judge).
Because the symlink is swapped only after the release is fully built and migrated, no request ever hits a half-deployed app.
The build step auto-detects the package manager from the committed lockfile — package-lock.json → npm, yarn.lock → yarn, pnpm-lock.yaml → pnpm, bun.lockb/bun.lock → bun. yarn and pnpm work out of the box (provisioning enables Corepack, which ships with Node); bun must be installed on the server if you use it (e.g. via a deploy before hook or manually).
Deploys (and rollbacks) take a per-app lock on the server, so two runs can't race each other — a concurrent attempt fails immediately, reporting when the other run started. If a deploy crashed and left a stale lock, re-run with --force to break it (deploy --force / rollback --force). A deploy that fails partway also removes its half-built release, so crashed deploys never leak disk (the active release is never touched).
First deploy
On the very first deploy there's no .env yet, so the package seeds shared/.env from the first of these that exists in your repo: .env.<environment> (the environment is the server name, e.g. .env.production), then .env, then .env.example. Commit a .env.production (in your private repo) with the real APP_KEY, DB creds, etc. and the app is configured from the first deploy — otherwise bosun seeds a template, skips migrations and queue:restart (both need a configured, migrated app), and reminds you to fill it in on the server:
ssh deployer@203.0.113.10 nano /home/deployer/app/shared/.env # set APP_KEY, DB creds, etc. exit php artisan deploy production # this run will migrate
Rolling back
php artisan rollback production # back to the previous release php artisan rollback production --release=20260702T090000 # back to a specific release
The current symlink is swapped atomically to the earlier release and PHP-FPM/queue workers are reloaded, so it takes effect instantly. Database migrations are not reversed — if the bad deploy migrated the schema, check the older code is still compatible before rolling back.
Checking status
php artisan status production
Release ......................... 20260702T090000 (9f8e7d6c5b4a)
Releases kept ................................................ 3
shared/.env ............................................ present
nginx ................................................... active
php8.3-fpm .............................................. active
redis-server ............................................ active
supervisor .............................................. active
fail2ban ................................................ active
Queue workers ....................................... 1/1 running
Disk used ............................................ 24% (31G free)
It also warns when a deploy lock is currently held (with when that deploy started).
Tailing logs
php artisan logs production # last 100 lines of the Laravel log php artisan logs production --type=nginx # app / nginx / worker / fpm php artisan logs production --lines=500 php artisan logs production --follow # stream live (Ctrl-C to stop)
Connects as root by default so every log is readable (Nginx and PHP-FPM logs aren't group-readable by the deploy user); override with --user.
Opening a shell
php artisan ssh production # shell as the deploy user, in the current release
php artisan ssh production --user=root
This drops you into a login shell in <deploy_path>/current. Unlike every other command — which talk to the server over phpseclib — ssh shells out to your local ssh binary for a real interactive terminal, so it needs ssh on your PATH (every dev machine has it).
Useful options
php artisan deploy staging # deploy to a different server php artisan deploy production --branch=hotfix php artisan deploy production --no-build # skip front-end asset build php artisan deploy production -v # stream live command output
-v also works on setup, streaming the raw server output for each step.
Queue workers & Horizon
Provisioning writes a Supervisor program for your queue, shaped by the queue config block:
'queue' => [ 'horizon' => false, // run `php artisan horizon` instead of queue:work 'connection' => 'redis', // queue connection (empty = default) 'queue' => 'high,default', // queue names (empty = default) 'processes' => 2, // worker count (ignored under Horizon) 'tries' => 3, 'timeout' => 60, // Supervisor's stop grace scales with this 'memory' => 128, ],
With horizon => true (or DEPLOY_HORIZON=true), Supervisor runs a single Horizon master — which manages its own worker pool from your app's config/horizon.php — and deploys/rollbacks cycle it with horizon:terminate instead of queue:restart. Re-run setup after changing any of these to rewrite the Supervisor program.
Deploy notifications
Announce each deploy's result to Slack and/or a generic webhook:
'notifications' => [ 'slack' => env('DEPLOY_SLACK_WEBHOOK'), // Slack incoming webhook — posts a formatted message 'webhook' => env('DEPLOY_WEBHOOK'), // any URL — receives a JSON payload ],
The webhook payload is { "event": "deploy", "status": "success|failure", "application", "server", "branch" }. Both are opt-in (empty = off) and best-effort — a slow or down endpoint never affects the deploy itself. Notifications are sent from the machine running deploy, so it needs outbound network (fine for local and CI).
Deployment hooks
Run extra commands on the server during a deploy via config/bosun.php:
'hooks' => [ 'before' => ['php artisan down'], // runs in the deploy path, before building 'after' => ['php artisan up'], // runs in the new release ],
How it fits together
SetupCommand ─┐ ┌─ Provisioner ──┐
├─ RemoteCommand (rendering)┤ ├─ RemoteScript ─ Connection (SSH)
DeployCommand ┘ └─ DeploymentRunner ┘
Connectionis a tiny interface (run,put,disconnect). The real one uses phpseclib; tests use an in-memory fake.RemoteScriptorchestrates a sequence of tasks but knows nothing about the console.RemoteCommandrenders those tasks (compact ticks, or streamed output with-v) and reports failures.
That separation is why the provisioning and deployment logic is fully unit-tested without ever opening a socket.
Testing
composer install
composer test
Security notes
- The deploy user is unprivileged. Its only
sudorights are passwordless reloads of PHP-FPM/Nginx and Supervisor control, written to/etc/sudoers.dand validated withvisudo -c. - SSH password authentication is disabled — key-only login. (So provision with a key; a password-only server can't be deployed to.)
- fail2ban is installed to throttle SSH brute-force attempts, and automatic security updates (
unattended-upgrades) are enabled. - Provisioning enables UFW and opens only SSH and HTTP/HTTPS. Nginx hides its version (
server_tokens off), and with a domain set a catch-all server rejects requests for any other host. - For HTTPS, point DNS at the server and run
php artisan secure <server>(see HTTPS).
License
MIT — see LICENSE.md.