Fix a Django Docker Container Marked Unhealthy

Fix a Django Docker container stuck in unhealthy status: debug the HEALTHCHECK command, use a health endpoint that works without curl, ALLOWED_HOSTS and HTTPS redirects, start_period for migrations, and dependent services.

Problem statement

docker ps shows the Django container as Up 3 minutes (unhealthy). Compose services with depends_on: condition: service_healthy never start, Swarm keeps replacing the task, or a reverse proxy such as Traefik ignores the container. Yet the app responds fine from your browser.

A container's health is whatever its HEALTHCHECK command returns, run inside the container every interval. If that command is missing a tool, requests the wrong host or scheme, or runs before the app is ready, the container is marked unhealthy even though Django works. Docker records the exact output of the last checks, so the cause is one command away.

How Docker decides healthHEALTHCHECK runsinside containerCommand exits0 healthy, 1 notRetries exceededafter start_periodStatus unhealthydependents waitDocker only knows the exit code of your check command, not whether Django really works.
  1. Read the check output: docker inspect --format '{{json .State.Health}}' web | python3 -m json.tool.
  2. If it says curl: not found, use a Python one-liner or install curl in the image.
  3. If it returns 400, add localhost to ALLOWED_HOSTS or send a Host header that is allowed.
  4. If it returns 301, request a path exempt from SECURE_SSL_REDIRECT or send X-Forwarded-Proto: https.
  5. Give slow starts room with start_period (migrations, collectstatic, cold imports).

Step-by-step solution

1. Read the recorded output

docker inspect --format '{{json .State.Health}}' myproject-web-1 | python3 -m json.tool
{
  "Status": "unhealthy",
  "FailingStreak": 7,
  "Log": [
    { "ExitCode": 1, "Output": "/bin/sh: 1: curl: not found\n" }
  ]
}

The Output of the last five checks is the whole story. Run the same command yourself to iterate quickly:

docker compose exec web sh -c 'python -c "import urllib.request; print(urllib.request.urlopen(\"http://127.0.0.1:8000/health/\").status)"'

2. Do not depend on curl

python:3.x-slim images do not include curl or wget. Instead of adding packages, use Python, which is always there:

HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
  CMD python -c "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health/', timeout=4).status == 200 else 1)"

Or in Compose:

services:
  web:
    healthcheck:
      test: ["CMD", "python", "-c", "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health/', timeout=4).status == 200 else 1)"]
      interval: 30s
      timeout: 5s
      start_period: 40s
      retries: 3

3. Make the health URL pass Django's checks

The check requests http://127.0.0.1:8000/health/. Two Django settings commonly reject it:

  • ALLOWED_HOSTS: 127.0.0.1 is not in the list, so Django returns 400 (DisallowedHost). urllib raises an HTTPError, the command exits 1.
  • SECURE_SSL_REDIRECT = True: the request is plain HTTP without X-Forwarded-Proto, so Django returns a 301 to HTTPS.

Exempt the health path from the redirect and allow the internal host:

ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") + ["127.0.0.1", "localhost"]
SECURE_REDIRECT_EXEMPT = [r"^health/$"]

Or have the check send headers: urllib.request.Request(url, headers={"Host": "example.com", "X-Forwarded-Proto": "https"}).

what State.Health.Log saysCheck outputCause and fixcurl: not foundslim image has no curl;check with python urllibHTTP Error 400DisallowedHost; allow127.0.0.1 or send HostHTTP Error 301 or redirectSSL redirect; exempt/health/ or send the headerConnection refusednot listening yet, wrongport, or bound to a sockettimed outhealth view too slow; makeit cheap, raise timeoutThe last five outputs are stored; read them before rewriting the checkdjango-deployment.com
docker inspect shows exactly why each check failed.

4. Allow for startup time

If the container runs migrations or collectstatic before Gunicorn starts, the first checks fail. During start_period, failures do not count towards retries. Set it longer than your worst-case startup:

healthcheck:
  start_period: 90s
  start_interval: 5s   # Docker 25+: check often during start_period

Better still, run migrations as a separate one-off step in your deploy rather than in every container start.

5. Keep the health view cheap and honest

A health endpoint should confirm the process can serve requests and reach critical dependencies, quickly:

from django.db import connection
from django.http import JsonResponse

def health(request):
    with connection.cursor() as cur:
        cur.execute("SELECT 1")
    return JsonResponse({"status": "ok"})

Avoid checks that call external APIs or run heavy queries; a slow database for one minute should not make every container restart. See Django Health Check Endpoint Guide.

6. Check what depends on health

services:
  worker:
    depends_on:
      web:
        condition: service_healthy

If web never becomes healthy, worker never starts. Make sure only true dependencies use service_healthy.

a health check that tells the truthstart_period 40-90 scovers cold start and importsinterval 30 s, retries 3about 90 s to mark unhealthypython urllib, timeout 4 sno extra packages neededGET /health/ on 127.0.0.1allowed host, redirect exemptSELECT 1, no external callsfast and dependency-awareEach layer removes one reason a working app looks unhealthydjango-deployment.com
Tune the timing last; most unhealthy containers have a broken command, not slow apps.

Verification check: docker ps shows (healthy) within start_period plus one interval, and docker inspect shows ExitCode: 0 entries in the health log. Stop the database briefly and confirm the status turns unhealthy, then recovers.

Explanation

Docker's HEALTHCHECK is a periodic docker exec of your command. Exit code 0 means healthy, 1 means unhealthy, and anything else is reserved. Docker itself only reports the status; orchestration features act on it, such as Compose dependency ordering, Swarm task replacement, and proxies that only route to healthy containers. Because the command runs inside the container's network namespace, 127.0.0.1 reaches Gunicorn directly, bypassing Nginx, which is why Django's host and HTTPS checks apply to it.

Edge cases / notes

  • Unix sockets. If Gunicorn binds to a socket instead of a port, check via the socket or bind an additional 127.0.0.1:8000 listener.
  • Kubernetes ignores HEALTHCHECK; it uses liveness and readiness probes defined in the pod spec.
  • Shell form. CMD-SHELL runs through /bin/sh; distroless images have no shell, so use the exec form.
  • Restart policies do not restart unhealthy containers in plain Docker; only Swarm and some orchestrators do. Use autoheal tools or rely on the orchestrator.

Next steps

FAQ

Why is the container unhealthy when the site works?

The health check command itself fails, often because curl is missing or Django rejects the internal request.

Does Docker restart unhealthy containers?

Plain Docker does not. Swarm replaces unhealthy tasks; Compose only uses health for dependency ordering.

Should the health check hit the database?

A quick SELECT 1 is reasonable. Avoid slow queries or external services.

What does start_period do?

Failures during it do not count towards retries, giving the app time to start.

Keep going