You need a URL a load balancer can poll to decide whether a Strapi instance is alive, without authentication and without pulling real content. Strapi has one built in.
GET /_healthIt responds with HTTP 204 No Content and a strapi: You are so French! response header. No authentication, no body, no database round trip.
$ curl -I https://cms.example.com/_health
HTTP/2 204
strapi: You are so French!That is the whole endpoint. The rest of this post is about using it correctly.
What it does and does not tell you
/_health is a liveness signal: the Node process is running and the HTTP server is accepting and routing requests. That is genuinely useful: it distinguishes "the process crashed" and "the container never started" from "the app is fine".
It is not a readiness or dependency check. A 204 from /_health does not tell you that:
- the database is reachable,
- migrations completed,
- your upload provider is configured,
- the admin panel was built.
Strapi will not finish booting if the database is unreachable at startup, so in practice a 204 shortly after boot does imply the database was reachable at boot. But a database that goes away an hour later will not change the health response. If you need dependency checking, build it (see below).
Docker
Use it as the container healthcheck so orchestrators know when to restart:
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:1337/_health || exit 1--start-period matters. Strapi's boot includes loading the schema, running migrations, and syncing the database schema, which on a large project takes well over the default grace period. Too short a start period and Docker kills the container mid-migration, repeatedly.
In Compose, gate dependent services on it:
services:
strapi:
build: .
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:1337/_health"]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
depends_on:
postgres:
condition: service_healthyKubernetes
Use the same path for both probes, with different timings:
livenessProbe:
httpGet:
path: /_health
port: 1337
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /_health
port: 1337
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /_health
port: 1337
failureThreshold: 30
periodSeconds: 10The startupProbe is the important one for Strapi. It gives the process up to five minutes to finish booting while keeping the liveness probe's aggressive timings once it is up. Without it, a slow first boot, whether from a cold database or a large schema sync, gets killed and restarted in a loop.
AWS ALB / NLB target groups
Protocol: HTTP
Path: /_health
Success codes: 204
Healthy threshold: 2
Unhealthy threshold: 3
Timeout: 5 seconds
Interval: 30 secondsThe value that catches people out is success codes. ALB target groups default to expecting 200. Strapi returns 204, so the default configuration marks every healthy target as unhealthy and drains your entire service. Set it to 204, or the range 200-299.
Nginx upstream checks
location = /_health {
proxy_pass http://strapi_upstream/_health;
access_log off;
}access_log off keeps a 30-second poll from filling your log volume.
Uptime monitors
Point Pingdom, Better Stack, UptimeRobot, or Checkly at https://cms.example.com/_health and configure them to treat 204 as success. As with the ALB, several default to "expect 200" and will page you continuously about a perfectly healthy service.
If your monitor cannot be configured to accept 204, use a real content endpoint instead, as described below.
Building a deeper health check
When you need to know that the database is genuinely responding, add your own route. Create a custom route and controller, or register a route in src/index:
export default {
register({ strapi }) {
strapi.server.routes([
{
method: 'GET',
path: '/_health/deep',
handler: async (ctx) => {
const checks: Record<string, string> = {};
let ok = true;
try {
await strapi.db.connection.raw('SELECT 1');
checks.database = 'ok';
} catch (error) {
checks.database = 'error';
ok = false;
strapi.log.error('Health check: database unreachable', error);
}
ctx.status = ok ? 200 : 503;
ctx.body = {
status: ok ? 'ok' : 'degraded',
uptime: process.uptime(),
checks,
};
},
config: {
auth: false,
},
},
]);
},
bootstrap() {},
};config.auth = false is what makes it public. Without it the route requires authentication and your load balancer gets a 401.
Two cautions:
- Do not put this on the load balancer's liveness probe. A brief database blip would then take every instance out of rotation at once, turning a recoverable incident into a full outage. Use
/_healthfor liveness and a deep check for your monitoring dashboard and alerting. - Do not leak internals. Return status names, not connection strings, versions, or error messages. This endpoint is public.
Alternative: use a real content route
If you want a check that exercises the full stack (routing, middleware, database, permissions), poll a small published collection through the REST API:
GET /api/settings?fields[0]=id&pagination[pageSize]=1Grant the Public role read access to that one content type. This returns a 200 with a tiny body, works with monitors that insist on 200, and fails when anything in the request path is broken. The trade-off is that it hits the database on every poll, so keep the interval sane.
Summary
| Need | Use |
|---|---|
| Is the process alive? | GET /_health → 204 |
| Container healthcheck | wget --spider http://localhost:1337/_health |
| K8s startup probe | /_health, failureThreshold: 30 |
| ALB target group | /_health, success codes 204 |
| Is the database responding? | Custom /_health/deep route |
| Monitor that requires 200 | A small public content endpoint |




