Isometric illustration of a router sending traffic to a new dark app container with a green check, while the old white container waits on a separate platform

A zero downtime deploy in Rails is two handoffs, not one. The new release has to prove it can serve requests before the proxy sends it traffic, and the old release has to finish every request it already accepted before it exits. The health check controls the first handoff; Puma’s graceful shutdown and the proxy’s drain timeout control the second. This post walks through the exact sequence, what /up should and shouldn’t check, how readiness differs from liveness, and the timeouts that decide whether a deploy drops requests.


TL;DR

  • A zero downtime deploy starts the new release, waits until its health check passes, switches the proxy to it, and only then stops the old release.
  • The deploy gate is a readiness check: “can this process serve a request right now?” Keep it shallow. Rails 7.1+ already ships one at /up.
  • Requests are dropped on the way out, not in: the old Puma must receive SIGTERM, stop accepting, and finish in-flight requests before it is killed.
  • Every timeout in the chain must be longer than the one inside it: proxy drain ≥ Puma shutdown ≥ your slowest request.
  • Health checks don’t protect you from incompatible migrations or missing assets; those need their own two-step deploys.

Table of Contents

What happens during a zero downtime deploy?

A zero downtime deploy runs the old and new release side by side for a few seconds and moves traffic only when the new one is ready. Every platform that offers it, from Kamal to Render to Enkihost, follows roughly the same five steps.

Timeline of a zero downtime Rails deploy: the new release boots and passes its health check, the proxy switches traffic, the old release receives SIGTERM, drains in-flight requests and exits

  1. Start the new release next to the old one. The old release keeps serving all traffic.
  2. Poll the new release’s health check until it returns 200, or give up after a deploy timeout. If it never passes, the deploy fails and the old release stays live. This is the safety net.
  3. Switch the proxy. New requests go to the new release. Requests already in progress on the old release keep running.
  4. Drain the old release. The proxy (or the platform) waits for the old release’s open requests to finish, up to a drain timeout.
  5. Stop the old release with SIGTERM, then SIGKILL if it hasn’t exited after a grace period.

Steps 1–3 are about readiness: nobody sees a release that can’t answer. Steps 4–5 are about draining: nobody’s request is cut off halfway. Most “we have health checks but deploys still throw 502s” incidents are in steps 4 and 5.

Pro Tip: the health check never sees real traffic. A release can pass /up and still fail every real request (a bad credential, a broken view). Watch the error rate for a few minutes after each switch; a health check isn’t a smoke test.

Readiness vs liveness: which check gates a deploy?

Readiness gates the deploy; liveness restarts stuck processes. They answer different questions, and mixing them up either lets broken releases through or restarts healthy ones.

Check Question it answers Who acts on it What happens on failure What it should test
Startup Has the process finished booting? Orchestrator (Kubernetes startupProbe) Keep waiting, then restart Same as liveness, with a longer budget
Readiness Can it serve a request right now? Proxy / load balancer, deploy tool Take it out of rotation; during a deploy, don’t switch App booted, router answers
Liveness Is the process hopelessly stuck? Orchestrator / supervisor Kill and restart the process Process responds at all
Dependency (deep) Are Postgres, Redis, the queue reachable? Humans, via monitoring and alerts Page someone Database query, Redis PING, queue latency

The deploy gate should answer “did this release boot correctly?” If Postgres goes away for a minute, failing every release’s readiness check doesn’t help anyone: the old release can’t reach the database either, and a deploy blocked on a database outage just delays the fix. That’s why the deploy check is shallow and the dependency check feeds monitoring. The Rails health check endpoint guide builds the deep /health/ready endpoint for monitoring; this post is about the shallow one the deploy waits on.

What should the Rails health check endpoint return?

It should return 200 as soon as the app has booted and can route a request, and nothing more. Rails 7.1 and later generate exactly this as rails_health_check, mounted at /up:

# config/routes.rb
Rails.application.routes.draw do
  get "up" => "rails/health#show", as: :rails_health_check

  # ...your routes
end

