skip to content

A Django health-check view answers an orchestrator's HTTP probe with 400 or a redirect although the app is healthy; which Django settings cause this, and how do you fix it?

level: seniorimportance: should knowfreq 44%

answer

  1. what Host the probe sends
  2. get_host() in the middleware
  3. plain HTTP meets SSL redirect
  4. anonymous request, login middleware

basics

~20 s

Probes often send the container's IP as Host, which ALLOWED_HOSTS rejects with 400, and plain HTTP, which SECURE_SSL_REDIRECT answers with a 301. Send an allowed Host, exempt the path with SECURE_REDIRECT_EXEMPT, and mark the view login_not_required.

solid answer

~40 s

Django ships no health view, so the probe's request runs through the whole middleware stack. `CommonMiddleware` calls `request.get_host()` on every request; a probe whose `Host` is the container IP, say `10.0.3.17:8000`, is not in `ALLOWED_HOSTS`, so `DisallowedHost` is raised and turned into a 400. With `SECURE_SSL_REDIRECT = True`, a plain-HTTP probe that carries no header matched by `SECURE_PROXY_SSL_HEADER` gets a 301 from `SecurityMiddleware`; `SECURE_REDIRECT_EXEMPT = [r"^healthz/$"]` exempts it. `APPEND_SLASH` redirects `/healthz` to `/healthz/`, and on Django 5.1+ `LoginRequiredMiddleware` redirects anonymous requests unless the view is decorated with `@login_not_required`. Fix the Host by configuring the probe to send an allowed host or adding the container address to `ALLOWED_HOSTS` at start, never with `"*"`.

code

python · 25 lines
python
# health/views.py
from django.contrib.auth.decorators import login_not_required
from django.db import DatabaseError, connection
from django.http import JsonResponse
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_safe


@login_not_required
@never_cache
@require_safe
def healthz(request):
    try:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
    except DatabaseError:
        return JsonResponse({"status": "unavailable"}, status=503)
    return JsonResponse({"status": "ok"})


# urls.py: path("healthz/", healthz)

# settings.py
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [r"^healthz/$"]  # matched without the leading slash

go deeper

for a junior

Know that a health-check view is an ordinary Django view you write yourself and that ALLOWED_HOSTS must list the host names clients use.

for a middle

Explain how get_host() in CommonMiddleware turns an unlisted Host into a 400, and how SECURE_SSL_REDIRECT, APPEND_SLASH and LoginRequiredMiddleware produce redirects.

for a senior

Diagnose a failing probe from the status code and Location header, fix it without weakening ALLOWED_HOSTS, and write a view that survives the production middleware stack.

for a principal

Decide how much of the middleware stack a probe should exercise, weighing a realistic signal against a probe that fails for configuration reasons unrelated to health.

