Rails health check endpoint and what /up actually checks
Rails 8 already serves a health check at /up, but it only returns 200 when the app boots without raising, so if you want the database checked you write your own controller and return 503 when the query fails.
Rails has shipped a health check since 7.1, and every app generated on Rails 8 still has it. One line sits near the top of config/routes.rb: get "up" => "rails/health#show", as: :rails_health_check. Hit /up and Rails::HealthController renders a green page with a 200, or a red one with a 500 if something raised on the way in. You do not have to add it, wire it or name it. Most people running Rails in production already have this endpoint and have never opened it, which is worth knowing before you go shopping for a gem.
The next thing to know is what it does not do, and Rails is honest about this in the controller's own documentation: the endpoint does not reflect the status of your application's dependencies. It answers exactly one question. Did this process boot without raising. A Puma worker whose connection pool is full of dead Postgres sockets booted fine hours ago, and it will keep answering /up with a cheerful 200 while every real request 500s. That is the failure mode that costs you the outage. A monitor pointed at /up sits green through the whole thing, and a monitor you have learned to trust while it is wrong is worse than no monitor at all.
The controller you write when the database matters
class HealthController < ApplicationController
# Whatever ApplicationController runs before every action, skip it here.
skip_before_action :authenticate_user!, raise: false
def show
ActiveRecord::Base.with_connection { |c| c.select_value("SELECT 1") }
render json: { ok: true }
rescue StandardError => e
Rails.logger.warn("health check failed: #{e.class}")
# 503, so a booted app with a dead database reads as down.
render json: { ok: false, error: "database" }, status: :service_unavailable
end
endThen point the existing route at it in config/routes.rb: get "up" => "health#show", as: :rails_health_check. Keeping the path means the load balancer config, the Kubernetes probe and anything else already hitting /up carry on working without a change.
What to check and what to leave out
- One query against the database you cannot serve a request without. SELECT 1 through the pool is enough: it proves the pool still hands out a live connection, and it costs well under a millisecond.
- Redis, only if a request genuinely fails without it. If the app degrades to a slower page, that is not something worth waking anyone up for.
- Nothing that calls a third-party API. Fan out to Stripe and S3 and you will page yourself at 3am for someone else's outage, about a thing you cannot fix.
- A budget well under a second. The Logdash pinger gives up after 10 seconds and records the check as down, and an endpoint that needs 10 seconds is already telling you something.
- No version string, no migration status, no environment dump in the body. Assume the URL is public, because eventually it is.
Point a monitor at it
- Ship it, then try to break it Deploy, open /up in a browser, then stop Postgres on your machine and load the same path. You want a small JSON body with a 503, not a 500 page with a stack trace in it.
- Create the monitor Add a monitor in Logdash and paste the full URL. Checks run every 5 minutes on the free plan, every minute on Builder and every 15 seconds on Pro, and each one records the status code and the response time.
- Take the database away Point staging at a dead database and wait one interval. The status code leaves the 200-399 range, the monitor flips to down on that transition, and the Telegram alert arrives naming the endpoint and the code it got back.
The one that catches people: host authorization
If you have set config.hosts in production, ActionDispatch::HostAuthorization answers any request with an unexpected Host header with 403 Forbidden. An external monitor hitting your real domain is fine. A load balancer or a Kubernetes probe hitting the pod by IP is not, and 403 sits outside 200-399, so the check reads as down while the app is perfectly healthy. The Rails guide gives you the exclusion: config.host_authorization = { exclude: ->(request) { request.path == "/up" } }. Set it before you lose an evening to a red monitor pointed at a green app.