Rails::HealthController#show returns 200 with a small green HTML page if the app booted, and 500 if an exception was raised while booting. It doesn’t touch the database, which is what you want for a deploy gate.

Two production settings commonly break it, because the deploy tool calls /up over plain HTTP on an internal IP:

# config/environments/production.rb
Rails.application.configure do
  config.assume_ssl = true
  config.force_ssl = true

  # Don't redirect the health check to https; the proxy calls it over http.
  config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }

  # Allow your domains, but let the health check through from any Host header
  # (the deploy tool calls it by container IP).
  config.hosts = ["example.com", /.*\.example\.com/]
  config.host_authorization = { exclude: ->(request) { request.path == "/up" } }

  # Keep health check polling out of the logs (Rails 8.0+).
  config.silence_healthcheck_path = "/up"
end

Without the ssl_options exclude, /up answers 301, and most deploy tools treat anything other than 2xx as unhealthy. Without the host_authorization exclude, it answers 403 Blocked hosts. The force HTTPS guide covers the rest of the SSL settings.

Should /up warm the app before it reports healthy?

Only if your first request is slow enough to time out. Puma loads the app before binding when you use preload_app! (or eager_load = true, the production default), so by the time /up answers, the code is loaded. Caches are cold, and the first database connection is opened lazily, but that costs milliseconds, not seconds. If you have something heavier, such as loading a large lookup table into memory, do it in an initializer so it completes before Puma binds the port, rather than adding it to the health check.

How does the old release finish its requests?

When Puma receives SIGTERM, it stops accepting new connections, finishes the requests it is already processing, and exits. The deploy is only zero downtime if the platform waits for that to finish before sending SIGKILL.

The settings that matter live in config/puma.rb:

# config/puma.rb
max_threads = ENV.fetch("RAILS_MAX_THREADS", 3).to_i
threads max_threads, max_threads

port ENV.fetch("PORT", 3000)

workers ENV.fetch("WEB_CONCURRENCY", 2).to_i
preload_app!

# On SIGTERM, also process connections already waiting in the socket backlog
# instead of dropping them.
drain_on_shutdown

# Give workers this long to finish in-flight requests on shutdown before
# they are force-killed. Must be shorter than the platform's grace period.
worker_shutdown_timeout 25

# Leave persistent_timeout at Puma's default (65 s in current Puma). It must stay
# longer than the proxy's idle keep-alive timeout, or the proxy reuses a
# connection Puma already closed and the client gets a 502.

plugin :tmp_restart
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]

Three timeouts have to nest, from the outside in:

Layer Setting Typical default Must be
Proxy / deploy tool Kamal drain_timeout, Render maxShutdownDelaySeconds, Fly kill_timeout 30 s (Kamal, Render), 5 s (Fly) Longest
Container runtime docker stop --time / stop_grace_period 10 s ≥ Puma shutdown
Puma worker_shutdown_timeout 30 s ≥ your slowest request
Your app Slowest legitimate request — Shortest; enforce it with a request timeout

If Docker’s 10-second default is shorter than Puma’s shutdown window, Docker sends SIGKILL while requests are still running, and those clients see a reset connection. Keep the slowest request short with a timeout at the app level; the Rails request timeout guide shows how, and the Puma workers and threads guide explains the worker and thread settings above.

Pro Tip: in Docker, use exec form for the command (CMD ["./bin/thrust", "./bin/rails", "server"]), not shell form. With shell form, /bin/sh is PID 1, it doesn’t forward SIGTERM, and Puma never hears about the shutdown until SIGKILL arrives.

What about long-lived connections?

WebSockets (Action Cable) and server-sent events don’t drain: they stay open until someone closes them. On shutdown, the old release closes them and clients reconnect, which Action Cable’s JavaScript client does automatically, to the new release. Design for the reconnect (re-subscribe, re-fetch missed state) rather than trying to keep sockets open across a deploy.

A complete zero downtime setup with Kamal 2

Kamal 2 is a good reference because every step of the cutover is a visible setting. kamal-proxy runs on the host, starts the new container, polls its health check, switches traffic and drains the old one.

# config/deploy.yml
service: myapp
image: myorg/myapp

