Write a health endpoint your load balancer can trust
A health endpoint answers one question: can this server take a request now? What to return, what never to check, code for four frameworks, Hetzner settings.
- Author
- Hetscale
- Published
- Last updated
- Tags
- guide, health-check, hetzner-load-balancer, readiness
A health check endpoint your load balancer can trust answers one question: can this server take a
request right now? It returns 200 with a short body, answers from memory, and never waits on the
database, a cache server or an outside API that every server shares. A check that does more fails
on every server at once when that shared service stumbles, and the load balancer takes the whole
site offline with it. Below: what to return, the endpoint for Laravel, Django, Express and Go, how
to call it the way the balancer does, and the Hetzner Load Balancer settings that go with it.
What does a load balancer do with the health check?
It calls every server at a fixed interval. A server that fails the check a set number of times in a row gets no new traffic until it passes again, and a new server gets none until it has passed (Hetzner support confirmed this to us in September 2026). When no server passes, no server gets traffic, and the site is down.
So the endpoint is a switch, not a report. It decides when a new server starts taking traffic and when a broken one stops, and if something replaces servers for you, a script or an autoscaler, it decides which ones are replaced. Whatever makes the check fail takes servers out of rotation.
Why should a health check not touch the database?
Because the database is shared, and a shared failure should not look like every server failing. A deep check, one that queries the database, pings Redis or calls another API before it answers, fails on every server in the same second when that service has a slow query, a failover or a network blip. The balancer marks every target unhealthy and stops sending traffic to all of them, including the requests that did not need the database: cached pages, static files, the error page your application would have shown. When the database is back, every server has to pass its checks again before it gets traffic. A loop that replaces unhealthy servers makes it worse: it starts replacing servers that were never broken.
A shallow check keeps the question on the server: is the process up, can it route a request, has it finished starting? Whether the database is healthy is a question for your monitoring, which can alert someone. The load balancer can only take servers away.
What should a health endpoint return?
200and a short body such asok. The balancer reads the status code; the body is for you, and the Hetzner check can require it too.- An answer from memory. No database, no cache server, no outside call, no file on a network mount. A check that waits behind slow requests times out when the server is busy, and the balancer takes the busy server away and hands its traffic to the others.
Cache-Control: no-store, and nothing in front of the endpoint that caches. A cached200keeps a dead server in rotation.- No redirect. Not to HTTPS, not to a login page, not from
/healthto/health/. The Hetzner check accepts 2xx and 3xx answers unless you change it, so a redirect passes even when the application behind it is broken. - No login, no session, no rate limit. A check that passes through session middleware can start a new session on every call; with sessions in the database, that is a new row per check.
Liveness or readiness: which one does the load balancer need?
Kubernetes separates liveness from readiness, and the two words are useful without Kubernetes. Liveness asks whether the process should be restarted; readiness asks whether it should receive traffic now. A load balancer’s check is a readiness check.
A server can be alive and not ready: while it starts, before its configuration is loaded and its
workers are running, and while it shuts down. Return 503 until the server is ready, and the
balancer holds traffic until it can be served. Return 503 again from the moment the server
begins to shut down, and keep it running for at least the check’s interval times its retries: the
balancer stops sending it new connections, and the requests it already has can finish. Readiness
stays about this one server; the services every server shares stay out of it.
Step 1: add the endpoint
Call it /health, or keep the one your framework ships. Register it before any middleware that
starts a session, checks a login or redirects.
Laravel
Laravel 11 and later ship one: bootstrap/app.php registers /up with
->withRouting(health: '/up'). Since Laravel 11.16 the route sits outside the web middleware
group and starts no session; before that it did, and health checks filled session tables. In
current versions it also keeps answering during php artisan down. It returns 200 once the
application boots and 500 if a listener of the DiagnosingHealth event throws, so keep database
queries out of those listeners. Point the balancer at /up; its page reads Application up, which
is the string to expect in the check’s response setting.
Django
# views.py
from django.http import HttpResponse
from django.views.decorators.cache import never_cache
@never_cache
def health(request):
return HttpResponse("ok", content_type="text/plain")
# urls.py
from django.urls import path
from .views import health
urlpatterns = [
path("health", health),
]
Three Django settings decide whether the balancer ever sees that 200. ALLOWED_HOSTS must
contain the hostname the check sends, or Django answers 400. The path has no trailing slash, so
APPEND_SLASH does not redirect /health. And with SECURE_SSL_REDIRECT on, exempt the path,
because the check arrives over plain HTTP: SECURE_REDIRECT_EXEMPT = [r"^health$"].
Express
// Before app.use(session(...)) and any authentication middleware
app.get('/health', (req, res) => {
res.set('Cache-Control', 'no-store');
res.type('text/plain').send('ok');
});
Go
With go 1.22 or later in go.mod, where a GET pattern also answers HEAD; below that, the
pattern with a method in it matches nothing:
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("ok"))
})
Step 2: call it the way the balancer will
The balancer does not come through DNS and TLS. It calls the target’s address, the private one if
the target uses it, on the port the health check names, with the check’s domain as the Host
header, or with no Host header at all when the domain is empty. Do the same from another server
in the private network:
curl -si -H 'Host: <your-domain>' http://<private-ip>/health
Look for 200, your body and Cache-Control: no-store, and for the absence of Location and
Set-Cookie. The Host header matters when the server routes by hostname, as Caddy, Traefik and
nginx do, or checks it, as Django does: without the right one, the request can land on another
site or on an error. Then time it:
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' -H 'Host: <your-domain>' http://<private-ip>/health
Last, on a staging copy, stop the database and call it again. It should still answer 200. If it
does not, the endpoint depends on something it should not.
Step 3: set the Hetzner Load Balancer health check
In the Hetzner Cloud Console, open the load balancer, open the service and edit its health check. Every service has its own check, so give the HTTP and the HTTPS service the same one.
| Setting | What to set |
|---|---|
| Protocol | HTTP |
| Port | The port your application answers on |
| Path | /health |
| Domain | Your site’s hostname |
| Status codes | 200 only |
| Response | The text your endpoint returns, such as ok |
| Interval | 5 seconds |
| Timeout | 5 seconds |
| Retries | 2 |
The status code does most of the work. The response string only has to appear somewhere in the
body, which is enough to catch a 200 that a proxy sent on the application’s behalf.
Interval times retries is roughly how long the check needs to mark a dead server: 10 seconds with these values, 45 seconds with the 15-second interval and 3 retries of Hetzner’s own examples. A server that failed needs the same number of passing checks to come back. The allowed ranges are 3 to 60 seconds for the interval, 1 to 60 seconds for the timeout and 1 to 5 retries. Keep the timeout well above the endpoint’s usual answer time, so that a busy server is not taken for a dead one.
With the hcloud CLI, once per service:
hcloud load-balancer update-service <load-balancer> --listen-port 80 \
--health-check-protocol http --health-check-port 80 \
--health-check-http-path /health --health-check-http-domain <your-domain> \
--health-check-http-status-codes 200 --health-check-http-response ok \
--health-check-interval 5s --health-check-timeout 5s --health-check-retries 2
Run it again with --listen-port 443 for the HTTPS service; --health-check-port stays the port
your application answers on. In Git Bash on Windows, put MSYS_NO_PATHCONV=1 in front of the
command, or /health is rewritten into a Windows path.
Step 4: watch it fail on purpose
Stop the application on one server, for Docker Compose with docker compose stop <service>. Within
interval times retries the target turns unhealthy in the Console, under the balancer’s targets,
and the site keeps answering from the other servers. Since August 2026 the API also says why: in
GET /load_balancers/{id}, each target’s health_status carries a detail such as
layer4_no_connection when nothing answers on the port, layer7_timeout when the endpoint is too
slow, unexpected_http_status with the code it got, or unexpected_http_content when the body
lacks your response string. Start the application again and watch the target return. If the site
went down with it, the balancer had nothing to fall back on.
What this does not cover
- Monitoring. A shallow check stays green while the database is down. Watch the database, the cache and the outside services with your monitoring, from a deep endpoint of its own that the load balancer never calls.
- A single server. With one target, the check can only switch the site off: when it fails, no server is left to take traffic until it passes again. It starts to protect you with the second server; the calculator shows what a group that follows your load costs against a fixed one.
- TCP checks. For a service that does not speak HTTP, a TCP check proves only that the port is open, not that the application behind it answers.
Where Hetscale fits
This same check is what decides when a new server receives traffic. Hetscale takes your load balancer’s per-target health as its only readiness signal: a new server counts as capacity and takes traffic only once your balancer reports it healthy, and one that never gets there by the deadline, 15 minutes by default, is reported with the balancer’s reason and never counted. A server that stays unhealthy for 3 minutes gets a replacement started beside it, unless its own CPU is pinned: then it is overloaded, not dead, and the group scales out instead (What happens when a server dies). Each of those decisions is only as good as the check. If you run Docker Compose on one server today, the onboarding case for Docker Compose starts with the load balancer and this endpoint.
Frequently asked questions
Should the health check test the database? Not the one your load balancer uses. A database problem is shared by every server, and the balancer can only react by removing all of them. Test the database from your monitoring, on an endpoint the balancer never calls.
What should /health return? 200 with a short body such as ok, Cache-Control: no-store,
no redirect and no session, answered from memory.
Why does my Django health check return 400 behind a load balancer? Django answers 400 to a
host it does not find in ALLOWED_HOSTS, and a Hetzner check sends no Host header unless you set
its domain. Set the health check’s domain to your site’s hostname, which ALLOWED_HOSTS already
contains.
Why does a broken server stay healthy? Usually because the check never reaches the
application: it is answered by a redirect, which Hetzner accepts by default, by a cache, or by the
proxy on the server itself. Allow only 200 and make the application answer it.
How often should the load balancer check? Every 5 seconds with 2 retries takes a dead server out of rotation within about 10 seconds, at a cost of one small request per server every 5 seconds.