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:
get "up" => "rails/health#show", as: :rails_health_checkThe 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:
class HealthController < ApplicationController
skip_authentication! # or your equivalent
def show
render json: { status: 'ok' }, status: :ok
end
endThen add the route:
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:
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
endWhen 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:
- Start a new container with your updated code
- Poll
/uprepeatedly until it returns 200 OK - Once healthy, add the new container to the load balancer
- Remove the old container from the load balancer
- 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:
# 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: 5The 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:
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
endThe 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:
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
endKey takeaways
- The
/upendpoint 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.






Comments
Loading comments…