servers:
  web:
    - 203.0.113.10

proxy:
  ssl: true
  host: example.com
  app_port: 80            # Thruster listens on 80 in the Rails 8 Dockerfile
  healthcheck:
    path: /up
    interval: 1           # seconds between polls
    timeout: 3            # per-request timeout

# How long the new container has to pass the health check.
deploy_timeout: 30

# How long the old container may keep finishing requests after the switch.
drain_timeout: 30

registry:
  username: myorg
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    WEB_CONCURRENCY: 2
    RAILS_MAX_THREADS: 3

builder:
  arch: amd64
# Dockerfile (last lines of the Rails 8 default)
USER 1000:1000
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]

Kamal’s drain_timeout (30 s) is longer than Puma’s worker_shutdown_timeout (25 s above), so Puma finishes first. The Kamal 2 deployment post covers the rest of deploy.yml.

What breaks zero downtime even with health checks?

Health checks only prove that the new release boots. Several kinds of change break requests during the overlap window, when old and new code run at the same time.

Problem What users see Fix
Migration removes or renames a column the old release still reads PG::UndefinedColumn 500s on the old release until it stops Expand/contract: add first, deploy, remove later (zero downtime migrations)
Old pages request old fingerprinted assets that the new release deleted Unstyled pages, broken JS until reload Keep the previous release’s assets (Kamal asset_path, or a CDN)
secret_key_base changed Everyone is logged out, CSRF errors Never rotate it in a normal deploy
Sidekiq/Solid Queue killed mid-job Jobs retried, possibly run twice Idempotent jobs; give workers a shutdown timeout
Requests longer than the drain timeout Connection reset on the slowest endpoints App-level timeouts, move long work to jobs
Boot takes longer than the deploy timeout Deploy fails, old release keeps serving Raise the timeout or find the slow initializer

Note the last row: a failed deploy is the health check working. The old release kept serving; nothing was dropped.

Verify it works: deploy under load

Run a steady stream of requests during a deploy and count anything that isn’t a success. If the count is zero, the cutover is clean.

#!/usr/bin/env bash
# bin/deploy-probe: run in a second terminal while you deploy.
URL="${1:-https://example.com/up}"
ok=0; fail=0
end=$((SECONDS + 120))
while [ $SECONDS -lt $end ]; do
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL")
  if [ "$code" = "200" ]; then ok=$((ok+1)); else fail=$((fail+1)); echo "$(date +%T) $code"; fi
  sleep 0.1
done
echo "ok=$ok fail=$fail"
chmod +x bin/deploy-probe
bin/deploy-probe https://example.com/up   # terminal 1
kamal deploy                              # terminal 2

Probing /up tests the switch. To test draining, also probe a slow real page; a request that takes 2–3 seconds is more likely to be in flight at the moment the old release stops. Expect fail=0 for both.

Troubleshooting

target failed to become healthy within configured timeout (30s) (Kamal). The new container never returned 200 on the health path. Run kamal app logs and look for one of the next two errors, or a boot exception. If the app is just slow to boot, raise deploy_timeout.

[ActionDispatch::HostAuthorization::DefaultResponseApp] Blocked hosts: 172.18.0.5:80. The deploy tool calls /up by container IP, which isn’t in config.hosts. Add the host_authorization exclude shown above.

Health check returns 301 instead of 200. force_ssl is redirecting the internal HTTP probe. Add the ssl_options redirect exclude.

502 Bad Gateway for a second or two right after the switch. The old release was killed before it finished. Check that the Docker command uses exec form, that Puma gets SIGTERM (you’ll see - Gracefully stopping, waiting for requests to finish in the logs), and that the timeouts nest as in the table above.

Errno::EADDRINUSE: Address already in use - bind(2) for "0.0.0.0" port 3000. Old and new release are trying to bind the same port on the same host. Platforms avoid this by running each release in its own container or network namespace; on a bare server, use a proxy and a different port per release.

How Heroku, Render, Fly.io and Upsun handle the cutover

All four can deploy without downtime, but they decide “ready” differently: some poll an HTTP path, some wait a fixed time, some only check that the process started.

