Skip to main content

Rails zero-downtime deployment guide

TL;DR

Complete guide to deploying Rails applications with zero downtime using Puma phased restarts, safe migrations, and health verification.

Key facts

Topology
Rails zero-downtime on Linux

TL;DR

Zero-downtime Rails deployment means deploying new code without dropping a single request. This requires Puma's phased restart, backward-compatible database migrations, pre-compiled assets, and a health check endpoint to verify the new code is serving correctly.

Puma phased restart

Puma's phased restart (SIGUSR1) replaces workers one at a time while the master process keeps the listening socket open:

  1. Master receives SIGUSR1
  2. Master spawns a new worker with the updated code
  3. New worker starts accepting connections
  4. Master sends SIGTERM to the oldest worker
  5. Old worker drains in-flight requests and exits
  6. Repeat until all workers are replaced
# Trigger phased restart
kill -USR1 $(cat /var/www/myapp/tmp/pids/server.pid)

# Or via pumactl
bundle exec pumactl -S tmp/pids/puma.state phased-restart

# Or via systemd (if ExecReload is configured)
sudo systemctl reload puma

Requirement: preload_app! must be disabled for phased restarts, or you must use Puma 6+ which supports phased restarts with preload_app! via the fork_worker option:

# config/puma.rb
fork_worker
preload_app!

Safe database migrations

The most common cause of downtime during Rails deployments is a migration that locks a table or is incompatible with the running code. Use the strong_migrations gem to catch dangerous migrations at development time:

bundle add strong_migrations

strong_migrations blocks operations like:

  • Removing a column (old code still references it)
  • Renaming a column or table
  • Adding a non-nullable column without a default
  • Adding an index without CONCURRENTLY

Safe migration strategy

Follow a multi-deploy approach for breaking changes:

Deploy 1: Add the new column (nullable or with default)

class AddMiddleNameToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :middle_name, :string
  end
end

Deploy 2: Update code to write to both old and new columns

Deploy 3: Backfill data and switch reads to the new column

Deploy 4: Remove the old column

For adding indexes on large tables:

class AddIndexToOrdersStatus < ActiveRecord::Migration[7.1]
  disable_ddl_transaction!

  def change
    add_index :orders, :status, algorithm: :concurrently
  end
end

Asset precompilation

Precompile assets before restarting Puma so the new workers serve the correct asset fingerprints immediately:

RAILS_ENV=production bundle exec rails assets:precompile

If you use Webpacker/Shakapacker, this compiles both Sprockets and Webpack assets.

Deployment script

A complete zero-downtime deployment sequence:

#!/bin/bash
set -euo pipefail

APP_DIR="/var/www/myapp"
cd "$APP_DIR"

echo "==> Pulling latest code"
git fetch origin main
git reset --hard origin/main

echo "==> Installing dependencies"
bundle config set --local deployment true
bundle config set --local without 'development test'
bundle install

echo "==> Running migrations"
RAILS_ENV=production bundle exec rails db:migrate

echo "==> Precompiling assets"
RAILS_ENV=production bundle exec rails assets:precompile

echo "==> Phased restart"
sudo systemctl reload puma

echo "==> Verifying health"
sleep 5
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/up)
if [ "$HTTP_CODE" != "200" ]; then
    echo "Health check failed — investigate immediately"
    exit 1
fi

echo "==> Deployment complete"

Capistrano and Kamal

For more sophisticated deployments, use Capistrano or Kamal:

Capistrano creates symlinked releases with rollback capability:

bundle add capistrano capistrano-rails capistrano-bundler capistrano3-puma
bundle exec cap production deploy

Kamal (from the Rails team) deploys via Docker with zero-downtime container swaps and built-in health checks:

kamal setup
kamal deploy

Health check endpoint

Rails 7.1+ includes a built-in health check at /up. For older versions, add one:

# config/routes.rb
get "/up", to: proc { [200, {}, ["OK"]] }

Blue-green with nginx upstream

For maximum safety, run both old and new versions simultaneously:

upstream rails_blue {
    server unix:///var/www/myapp-blue/tmp/sockets/puma.sock;
}

upstream rails_green {
    server unix:///var/www/myapp-green/tmp/sockets/puma.sock;
}

server {
    location / {
        proxy_pass http://rails_blue;  # Switch to rails_green after deploy
    }
}

After deploying to the green environment and verifying health, update the nginx config to point to green and reload:

sudo nginx -t && sudo systemctl reload nginx

With Reflex

Reflex orchestrates zero-downtime Rails deployments end-to-end: pulling code, running migrations safely, precompiling assets, executing phased Puma restarts, and verifying health — with automatic rollback if the health check fails. It replaces manual deployment scripts with audited, repeatable playbooks. See How it works.