Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 092 / 100

Rails health checks and readiness: a step-by-step guide

Know your app is up.

Expona Admin7 min read

Why health checks matter in production

Your Rails application is running. Or is it? Databases fail silently, memory leaks creep in, and load balancers need a way to know whether a request will succeed before sending traffic your way. A proper health check answers that simple question: is this instance ready to serve requests right now? This article walks you through implementing Rails health checks and readiness: a step-by-step guide that your infrastructure can rely on, whether you're using Kamal, Kubernetes, or traditional load balancers.

The /up route in new Rails apps

Starting with Rails 6.1, new applications include a built-in health check endpoint at /up by default. This is intentional—the Rails team recognized that every production app needs one, so they made it available out of the box.

When you generate a new Rails app, look in your routes file:

ruby
get "up" => "rails/health#show", as: :rails_health_check

The controller that handles this lives in Rails itself. By default, it simply returns a 200 OK response. The endpoint responds quickly because it does almost nothing—it's designed to be lightweight and fast.

If you're on Rails 6.0 or earlier, you need to add this manually. Create a simple controller:

ruby
class HealthController < ApplicationController
  skip_authentication!  # or your equivalent
  
  def show
    render json: { status: 'ok' }, status: :ok
  end
end

Then add the route:

ruby
get "/up", to: "health#show"

The key principle: your health check endpoint should skip any authentication, rate limiting, or expensive middleware. It's for machines, not users, and it needs to be fast and always available when your app process is running.

Checking database connectivity

A 200 OK response to /up tells you the Rails process is alive. But what if your database is down? Your app will crash as soon as it tries to query data. That's why you need to extend your health check to verify that critical dependencies are actually working.

Rails 7.0+ ships with additional health check types. You can check them by visiting /up?type=all or individual checks like /up?type=database. These check database connectivity, cache connectivity, and other configured services.

For older Rails versions or for fine-grained control, build your own:

ruby
class HealthController < ApplicationController
  skip_before_action :authenticate_user!
  
  def show
    perform_checks!
    render json: { status: 'ok', checks: @checks }, status: :ok
  rescue => e
    render json: { status: 'unhealthy', error: e.message }, status: :service_unavailable
  end
  
  private
  
  def perform_checks!
    @checks = {}
    @checks[:database] = check_database
    @checks[:redis] = check_redis if redis_configured?
  end
  
  def check_database
    ActiveRecord::Base.connection.execute("SELECT 1")
    { status: 'ok' }
  rescue => e
    { status: 'failed', error: e.message }
  end
  
  def check_redis
    Rails.cache.write('health_check', 'ok', ex: 5)
    { status: 'ok' }
  rescue => e
    { status: 'failed', error: e.message }
  end
  
  def redis_configured?
    Rails.cache.class.to_s.include?('Redis')
  end
end

When your load balancer hits this endpoint and gets a 503 Service Unavailable, it immediately stops routing traffic to that instance. Your team gets notified before users see errors. In one real scenario, a team caught a database connection pool exhaustion because the health check started failing 30 seconds before the app became completely unresponsive.

Integration with load balancers

Load balancers use health checks to decide whether an instance is healthy. This is how you go from "one instance crashed" to "traffic automatically reroutes to healthy instances" without manual intervention.

Most load balancers (AWS Application Load Balancer, HAProxy, nginx upstream checks) follow the same pattern:

  • Send an HTTP request to the health check endpoint every 5-30 seconds
  • Expect a 2xx response status code for healthy
  • Expect a 5xx response status code for unhealthy
  • Remove unhealthy instances from the pool
  • Re-add them once they return to health

Configure your load balancer to hit /up:

Setting Value
Path /up
Protocol HTTP
Port 3000 (or your app port)
Interval 10 seconds
Healthy threshold 2 consecutive successes
Unhealthy threshold 3 consecutive failures
Timeout 2 seconds

These settings mean your load balancer declares an instance unhealthy after 30 seconds of failed checks (3 failures × 10 second interval), then takes 20 seconds to re-add it once it recovers (2 successes × 10 second interval). Adjust based on your tolerance for outages.

One team configured their health check to time out at 5 seconds, then watched as a slow database query caused the load balancer to constantly remove and re-add instances—creating more load, not less. Lesson: keep health check timeouts aggressive but realistic.

