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
| Service | Container | Image | Command | Ports | Volumes | Depends on |
|---|---|---|---|---|---|---|
django | emaw_django | built from ./Dockerfile → emaw-app:latest | collectstatic --noinput && migrate --noinput && gunicorn --bind 0.0.0.0:8000 --workers 4 --timeout 120 --graceful-timeout 30 --forwarded-allow-ips '*' EMAW_BACKEND.wsgi:application | none published | embeddings_data:/app/embeddings, log_data:/app/logs | redis, qdrant |
worker | emaw_worker | same | celery -A EMAW_BACKEND worker --loglevel=info --concurrency=2 | none | log_data:/app/logs | redis, django, qdrant |
beat | emaw_beat | same | celery -A EMAW_BACKEND beat --loglevel=info --schedule /tmp/celerybeat-schedule | none | log_data:/app/logs | redis, worker |
redis | emaw_redis | redis:7 | default | none | redis_data:/data | |
qdrant | emaw_qdrant | qdrant/qdrant:v1.8.0 | default | 127.0.0.1:7002:6333 (REST), 127.0.0.1:7003:6334 (gRPC) | qdrant_storage:/qdrant/storage | |
tunnel | emaw_tunnel | cloudflare/cloudflared:latest | tunnel --no-autoupdate run --token ${TUNNEL_TOKEN} | none | none | django |
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 whyTRUST_CF_CONNECTING_IPexists. migrateruns on every start of thedjangocontainer, against whateverDB_HOSTpoints 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
portsentry you must not commit. TUNNEL_TOKENis read by compose for thetunnelservice and is not inx-app-env, so Django never sees it.
Dockerfile
FROM python:3.12-slim;PYTHONDONTWRITEBYTECODE=1,PYTHONUNBUFFERED=1,LOG_DIR=/app/logs,TZ=America/Chicago.- apt:
build-essential,libpq-dev,ffmpeg(forpydubaudio analysis). pip install -r requirements.txt, thenCOPY . ..- Runs as non-root user
app(uid 10001) with/app/logs,/app/staticfiles,/app/embeddingspre-created. EXPOSE 8000.collectstaticis 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
| Volume | Holds | Lose it and... |
|---|---|---|
qdrant_storage | the niv embeddings collection | AI search returns nothing until load_bible_embeddings runs again (~500 MB download) |
embeddings_data | the downloaded parquet | re-downloaded on next load |
redis_data | cache, Celery queues, throttle counters, webhook de-dup keys | cache cold; queued tasks lost |
log_data | app.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.