Write a Docker Entrypoint Script for Django
Write a robust docker-entrypoint.sh for Django: wait for dependencies, optionally run migrations and collectstatic behind flags, support web, worker, and one-off commands, exec the final process for signals, and keep secrets out of logs.
Problem statement
A Django image usually runs several roles: the web server, Celery workers, Beat, and one-off management commands. An entrypoint script is where container startup logic lives: waiting for the database, deciding whether to run migrations, and launching the right process. Done badly, it runs migrations in every replica at once, swallows signals so containers take ten seconds to stop, prints secrets into logs, or makes docker run image python manage.py shell impossible. A small, disciplined script avoids all of that.
- Use
#!/bin/shandset -eu; keep the script short and POSIX. - Optionally wait for the database with a bounded retry loop.
- Run migrations only when an environment flag asks for it (
RUN_MIGRATIONS=1), ideally in a single dedicated container or Job. - Do not run
collectstaticat startup; do it at image build time. - End with
exec "$@"so the command (gunicorn,celery,python manage.py ...) becomes PID 1 and receives signals. - Set
ENTRYPOINT ["/app/docker-entrypoint.sh"]and the defaultCMDto the web server.
Step-by-step solution
1. The script
#!/bin/sh
# /app/docker-entrypoint.sh
set -eu
wait_for_db() {
python - <<'PY'
import os, sys, time
import psycopg
url = os.environ.get("DATABASE_URL")
if not url:
sys.exit(0)
for i in range(30):
try:
psycopg.connect(url, connect_timeout=3).close()
sys.exit(0)
except psycopg.OperationalError:
print(f"waiting for database ({i + 1}/30)", flush=True)
time.sleep(2)
sys.exit("database not reachable")
PY
}
if [ "${WAIT_FOR_DB:-1}" = "1" ]; then
wait_for_db
fi
if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
python manage.py migrate --noinput
fi
exec "$@"
The database wait prints attempt numbers, never the URL, so credentials do not land in logs.
2. Wire it into the Dockerfile
COPY --chmod=755 docker-entrypoint.sh /app/docker-entrypoint.sh
RUN SECRET_KEY=build-only python manage.py collectstatic --noinput
USER app
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["gunicorn", "-c", "gunicorn.conf.py", "config.wsgi:application"]
With ENTRYPOINT plus CMD, the container runs docker-entrypoint.sh gunicorn .... Any command given at runtime replaces CMD:
docker run myapp celery -A config worker -l info
docker run -it myapp python manage.py shell
3. Migrations: once, not per replica
Running migrate in every web container start means three replicas race to apply the same migrations. Django takes no global lock, so concurrent runs can fail or deadlock. Prefer a separate one-off step per release (a Compose migrate service, a Kubernetes Job, a release phase). The RUN_MIGRATIONS flag lets single-container setups, such as a small VPS with one web container, opt in.
4. Why exec matters
Without exec, the shell stays as PID 1 and Gunicorn is its child. docker stop sends SIGTERM to PID 1; sh does not forward it, so Docker waits ten seconds and then kills everything with SIGKILL, cutting off in-flight requests. With exec, Gunicorn is PID 1, receives SIGTERM, and shuts down gracefully.
5. What not to put in the entrypoint
collectstaticat every start: slow, and wrong for read-only filesystems. Do it in the image build.createsuperuserwith default passwords.pip installor anything that downloads at runtime.- Printing the environment (
env,set -xwith secrets present).
Verification check: docker run --rm myapp python -c "print('ok')" prints ok (commands pass through), docker stop on a web container returns in about a second (signals reach Gunicorn), and the logs show no connection strings.
Explanation
Docker concatenates ENTRYPOINT and CMD into the process it starts. An entrypoint script that ends with exec "$@" acts as a wrapper: it performs setup in a shell, then replaces itself with the requested command, keeping process ID 1 and its signal handling. This lets one image serve every role with predictable startup, while release-level tasks such as migrations stay explicit and run once.
Edge cases / notes
- Line endings and permissions cause "no such file or directory" and "permission denied"; see the entrypoint fix guide.
- tini or
--initadd an init process that reaps zombies; useful if your app spawns subprocesses. - Kubernetes can use
command/argsoverrides; the entrypoint still runs unlesscommandreplaces it. - Distroless images have no shell; implement the wait and flags in a small Python entrypoint instead.
Next steps
- Build the image itself with Dockerize Django for Production.
- Fix startup ordering with Make Docker Compose Wait for PostgreSQL Before Django Starts.
FAQ
Should my Django entrypoint run migrations?
Only behind a flag for single-container setups. With several replicas, run migrations once as a separate step.
Why end the entrypoint with exec "$@"?
So the final command becomes PID 1 and receives stop signals, allowing graceful shutdown.
Where should collectstatic run?
At image build time, so containers start fast and can use read-only filesystems.
How do I run a shell with an entrypoint image?
docker run -it myapp python manage.py shell; the entrypoint prepares and then executes your command.