Health checks during Kamal deploys

Kamal (formerly MRSK) is a containerized deployment tool popular in the Rails community. When you deploy with Kamal, it needs to know when a new container is ready to receive traffic. It uses the health check endpoint to determine this.

Kamal's deploy flow looks like this:

  1. Start a new container with your updated code
  2. Poll /up repeatedly until it returns 200 OK
  3. Once healthy, add the new container to the load balancer
  4. Remove the old container from the load balancer
  5. Stop the old container

If your health check never returns 200, Kamal waits until a timeout, then rolls back. This protects you from deploying broken code.

Your Kamal configuration might look like:

ruby
# config/deploy.yml
service: myapp
image: myregistry/myapp

servers:
  web:
    hosts:
      - 1.2.3.4
      - 1.2.3.5

env:
  clear:
    RAILS_ENV: production

healthcheck:
  path: /up
  interval: 5s
  timeout: 2s
  max_attempts: 5

The healthcheck configuration tells Kamal to poll /up every 5 seconds up to 5 times (25 seconds total) before giving up. If the check succeeds once, the container is considered ready. Make sure your health check doesn't depend on complex database migrations or external API calls—on deploy day, you want it fast and simple.

In practice, teams often extend their health check to verify that Rails has finished loading. A Rails app might be listening on port 3000 but still running initializers. The simple /up endpoint handles this because Rails doesn't start the request queue until boot is complete.

Building a comprehensive health check

Once you understand the basics, you can add layers. Here's a production-ready example:

ruby
class HealthController < ApplicationController
  skip_before_action :authenticate_user!
  skip_before_action :verify_authenticity_token
  
  def show
    case params[:type]
    when 'readiness'
      check_readiness
    when 'liveness'
      check_liveness
    else
      check_basic
    end
  end
  
  private
  
  def check_basic
    render json: { status: 'ok' }, status: :ok
  end
  
  def check_liveness
    # Is the process alive at all?
    render json: { status: 'alive' }, status: :ok
  end
  rescue => e
    render json: { status: 'dead', error: e.message }, status: :service_unavailable
  end
  
  def check_readiness
    # Is the process ready to handle traffic?
    errors = {}
    
    begin
      ActiveRecord::Base.connection.execute("SELECT 1")
    rescue => e
      errors[:database] = e.message
    end
    
    if errors.any?
      render json: { status: 'not_ready', errors: errors }, status: :service_unavailable
    else
      render json: { status: 'ready' }, status: :ok
    end
  end
end

The distinction between liveness and readiness comes from Kubernetes, and it's useful even without Kubernetes:

  • Liveness: Is the process running? (Almost always true unless there's a crash.)
  • Readiness: Can the process handle requests right now? (False if database is down, cache is cold, etc.)

Common mistakes

Mistake 1: Running database migrations in the health check. Your health check runs every 10 seconds. It must be idempotent and have no side effects. Any write operations in your health check will cause unexpected behavior.

Mistake 2: Forgetting to skip authentication. A health check that requires a valid session or API key defeats the purpose. Load balancers won't authenticate; they'll fail the check, and you'll lose all traffic.

Mistake 3: Making the check too strict. A health check that fails on minor issues (e.g., a single Redis instance being slow) can cause unnecessary cascading failures. Reserve health check failures for truly critical problems: database down, not enough connections, boot incomplete.

Mistake 4: Not testing the health check locally. Add a simple test:

ruby
require "test_helper"

class HealthTest < ActionDispatch::IntegrationTest
  test "health check returns ok" do
    get "/up"
    assert_response :ok
    assert_match /ok/, response.body
  end
  
  test "health check works without authentication" do
    # Don't set any auth headers
    get "/up"
    assert_response :ok
  end
end

Key takeaways

  • The /up endpoint should always be present and respond quickly. It's not optional in production.
  • Check database connectivity in your health endpoint to catch failures before your app crashes.
  • Load balancers use health checks to automatically remove unhealthy instances. Configure them with appropriate timeouts and thresholds.
  • Kamal relies on health checks to determine when a new container is ready for traffic. A fast, reliable check makes deploys safer.
  • Keep health checks simple, idempotent, and unauthenticated. Complex checks defeat their purpose.
  • Distinguish between liveness (process running) and readiness (can handle traffic) when designing health checks.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles