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 the bluey
tasks target (every host by default; see bluey_roles):
- 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.
bluey doesn't care what's behind the socket — a Node server listening on a unix socket works with the flow above unchanged. But Node skips three things puma does automatically, so the unit and the app must cover them:
- Stale socket files.
server.listen(path)fails withEADDRINUSEif the socket file already exists — and the file survives crashes,SIGKILL, and any exit that skipsserver.close().bluey:deployremoves the idle color's socket before starting it, which covers deploys — but a systemd auto-restart (Restart=on-failure) happens without the gem in the loop, so the unit needsExecStartPre=/bin/rm -f(or the app must unlink the path before listening). - Socket permissions. nginx needs write access to the socket to
connect. Node creates it with mode
0777 & ~umask; systemd's default umask (0022) yields0755, which a different user can't connect to. SetUMask=0007in the unit and add nginx's user to the app's group (alternative: chmod the socket after listen). - Graceful shutdown. Node's default
SIGTERMbehavior is an abrupt exit that drops in-flight requests. HandleSIGTERMand callserver.close()— the old color may still be draining requests when the next deploy stops it.
Example template unit (myapp-web@.service):
[Unit]
Description=myapp web (%i)
After=network.target
[Service]
Type=simple
User=deploy
Group=deploy
UMask=0007
Environment=NODE_ENV=production
Environment=SOCKET=/srv/apps/myapp/shared/tmp/sockets/node-%i.sock
WorkingDirectory=/srv/apps/myapp/current-%i
ExecStartPre=/bin/rm -f ${SOCKET}
ExecStart=/usr/bin/node server.js
Restart=on-failure
[Install]
WantedBy=multi-user.targetThe socket path follows the required <bluey_app_server>-%i.sock naming —
with bluey_app_server set to "node", the sockets are node-blue.sock
and node-green.sock, and the live symlink is node.sock.
App side: listen on the socket from the environment, exit cleanly on
SIGTERM, and serve the health-check endpoint:
const server = app.listen(process.env.SOCKET)
process.on("SIGTERM", () => {
server.close(() => process.exit(0))
})
// GET /up must return 2xx once the app is ready to serve trafficdeploy.rb:
set :bluey_app_server, "node"
set :bluey_service_template, "myapp-web@%{color}.service"
set :bluey_health_check_host, "myapp.example.com"
set :bluey_health_check_path, "/up"| 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. |
bluey_stop_previous |
false |
When true, stop the previous color's unit after cutover — reclaims its memory, forfeits instant rollback (bluey:flip_back). |
bluey_roles |
:all |
Role filter for every bluey task — which hosts run the blue/green units. A symbol or an array of symbols, e.g. :web or [:app, :worker]. |
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. With bluey_stop_previous enabled, each host also stops the
previous color's unit after its cutover, waiting for the graceful drain.
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 targeted 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." With bluey_stop_previous enabled, the old color is instead
stopped right after cutover: its memory is reclaimed immediately, the drain
relies on the app's graceful SIGTERM handling (see the infrastructure
expectations), and the instant-rollback window closes at cutover.
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. - With
bluey_stop_previousenabled there is no instant-rollback window at all:flip_back's precondition (previous unit stillactive) never holds, so it always aborts. Usecap <stage> deploy:rollback.
MIT