Elixir Phoenix health check endpoint with Ecto and the cluster

Phoenix ships no health route, so you write a controller action that runs one Ecto query, returns 200 with the number of connected nodes, and answers 503 when the query raises.

Phoenix does not generate a health route. Rails gives you /up and Laravel gives you /up. A new Phoenix app gives you a router with PageController in it and nothing else, so the endpoint is yours to write. That is mostly fine, except that the version most people write first is two lines - a plug that sends 200 - and two lines is exactly the version that lies to you.

The BEAM makes that trap easier to fall into than most runtimes. A supervisor restarts the Repo pool for you, the node survives failures that would take a Rails process out entirely, and the web server keeps accepting and answering requests while every checkout from the pool times out. A plug that returns 200 because the VM is still scheduling is measuring the wrong thing. Query the database the app actually uses, and answer 503 when the query does not come back. Logdash treats any status outside 200-399 as down, so that 503 is what turns a line on a chart into a notification.

The controller

lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
  use MyAppWeb, :controller

  alias MyApp.Repo

  def show(conn, _params) do
    Ecto.Adapters.SQL.query!(Repo, "SELECT 1")
    json(conn, %{ok: true, peers: length(Node.list())})
  rescue
    _error ->
      # 503, so a live node with a dead Repo reads as down.
      conn
      |> put_status(:service_unavailable)
      |> json(%{ok: false, error: "database"})
  end
end

Route it in lib/my_app_web/router.ex with get "/health", HealthController, :show, inside a scope that pipes through :api rather than :browser. The browser pipeline hands a health check a session, a CSRF token and a layout, and it needs none of them.

What to check, including the cluster

A Phoenix node can be perfectly healthy and still be useless if it has dropped out of the cluster. If you run Horde, distributed PubSub or anything that assumes peers, Node.list() belongs in the response: a node reporting zero peers when it should see three is the shape of a libcluster or DNS problem no database query will catch. Report the count and read it during an incident. Only turn it into a 503 if the node genuinely cannot serve a request alone, because a rolling deploy leaves every node short of peers for a minute and you do not want the fleet flapping mid-release.

  • One Ecto query on the Repo the app cannot serve without. SELECT 1 through Ecto.Adapters.SQL.query! goes through the real pool, so an exhausted pool and a missing Postgres both raise.
  • A cache or a Redis, only when a request fails outright without it.
  • No calls to third-party APIs. Their outage becomes your alert, and you cannot do anything about their outage at 3am.
  • Under a second. The Logdash pinger abandons a request after 10 seconds and records the check as down.
  • No release version, no node cookie, nothing about the topology beyond a count, on any URL reachable from the internet.

Point a monitor at it

  1. 1
    Try both paths locally Start the app, curl /health, then stop Postgres and curl it again. The second call should be a 503 with your JSON, which also proves the rescue clause is in the action and not swallowed by an error view.
  2. 2
    Create the monitor Add the service in Logdash and paste the 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.
  3. 3
    Kill the database in staging Take Postgres down and wait one interval. The status code leaves 200-399, the monitor flips on that transition, and the Telegram alert arrives with the endpoint and the code it saw.

One node is not the fleet

An HTTP check hits whichever node the load balancer picks, so with four nodes behind one hostname a single monitor tells you about roughly one in four. It catches a database outage, because that hits everyone at once. It misses one node that has lost its Repo, right up until the balancer sends the check that way. If per-node health matters, expose the endpoint on each node address and watch them separately. The free plan gives you one monitor per project across five projects, which covers a small fleet.

Does Phoenix have a built-in health check endpoint?

No. Unlike Rails with /up and Laravel with /up, Phoenix generates no health route at all. One controller action, or one plug in the endpoint, and it is yours to keep correct.

What path should an Elixir health check use?

/health or /healthz. Neither means anything to Phoenix, so pick one, write it in the runbook, and use the same path in every service you own so an on-call engineer never has to guess.

Should the Phoenix health check be a plug or a controller?

A plug matched high in lib/my_app_web/endpoint.ex answers before session parsing and routing, which keeps it cheap. A controller action is easier to read and to test. For one query, either is fine.

What status code should the endpoint return when Ecto fails?

503, via put_status(:service_unavailable) before json/2. Logdash marks a monitor down on anything outside 200-399, so a 200 carrying an ok false body keeps the monitor green through the entire outage.

Should a Phoenix health check endpoint be authenticated?

Leave it open and keep the body to a boolean and a peer count. Monitors cannot carry credentials easily, and there is nothing in that response worth protecting. Restrict by network if it has to be private.

Point it at your own URL and watch it for real.

Any public URL · checked every 15 s