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:
- Master receives
SIGUSR1 - Master spawns a new worker with the updated code
- New worker starts accepting connections
- Master sends SIGTERM to the oldest worker
- Old worker drains in-flight requests and exits
- 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.