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.
- Read the check output:
docker inspect --format '{{json .State.Health}}' web | python3 -m json.tool. - If it says
curl: not found, use a Python one-liner or install curl in the image. - If it returns 400, add
localhosttoALLOWED_HOSTSor send aHostheader that is allowed. - If it returns 301, request a path exempt from
SECURE_SSL_REDIRECTor sendX-Forwarded-Proto: https. - 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.1is not in the list, so Django returns 400 (DisallowedHost). urllib raises anHTTPError, the command exits 1.SECURE_SSL_REDIRECT = True: the request is plain HTTP withoutX-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"}).
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.
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:8000listener. - Kubernetes ignores HEALTHCHECK; it uses liveness and readiness probes defined in the pod spec.
- Shell form.
CMD-SHELLruns 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
autohealtools or rely on the orchestrator.
Next steps
- Build a proper endpoint with Django Health Check Endpoint Guide.
- If the container stops instead of staying unhealthy, see Fix Django Docker Container Exits Immediately.
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.