Zero-Downtime Deployment for Ruby

Khimananda Oli 8 min read Programming and Languages
Zero-Downtime Deployment for Ruby

By Khimananda Oli | Last reviewed: August 2026

Achieving zero-downtime deployment for Ruby applications requires coordinating application server restarts, web server buffering, and database schema changes so that no in-flight request is dropped during a release. Many teams still experience brief 502 errors or stuck requests because they treat code deployment and database migration as separate, uncoordinated events. This guide covers the exact configuration patterns for Puma, Nginx, and ActiveRecord that eliminate these gaps in production.

Nginx ProxyOld Worker (v1)Old Worker (v1)New Worker (v2)New Worker (v2)PostgreSQLPhased Restart: Old workers drain, new workers boot simultaneously
Architecture for zero-downtime deployment for Ruby showing concurrent old and new Puma workers behind Nginx during phased restart

How do you configure Puma for zero-downtime deployment for Ruby?

Puma’s phased restart is the cornerstone of zero-downtime deployment for Ruby. Unlike a full restart that kills all workers immediately, a phased restart spawns new workers with the updated code before terminating old ones. This overlap period is what prevents dropped requests. You must configure both the worker count and the preload behavior correctly for this to work reliably.

Essential Puma configuration

Your config/puma.rb must explicitly enable phased restarts and set appropriate worker counts. A common mistake is setting workers equal to CPU cores without accounting for the temporary 2x memory usage during the transition phase.

# config/puma.rb
max_threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count

# Critical for zero-downtime: enables phased restart via SIGUSR1
workers ENV.fetch("WEB_CONCURRENCY", 4)
preload_app!

# Allow new workers to boot before old ones are killed
worker_timeout 60
worker_boot_timeout 120

# Bind to unix socket for Nginx upstream
bind "unix:///var/run/myapp/puma.sock"

on_worker_boot do
  # Re-establish DB connection in each new worker
  ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end

The preload_app! directive is non-negotiable for phased restarts. Without it, Puma cannot fork new workers from an updated master process. The worker_boot_timeout should be set higher than your application’s actual boot time; if your app takes 45 seconds to load, set this to at least 90 seconds to prevent premature worker termination during slow deploys.

Triggering the phased restart

In your deployment script (Capistrano, Kamal, or custom), send SIGUSR1 to the Puma master process after code is deployed but before cleanup:

# In your deploy hook or systemd ExecReload
kill -SIGUSR1 $(cat /var/run/myapp/puma.pid)

# Or with systemd
systemctl reload myapp-puma.service

Monitor pumactl stats or your observability stack to confirm new workers reach ready state before old workers begin shutting down. If you use the four golden signals of monitoring, track saturation specifically during the restart window to catch memory pressure early.

How does Nginx prevent 502 errors during Ruby deployments?

Even with perfect Puma configuration, Nginx can cause 502 Bad Gateway errors if it doesn’t handle the socket transition gracefully. During a phased restart, there is a brief moment when the old socket may be unlinked and the new one isn’t fully accepting connections. Nginx must be configured to retry failed upstream connections transparently.

Nginx upstream configuration

upstream puma_backend {
    server unix:/var/run/myapp/puma.sock fail_timeout=0;
    # Keepalive reduces TCP overhead and helps during transitions
    keepalive 32;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://puma_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        
        # Critical: retry on next upstream if current fails
        proxy_next_upstream error timeout http_502 http_503;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 10s;
        
        # Buffer responses to free workers faster
        proxy_buffering on;
        proxy_buffer_size 8k;
        proxy_buffers 16 8k;
    }
}

The fail_timeout=0 directive tells Nginx never to mark the upstream as permanently failed during transient restart blips. Combined with proxy_next_upstream, this ensures that if a request hits the socket during the exact millisecond it’s being replaced, Nginx immediately retries rather than returning an error to the client. For teams managing multiple services, understanding Nginx vs Apache performance characteristics helps justify why Nginx is preferred for this pattern.

Socket permissions and SELinux/AppArmor

A frequent production issue is the new Puma master creating the socket with different ownership than the old one. Ensure your systemd unit specifies UMask=0002 and that the socket directory is owned by the same user running Puma. On Ubuntu systems with AppArmor, verify the profile allows socket creation in /var/run/myapp/; otherwise, the new worker boots but cannot bind, causing silent failures.

Deploy ScriptPuma MasterNew WorkersOld Workers1. Deploy code + bundle install2. SIGUSR1 (phased restart)3. Fork new workers (v2)4. Report READY5. Graceful shutdown old workers6. Drain complete, exitTotal overlap window: 30–90 seconds depending on boot time
Sequence of operations during zero-downtime deployment for Ruby showing worker lifecycle coordination

How do you run safe database migrations during Ruby deployments?

Database migrations are where most zero-downtime deployment for Ruby strategies fail. If you deploy code that references a column before the migration runs, or remove a column that old workers still need, you get application errors regardless of how smooth your Puma restart is. The solution is strictly backward-compatible migrations executed in a specific order.

The expand-contract pattern

Every destructive schema change must be split across at least two deployments:

  1. Expand: Add new columns or tables without removing anything. Deploy code that writes to both old and new structures.
  2. Migrate data: Backfill existing records into the new structure. This can happen asynchronously via background jobs.
  3. Contract: Once all code reads from the new structure and data is backfilled, remove the old column in a subsequent deploy.
