Skip to main content

Docker stack

The backend runs as one Docker Compose project (name: emaw_backend) defined in docker-compose.yml. The same file runs on the dev VM, on a developer's laptop, and in production through Portainer.

Services​

ServiceContainerImageCommandPortsVolumesDepends on
djangoemaw_djangobuilt from ./Dockerfile → emaw-app:latestcollectstatic --noinput && migrate --noinput && gunicorn --bind 0.0.0.0:8000 --workers 4 --timeout 120 --graceful-timeout 30 --forwarded-allow-ips '*' EMAW_BACKEND.wsgi:applicationnone publishedembeddings_data:/app/embeddings, log_data:/app/logsredis, qdrant
workeremaw_workersamecelery -A EMAW_BACKEND worker --loglevel=info --concurrency=2nonelog_data:/app/logsredis, django, qdrant
beatemaw_beatsamecelery -A EMAW_BACKEND beat --loglevel=info --schedule /tmp/celerybeat-schedulenonelog_data:/app/logsredis, worker
redisemaw_redisredis:7defaultnoneredis_data:/data
qdrantemaw_qdrantqdrant/qdrant:v1.8.0default127.0.0.1:7002:6333 (REST), 127.0.0.1:7003:6334 (gRPC)qdrant_storage:/qdrant/storage
tunnelemaw_tunnelcloudflare/cloudflared:latesttunnel --no-autoupdate run --token ${TUNNEL_TOKEN}nonenonedjango

All services use restart: unless-stopped, the tunnel-net bridge network, and json-file logging capped at 5 × 20 MB. django, worker and beat share the x-app-env environment anchor and get host.docker.internal mapped to the host gateway.

Things worth noticing:

  • No port reaches the internet. gunicorn is only reachable on the compose network; the tunnel is the only ingress. That is why --forwarded-allow-ips '*' is safe and why TRUST_CF_CONNECTING_IP exists.
  • migrate runs on every start of the django container, against whatever DB_HOST points at. In dev that is the shared database.
  • Qdrant's REST port is published on localhost so you can load embeddings from the host; nothing else is.
  • Redis is not published. Running Django on the host against the compose Redis needs a local-only ports entry you must not commit.
  • TUNNEL_TOKEN is read by compose for the tunnel service and is not in x-app-env, so Django never sees it.

Dockerfile​

Dockerfile:

  • FROM python:3.12-slim; PYTHONDONTWRITEBYTECODE=1, PYTHONUNBUFFERED=1, LOG_DIR=/app/logs, TZ=America/Chicago.
  • apt: build-essential, libpq-dev, ffmpeg (for pydub audio analysis).
  • pip install -r requirements.txt, then COPY . ..
  • Runs as non-root user app (uid 10001) with /app/logs, /app/staticfiles, /app/embeddings pre-created.
  • EXPOSE 8000. collectstatic is deliberately run at container start, not build time, so env vars are available.

.dockerignore excludes .git, .venv, .env*, logs, sqlite files, emaw_backup.json, media/, embeddings/, qdrant_storage/, staticfiles/.

Volumes​

VolumeHoldsLose it and...
qdrant_storagethe niv embeddings collectionAI search returns nothing until load_bible_embeddings runs again (~500 MB download)
embeddings_datathe downloaded parquetre-downloaded on next load
redis_datacache, Celery queues, throttle counters, webhook de-dup keyscache cold; queued tasks lost
log_dataapp.log, error.log, security.log (rotated)history gone; stdout logging is always on anyway

docker compose down -v deletes all four.

Common commands​

docker compose up --build # build image, start everything, follow logs
docker compose up -d # detached
docker compose ps # who is running (also tells you if the tunnel is up)
docker compose logs -f django # one service's logs
docker compose exec django python manage.py shell
docker compose exec django python manage.py load_bible_embeddings # first run only
docker compose down # stop; keeps volumes
docker compose down -v # stop and wipe volumes
docker compose up -d redis qdrant # only the data services (host-run Django)

Production differences​

In production the same compose file is deployed by Portainer on the DockerApp VM with the production .env (DJANGO_SETTINGS_MODULE=EMAW_BACKEND.settings.production). There is no CI that builds the image; Portainer builds from the checked-out repo. See Deployment.