Skip to content

Latest commit

 

History

17 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

capistrano-bluey

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.

Installation

In your Gemfile:

gem "capistrano-bluey", github: "feedbin/capistrano-bluey"

In your Capfile:

require "capistrano/bluey"
install_plugin Capistrano::Bluey

Infrastructure expectations

The 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's WorkingDirectory= points at current-%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-active on the units and sudo systemctl reload nginx without a password prompt.
  • The app exposes an HTTP health-check endpoint that returns 2xx when ready (Rails 7.1+ ships one at /up).
  • curl and timeout are available on the hosts.

Node.js apps

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:

  1. Stale socket files. server.listen(path) fails with EADDRINUSE if the socket file already exists — and the file survives crashes, SIGKILL, and any exit that skips server.close(). bluey:deploy removes 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 needs ExecStartPre=/bin/rm -f (or the app must unlink the path before listening).
  2. Socket permissions. nginx needs write access to the socket to connect. Node creates it with mode 0777 & ~umask; systemd's default umask (0022) yields 0755, which a different user can't connect to. Set UMask=0007 in the unit and add nginx's user to the app's group (alternative: chmod the socket after listen).
  3. Graceful shutdown. Node's default SIGTERM behavior is an abrupt exit that drops in-flight requests. Handle SIGTERM and call server.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.target

The 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 traffic

deploy.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"

Settings

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"

Wiring it into your deploy

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"

Tasks

cap <stage> bluey:deploy

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.

cap <stage> bluey:flip_back

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.

cap <stage> bluey:status

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]

How a deploy works

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.

Rollback semantics

  • bluey:flip_back rolls 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_back is impossible (the previous color was already stopped by a subsequent deploy), use cap <stage> deploy:rollback, which rebuilds the prior release through the normal blue/green flow.
  • With bluey_stop_previous enabled there is no instant-rollback window at all: flip_back's precondition (previous unit still active) never holds, so it always aborts. Use cap <stage> deploy:rollback.

License

MIT

About

Blue/green deploys for Capistrano

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages