Rails Health Check: What /up Proves and What It Misses
A rails health check ships with every app since 7.1, but /up only proves the process booted. Here is the second route, and the traps around both.
The rails health check you already have is /up, generated into config/routes.rb since Rails 7.1. It returns 200 when the application booted without raising and 500 when it did not. What it never touches is your database, your Redis or your job queue, and that is the gap teams find during an incident rather than before one.
What does the default rails health check at /up prove?
One thing: the process booted and can render a response. The HealthController documentation says so plainly, noting that the endpoint does not reflect the status of all of your application's dependencies, such as the database or Redis cluster. It is a liveness signal, and a good one.
# config/routes.rb
get "up" => "rails/health#show", as: :rails_health_check
Change the path by changing the string. The Rails docs use get "healthz" => "rails/health#show" as their example. Here is what the default route does and does not catch.
| Failure | Caught by /up |
|---|---|
| An initializer raised during boot | yes, 500 |
| The Puma worker is wedged or the port is closed | yes, no response at all |
| Postgres is unreachable | no, still 200 |
| Redis is down and sessions are failing | no |
| Sidekiq is not running and the queue is growing | no |
| The TLS certificate expired at the edge | no |
Rows one and two are worth having on every instance. Rows three to six are the reason you write a second route rather than replacing this one.
Adding a route that checks dependencies
Keep /up as liveness and add /readyz for readiness. Two routes, two audiences: a restart controller reads the first, a load balancer reads the second. The same split is worked through for orchestrators in our piece on designing a health endpoint.
# config/routes.rb
get "up" => "rails/health#show", as: :rails_health_check
get "readyz" => "health#readyz"
# app/controllers/health_controller.rb
class HealthController < ActionController::Base
CHECKS = {
database: -> { ActiveRecord::Base.connection.select_value("SELECT 1").to_i == 1 },
redis: -> { Rails.cache.redis.ping == "PONG" },
}.freeze
def readyz
results = CHECKS.transform_values do |probe|
probe.call ? "ok" : "failed"
rescue StandardError => e
e.class.name
end
healthy = results.values.all?("ok")
render json: { status: healthy ? "ok" : "degraded", checks: results },
status: healthy ? :ok : :service_unavailable
end
end
Inherit from ActionController::Base rather than ApplicationController, or your authentication before_action will redirect the probe to the login page. A 302 is not a health signal and most balancers will read it as a pass. For an API-only application, use ActionController::API instead.
Resist wrapping those lambdas in Timeout.timeout. Ruby's timeout raises inside whatever the thread happened to be doing, which can leave a connection checked out and unusable. Set statement_timeout on the Postgres connection or lower checkout_timeout in database.yml, and let the adapter fail cleanly.
Which dependencies belong in the check
Only the ones this instance needs to serve a request in the next second. The Rails documentation is blunt about the alternative, warning that it can lead to situations where your application is being restarted due to a third-party service going bad. A payment gateway outage should page someone, not recycle your pods.
| Dependency | In readiness? | How to check it |
|---|---|---|
| Primary database | yes | select_value("SELECT 1") |
| Redis for cache or sessions | yes | ping |
| Sidekiq or Solid Queue | latency only | queue latency above a threshold |
| Third-party payment or mail API | no | monitor it as its own target |
Queues deserve the nuance. A worker process being up tells you nothing, because a worker can be up and hopelessly behind. Sidekiq exposes queue latency through Sidekiq::Queue.new("default").latency, which returns the age of the oldest job in seconds. That number correlates with user pain in a way that a process count never does.
Pick a threshold per queue rather than one global number. A mailer queue sitting at sixty seconds is fine, and a queue that drives a real-time feature is not. On Rails 8 with Solid Queue the jobs live in the database, so the connection half is already covered by your existing database check.
Keeping probe traffic out of the logs
A probe every five seconds is more than seventeen thousand log lines a day per instance, and it buries everything real. Rails ships a setting for exactly this, documented in the configuration guide as using Rails::Rack::SilenceRequest under the hood.
# config/environments/production.rb
config.silence_healthcheck_path = "/up"
It takes a single path. Once you have added /readyz you either pick the noisier of the two, or insert Rails::Rack::SilenceRequest yourself for the second one. In our testing the balancer path is the one worth silencing, since it is polled far more often than the deploy path.
The 403 that looks like downtime
config.hosts blocks any request whose Host header is not on the allow list. A balancer that probes by IP address sends Host: 10.0.1.14, gets a 403 from the host authorization middleware, and marks the target unhealthy. Nothing is actually broken, and the application logs look fine because the request never reached a controller.
config.host_authorization = {
exclude: ->(request) { request.path.in?(["/up", "/readyz"]) }
}
The configuration guide documents this exclude option. An empty config.hosts in production means you will never hit the problem. Add host checking later to block Host header injection, though, and every probe that addresses the instance directly starts failing at once.
Deploy probe or balancer probe?
They are different jobs and they want different routes. A deploy tool waits for a new container to answer before cutting traffic over, and Kamal uses /up for that by default. Point that path at a dependency check and a thirty-second database blip rolls back a perfectly good deploy.
We prefer the boring split: the deploy tool and any restart controller read /up, the load balancer reads /readyz, and nothing else is allowed to depend on either. The balancer side of that argument, including drain behaviour and check intervals, is covered in our load balancer health check guide.
What no route inside the application can see
Every check so far runs inside Rails, which means it is invisible the moment a request stops reaching Rails. Think of an expired certificate at the edge, a DNS record pointing at a decommissioned address, or a security group closed by a Friday afternoon change. In each case the application keeps answering /readyz correctly to nobody at all.
An external check makes the same request a browser makes, from somewhere that is not your infrastructure.
curl -X POST https://uptimeroom.com/api/v1/monitors \
-H "Authorization: Bearer urm_live_..." \
-H "Content-Type: application/json" \
-d '{"name":"rails app","type":"http","target":"https://app.example.com/up","intervalSec":60,"regions":["london","los-angeles"],"expectedStatus":[200]}'
Run it from two of the six probe regions available as of September 2026, so a single bad network path does not page anyone. Allowlisting at the edge is easier with the egress addresses published on the probe IPs page. The framework-by-framework version of this pattern is collected in our Laravel walkthrough, which hits the same wall from a different direction.
Common mistakes
Leaving authentication on the health route. Inheriting from ApplicationController drags every before_action along, so the probe gets a redirect to /login and the balancer quietly accepts it.
Pointing the deploy check at your dependency check. Deploys then fail whenever a dependency is slow, which is exactly when you most want to ship a fix.
Alerting on queue worker processes instead of queue latency. Workers can be running and an hour behind. Measure the age of the oldest job and alert on that.
A good rails health check is two routes with two owners: /up for anything that restarts or deploys you, and a readiness route for anything that routes traffic to you. Add an outside probe on the public hostname and you also cover the certificate, DNS and edge failures that neither route can report. You can wire all three up in about twenty minutes and start free while you tune the thresholds.
FAQ
That the application booted with no exceptions and can render a response. The Rails HealthController documentation states it does not reflect the status of all of your dependencies, such as the database or Redis cluster. So a wedged Puma worker or a failed initializer is caught, and an unreachable Postgres is not.
Edit the generated line in config/routes.rb and change the path string. The Rails docs show get "healthz" => "rails/health#show", as: :rails_health_check as the example. Keep the named route helper, because deploy tooling and generated config often reference rails_health_check rather than the literal path.
Not on /up. Add a separate readiness route that runs a SELECT 1 and returns 503 when it fails, and leave /up as the liveness signal your deploy tool and restart controller read. Mixing them means a brief database outage restarts healthy instances or rolls back a good deploy.
Set config.silence_healthcheck_path in config/environments/production.rb. The Rails configuration guide documents it as using Rails::Rack::SilenceRequest to suppress the request log line. It accepts one path, so if you run both a liveness and a readiness route you have to choose one or insert the middleware manually.
Only if you want its extras. The built-in endpoint covers boot status, and a twenty-line controller covers database and Redis. The gem is still useful when you want its bundled checks for things like mail gateways and cache stores without writing them, but it is no longer the default answer.
Related reading
- Health Check Endpoint Design: What It Should Return - A health check endpoint answers one question for one caller. Here is how to pick the caller, choose 200 or 503, and keep the wrong things out of the body.
- Load Balancer Health Check: Settings That Matter - A load balancer health check decides which backends get traffic. Here are the four parameters, the shallow-versus-deep trap, and what to return.
- Laravel Health Check: Past the Built-In /up Route - The built-in Laravel health check proves the framework booted and nothing more. Here is how to deepen it, and cover the queue and scheduler it cannot see.