## Django has no built-in health view Django ships no health-check endpoint, so a project writes one: a function view mapped to a path such as `healthz/` that returns `200` when the process can serve and, if the team wants, `503` when a dependency such as the database is unreachable. Whether a probe should check dependencies at all is a probe-design choice made elsewhere. The Django-specific trouble is that the probe's request passes through the **whole middleware stack** before it reaches the view, and several production settings answer it first. ## Why the probe gets a 400 - An orchestrator's HTTP probe often connects to the container's own address and sends it as the `Host` header, for example `10.0.3.17:8000`. - `CommonMiddleware.process_request` calls `request.get_host()` on every request, and `SecurityMiddleware` calls it when it builds an HTTPS redirect (unless `SECURE_SSL_HOST` is set). `get_host()` validates the host against `ALLOWED_HOSTS` and raises **`DisallowedHost`**, a `SuspiciousOperation`, which Django turns into a **400** and logs on the `django.security.DisallowedHost` logger. - It works on a laptop because, with `DEBUG = True` **and** an empty `ALLOWED_HOSTS`, Django allows `.localhost`, `127.0.0.1` and `[::1]`. Fixes, best first: 1. Configure the probe to send a `Host` header that is already in `ALLOWED_HOSTS`. 2. Add the container's own address to `ALLOWED_HOSTS` at start, read from an environment variable the platform provides. 3. Answer the probe path in a small middleware placed **first** in `MIDDLEWARE`, above `SecurityMiddleware` and `CommonMiddleware`. It returns before `get_host()` is ever called, at the cost of skipping every check below it. `ALLOWED_HOSTS = ["*"]` also silences the error, and it does so by switching off Host-header validation for every request, which is the protection the setting exists to provide. ## Why the probe gets a redirect | Response | Source | Fix | |---|---|---| | `301` to `https://...` | `SecurityMiddleware` with `SECURE_SSL_REDIRECT = True`: the probe speaks plain HTTP and sends no header matched by `SECURE_PROXY_SSL_HEADER`, so `request.is_secure()` is false | `SECURE_REDIRECT_EXEMPT = [r"^healthz/$"]`; the regexes are matched against the path **without its leading slash** | | `301` to `/healthz/` | `CommonMiddleware` with `APPEND_SLASH = True`: the probe asked for `/healthz` and only `healthz/` resolves | point the probe at the exact path | | `302` to the login page | `LoginRequiredMiddleware` (Django 5.1+) redirects anonymous requests | decorate the view with `@login_not_required` | A redirect is worse than an error in one respect: depending on how the probe treats 3xx responses, it may even count as success while proving only that middleware runs, never that the view works. ## Writing the view - Use `require_safe` rather than `require_GET` if the probe may send `HEAD`; `require_safe` allows both methods. - Add `never_cache` so the response carries headers that stop an intermediate cache from answering the probe with a stale `200`. - Put `@login_not_required` **outermost**: `LoginRequiredMiddleware.process_view` reads the `login_required` attribute on the callable the URLconf resolved to. - If it checks the database, run a trivial query through `connection.cursor()` and catch `DatabaseError` (which covers `OperationalError`), returning `503`. - Keep it cheap and unauthenticated, and return no configuration, version or dependency details. ## Why it only breaks in production Each cause is switched off or relaxed in a typical development setup, which is why the same view passes locally and fails behind the orchestrator: | Setting | Typical development value | Typical production value | Effect on the probe | |---|---|---|---| | `DEBUG` | `True` | `False` | with `True` and an empty `ALLOWED_HOSTS`, localhost variants are allowed | | `ALLOWED_HOSTS` | empty | the public host names only | the container's IP is not listed | | `SECURE_SSL_REDIRECT` | `False` | `True` | plain-HTTP probes are redirected | | `LoginRequiredMiddleware` | often absent | present | anonymous probes are redirected to login | The lesson for a release is to exercise the health path with production settings before the orchestrator depends on it, for example in a staging environment that uses the same settings module and the same probe configuration. ## Diagnosing it 1. Call the path from inside the container with the probe's exact `Host` header and scheme, then again with the public host name, and compare. 2. For a 400, read the `django.security.DisallowedHost` log line; it names the rejected host. 3. For a redirect, read the `Location` header: an `https://` target points at `SECURE_SSL_REDIRECT`, a trailing-slash target at `APPEND_SLASH`, a login URL at `LoginRequiredMiddleware`.

  • Why not fix the Django probe's 400 with ALLOWED_HOSTS = ['*']?
    It removes Host-header validation for every request, not just the probe. `request.get_host()` feeds absolute URLs such as password-reset links and redirects, so accepting any host lets an attacker influence them. Send an allowed host from the probe, add the container's own address at start, or answer the probe in a middleware placed before the host check.
  • Where in Django's MIDDLEWARE would a probe-answering middleware go, and what does it skip?
    First in the list, above `SecurityMiddleware` and `CommonMiddleware`. Returning a response there short-circuits everything below it on the way in: host validation, the HTTPS redirect, sessions, authentication and `LoginRequiredMiddleware`. That is why it avoids the 400, and also why it must do nothing but answer the probe path.

saying these in an interview costs you the question

  • ALLOWED_HOSTS is only checked for POST requests, so a GET probe cannot hit it.
  • Setting ALLOWED_HOSTS = ['*'] is the standard fix for probe failures.
  • SECURE_PROXY_SSL_HEADER stops the redirect even when the probe sends no forwarded header.
  • Django ships a built-in health-check view you only need to route.
  • A 301 from the health path means the application behind it is healthy.