The health endpoint moved to `/_core/health`
@spfn/core used to serve its built-in health endpoint at /health. It no longer does.
The endpoint answers at /_core/health, and /health belongs to your app.
If nothing in your deployment probes /health, there is nothing to do.
Does this affect me?
| What you have | What to do |
|---|---|
A readiness/liveness probe, HEALTHCHECK, uptime monitor or load balancer target on /health |
Move it, or restore the old address |
healthCheck({ path: '/healthz' }) or any other custom path |
Nothing — a configured path still answers, and now /_core/health does too |
healthCheck({ enabled: false }) |
Nothing — /health was already a 404 for you |
Your own GET /health route |
Nothing — it now answers, and used to be unreachable. See below |
Nothing probing /health |
Nothing |
The fastest way to check a running deployment:
curl -i https://your-app.example.com/health
410 Gone means it is on a release that moved the endpoint and nothing in the app claims
that path. The response body names the new address.
What you will see if you miss this
The probe fails, the pod never enters rotation, and a Kubernetes event says only that the
probe failed — no response body, no status text. That is why the old path answers 410
for one release instead of a bare 404, and why the server logs this the first time it is
hit:
⚠️ GET /health is answering 410: @spfn/core no longer serves it. Point your readiness
probe, Dockerfile HEALTHCHECK and load balancer at /_core/health, or restore this path
with healthCheck({ path: '/health' }). This notice is removed in the next release.
The warning is logged once per server, not once per probe interval.
1. Move the probe (preferred)
/_core/ belongs to @spfn/core. Paths in it are registered before your app's routes, so
no route you declare can take one — the address stays true through every later release, and
through whatever your app grows into.
Kubernetes:
readinessProbe:
httpGet:
path: /_core/health # was: /health
port: 8790
Dockerfile:
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8790/_core/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
Check every place a path is written down. A probe path lives in more files than it feels
like: the manifest, the Dockerfile, docker-compose.yml, a load balancer target group, an
uptime monitor, and whatever dashboards alert on it.
2. Restore the old address
When the probe path is frozen somewhere you cannot reach — a load balancer console owned
by another team, an appliance, a contract with a platform — ask for /health back:
export default defineServerConfig()
.healthCheck({ path: '/health' })
.build();
The built-in then answers on both /_core/health and /health. This is an addition, not a
move: /_core/health answers either way.
One constraint. The configured path is registered before your app's routes, so if your
app also declares GET /health that route will not run, and the server says so at boot.
Pick one — the built-in there, or your own route there.
Your own /health route now runs
If your app declares GET /health, that handler used to be unreachable: the built-in was
registered first and Hono never got past it. Nothing warned you loudly enough. Now the
route is simply yours.
That is a behaviour change worth checking. If a probe points at /health and your app has
such a route, the probe now reads your handler's response. Make sure it answers 200, or
move the probe to /_core/health.
Why the endpoint moved at all
A readiness probe's path is fixed in places the framework cannot change: a GitOps
manifest, a Dockerfile, a load balancer's console. A version bump migrates none of them.
So the endpoint needs one address that is true regardless of what an app declares — and
/health could never be that, because it is an ordinary path an app has every right to
want.
The old arrangement forced a choice between two bad outcomes: the built-in wins and an
app's own /health route silently never runs, or the app wins and probes break depending
on what the app happens to define. Splitting the two addresses removes the choice.
/_auth/ and /_ops/ have worked this way since they existed, and neither has ever had a
shadowing defect.