Platform When traffic switches Old release shutdown
Heroku (Preboot enabled) About 3 minutes after the new dynos start, on time, not on health SIGTERM, then SIGKILL 30 s later
Render When all new instances pass healthCheckPath SIGTERM, then SIGKILL after maxShutdownDelaySeconds (default 30 s)
Fly.io Rolling, per Machine, once [[http_service.checks]] pass kill_signal (default SIGINT), then SIGKILL after kill_timeout (default 5 s)
Upsun Default stop-start strategy holds idempotent requests (GET, PUT, DELETE, not POST) during the deploy; the rolling strategy serves traffic from a temporary clone; post_start can delay routing until the app answers WebSocket and SSE connections dropped under any strategy
Enkihost Once the new container is running (within 30 s) Old container removed after the switch

Two things to change on day one: on Fly.io, set kill_signal = "SIGTERM" and raise kill_timeout, because the 5-second default is shorter than Puma’s shutdown window. On Heroku, preboot is opt-in (heroku features:enable preboot); without it, every deploy restarts dynos in place with a brief gap.

Author Perspective

My view is that “zero downtime” is a claim you should test, not a feature you switch on. The health check part is the easy half and every platform gets it roughly right; the draining half is where configuration quietly decides the outcome, and it’s invisible until you deploy under load. A shell-form CMD that swallows SIGTERM, or a container stop timeout shorter than Puma’s shutdown window, looks perfectly fine in a quiet staging deploy. That’s why I treat the timeouts table above as a checklist for every new app and run the probe script against a slow page before I trust a deploy pipeline. It takes ten minutes, and it’s the only way I know to actually confirm the claim rather than assume it.

Zero downtime deploys on Enkihost

On Enkihost, zero-downtime deploys are the standard deploy path, not something you configure. Each release starts in a new container next to the running one; the old container keeps serving until the new one is running, and if the new one doesn’t come up within 30 seconds, the deploy is aborted and the old release stays live.

Enkihost

What that means for the setup in this post:

  • Keep /up shallow and reachable over HTTP, with the ssl_options and host_authorization excludes. It costs nothing and it’s what any proxy or uptime monitor will call.
  • Keep the Puma settings: exec-form command, drain_on_shutdown and a worker_shutdown_timeout shorter than your slowest acceptable request. They decide whether the old container finishes cleanly.
  • Point an external uptime monitor at your deep check, because the deploy gate is the container’s state, not your dependencies.
  • Managed PostgreSQL and Redis are injected as DATABASE_URL and REDIS_URL, so old and new releases talk to the same database during the overlap, which is exactly why the expand/contract migration rule applies.
  • Per-app resource isolation means booting the new release doesn’t compete with another customer’s app for memory during the overlap.

Deploys run from git push. See enkihost.com for details.

FAQ

Does Rails have a built-in health check endpoint?

Yes. Since Rails 7.1, new apps include get “up” => “rails/health#show” in config/routes.rb. It returns 200 if the app booted without exceptions and 500 otherwise, and it does not check the database or other dependencies.

Should the deploy health check query the database?

No. The deploy gate should only confirm that the new release booted and can route a request. If it queries the database, a short database outage blocks every deploy, including the one that might fix it. Check the database in a separate endpoint that feeds monitoring.

Why do I still get 502 errors during a deploy if the health check passes?

The health check only protects the switch to the new release. 502s right after the switch usually mean the old release was killed before it finished its requests: Puma didn’t receive SIGTERM, or the container’s stop timeout was shorter than Puma’s shutdown timeout.

How long should worker_shutdown_timeout be in Puma?

Longer than your slowest legitimate request and shorter than the platform’s grace period before SIGKILL. With a 30-second platform grace period and requests capped at 15 seconds, 25 seconds is a reasonable value.

Do WebSocket connections survive a zero downtime deploy?

No. WebSocket and server-sent event connections are closed when the old release stops, and clients reconnect to the new one. Action Cable’s JavaScript client reconnects automatically, so design channels to re-subscribe and re-fetch any state they missed.

Sources