# Migration 1 (Deploy A): Expand
class AddEmailLowerToUsers < ActiveRecord::Migration[7.2]
  def change
    add_column :users, :email_lower, :string
    add_index :users, :email_lower
  end
end

# Application code in Deploy A: Write to both
before_save :sync_email_lower
def sync_email_lower
  self.email_lower = email&.downcase
end

# Migration 2 (Deploy B, after backfill): Contract  
class RemoveLegacyEmailIndex < ActiveRecord::Migration[7.2]
  def change
    remove_index :users, :email # Only after confirming zero reads
  end
end

Never run rails db:migrate as part of the same atomic deploy step as the code release. Run migrations in a pre-deploy hook, verify success, then proceed with the Puma phased restart. For PostgreSQL-specific safety patterns, see PostgreSQL administration essentials which covers lock timeouts and concurrent index creation.

Handling long-running migrations

If a migration takes longer than your deploy timeout, it will block the entire release pipeline. Use CONCURRENTLY for indexes, batch large UPDATE statements, and always set lock_timeout to prevent indefinite blocking:

# Safe concurrent index creation
CREATE INDEX CONCURRENTLY idx_users_email_lower ON users (email_lower);

# Batched updates to avoid long locks
UPDATE users SET email_lower = LOWER(email) 
WHERE id IN (SELECT id FROM users WHERE email_lower IS NULL LIMIT 1000);

What are the trade-offs between deployment strategies for Ruby?

Phased restarts aren’t the only path to zero-downtime deployment for Ruby. Understanding alternatives helps you choose the right approach for your team’s operational maturity and infrastructure constraints.

StrategyDowntime RiskMemory OverheadComplexityBest For
Puma Phased RestartVery Low2x during transitionMediumMost Rails apps on VPS/bare metal
Blue-Green DeployNone2x permanentHighCritical services with budget for duplicate infra
Kubernetes Rolling UpdateLowConfigurable surgeHighContainerized apps on EKS/GKE/AKS
Systemd Socket ActivationNone1x + bufferMediumSingle-server apps needing instant failover

For teams already on Kubernetes, blue-green and canary deploys on Kubernetes provide stronger guarantees than phased restarts alone, at the cost of platform complexity. For single-server setups common among Nepal-based startups, systemd socket activation offers zero-downtime without the memory penalty of keeping two full application sets in RAM.

Phased RestartMemory: 2x peakRisk: Very Low✓ Simple setupBlue-GreenMemory: 2x constantRisk: None✓ Instant rollbackK8s RollingMemory: Surge 25-50%Risk: Low✓ Auto-healingSocket Activ.Memory: 1x + bufRisk: None✓ Single serverVPS / Bare MetalSmall-Medium TeamsMulti-Region CloudEnterprise / FintechEKS / GKE / AKSPlatform TeamsSingle ServerCost-SensitiveChoose based on infrastructure budget, team size, and compliance requirements
Trade-off comparison for zero-downtime deployment for Ruby strategies across memory, risk, and use case

Implementing reliable zero-downtime deployment for Ruby in production

Reliable zero-downtime deployment for Ruby depends on treating your deployment pipeline as a first-class system component, not an afterthought. Start with Puma phased restarts and Nginx retry configuration as your baseline. Adopt the expand-contract migration pattern before attempting any destructive schema changes. Monitor memory saturation during the transition window, and only graduate to blue-green or Kubernetes rolling updates when your traffic volume or compliance requirements demand it. If your team needs help designing a deployment strategy that matches your infrastructure and audit requirements, reach out to discuss your specific setup.

Frequently Asked Questions

It is a release strategy ensuring Ruby applications remain available during updates by running old and new code simultaneously until the transition completes.

Capistrano, Kamal, Puma with phased restarts, and Kubernetes rolling updates are the standard tools for achieving this in modern Ruby environments.

Yes, Puma offers phased restarts that reload workers gradually without dropping active connections when configured correctly.

Capistrano symlinks new releases atomically while keeping the previous version active, allowing instant rollback if health checks fail post-deploy.

No, Heroku dynos restart completely during deploys causing brief downtime unless you use multiple web dynos with preboot enabled.

Nginx buffers requests and retries failed upstream connections during Puma or Unicorn restarts to prevent user-facing errors.

Migrations must be backward compatible since old and new application versions run concurrently during the deployment window.

Kamal uses Docker containers with built-in health checks and rolling deploys, offering more predictable zero-downtime behavior than traditional Capistrano setups.

Premature SIGTERM signals, missing connection draining, or misconfigured load balancer health check intervals typically cause request drops.

Set timeouts to at least 30 seconds to allow in-flight requests to complete before forcing worker termination.

Not necessarily; rolling updates with proper health checks usually suffice for most Ruby web applications without doubling infrastructure costs.

Run continuous HTTP requests with tools like hey or wrk during deployment and confirm zero 5xx responses in logs.

Phased restarts temporarily double worker memory usage since old and new processes coexist during the transition period.

Sidekiq requires quiet signals before shutdown to finish processing jobs; otherwise active jobs fail during worker replacement.

Running two app versions simultaneously can expose inconsistent authentication states if session schemas change between releases without migration compatibility.