Blue/green deploys for Capistrano.
Each app host runs two instances of a systemd template unit — e.g.
myapp-web@blue.service and myapp-web@green.service. One color is active,
receiving traffic from nginx through a live unix-socket symlink; the other is
idle, still holding the previous release, kept warm for instant rollback.
A deploy boots the idle color with the new release, health-checks it over its unix socket, then atomically cuts traffic over by flipping the symlink and reloading nginx. If the health check fails, the new color is stopped and the deploy aborts — the old color never stopped serving.
In your Gemfile:
gem "capistrano-bluey", github: "feedbin/capistrano-bluey"In your Capfile:
require "capistrano/bluey"
install_plugin Capistrano::BlueyThe gem assumes this infrastructure already exists on each host with the
:app role:
- A systemd template unit (e.g.
myapp-web@.service) whose instance name is the color. Each instance must bind its own socket named<bluey_app_server>-%i.sock(e.g.puma-blue.sock,puma-green.sock) in the socket directory. - Each color runs the release at
<deploy_to>/current-<color>(e.g. the unit'sWorkingDirectory=points atcurrent-%i). The gem manages these symlinks; you don't create them. - An nginx upstream pointing at the live symlink,
<bluey_app_server>.sock(e.g.puma.sock), in the socket directory. On the first deploy the gem creates this symlink automatically, pointing at blue. - The deploy user can run
sudo systemctl start/stop/is-activeon the units andsudo systemctl reload nginxwithout a password prompt. - The app exposes an HTTP health-check endpoint that returns 2xx when ready
(Rails 7.1+ ships one at
/up). curlandtimeoutare available on the hosts.
| Setting | Default | Description |
|---|---|---|
bluey_app_server |
(required) | Socket name prefix, e.g. "puma" or "pitchfork". Sockets are named <bluey_app_server>-<color>.sock, with the live symlink at <bluey_app_server>.sock. |
bluey_service_template |
(required) | printf-style systemd unit name with %{color}, e.g. "myapp-web@%{color}.service". |
bluey_health_check_host |
(required) | Value for the Host: header sent with the health-check request. |
bluey_health_check_path |
"/up" |
Path requested over the unix socket during the health check. |
bluey_socket_dir |
#{shared_path}/tmp/sockets |
Directory containing the per-color sockets and the live symlink. |
bluey_health_timeout |
90 |
Seconds to wait for the new color to become healthy before aborting. |
bluey_health_interval |
1 |
Seconds between health-check polls. |
Example deploy.rb:
set :bluey_app_server, "puma"
set :bluey_service_template, "myapp-web@%{color}.service"
set :bluey_health_check_host, "myapp.example.com"The plugin defines tasks but adds no hooks — you decide when the blue/green
cutover runs. The usual approach is to override deploy:restart in
deploy.rb:
namespace :deploy do
task :restart do
invoke "bluey:deploy"
end
end
after "deploy:published", "deploy:restart"Runs in two passes. First pass, per host: restarts the idle unit on the new
release (stop, repoint current-<color>, start) and polls the health-check
endpoint over the unix socket. Second pass, only once every host is
healthy: flips the live symlink and reloads nginx on each host. If any host
fails its health check, that color is stopped and the deploy aborts — traffic
never moved.
Instant rollback: flips the live symlink back to the previous color, reloads
nginx, and repoints current at that color's release. Precondition: the
previous color's unit must still be active and its current-<color> symlink
must exist — i.e. no deploy has happened since. Otherwise the task aborts; use
cap <stage> deploy:rollback instead.
Shows, for every app host: where the live symlink points, and for each color
which release current-<color> points at plus its systemd state:
bluey: app1.example.com
puma.sock -> puma-green.sock
current-blue -> /srv/apps/myapp/releases/20260609111111 [inactive]
current-green -> /srv/apps/myapp/releases/20260610090909 [active]
The live symlink (<bluey_app_server>.sock) is the sole source of truth
for which color is active on a host. Each host resolves its own active/idle
colors independently by reading that symlink — there is no shared state.
Symlink flips are atomic: the new link is created at a temporary name and
renamed over the destination (ln -sfn + mv -fT). A bare ln -sfn would
unlink and recreate the path, leaving a window where a request hitting nginx
gets ENOENT from the upstream socket.
This means the fleet can end up on mixed colors, e.g. after a deploy that
failed partway through cutover. That's not an error: as long as every host is
serving the same release, traffic is consistent. Use bluey:status to confirm
releases match across hosts.
The old color stays running until the next deploy stops it (to reuse its slot for the new release). So the instant-rollback window is "until the next deploy starts."
bluey:flip_backrolls back code only, never migrations. For this to be safe, migrations must follow expand/contract discipline: a deploy may add columns and tables, but must not drop or rename anything the previous release's code depends on. If a deploy includes a destructive migration, accept that flipping back is not safe for that deploy.- If
flip_backis impossible (the previous color was already stopped by a subsequent deploy), usecap <stage> deploy:rollback, which rebuilds the prior release through the normal blue/green flow.
MIT