Docker Compose
The docker-compose.yml at the pod-portals root orchestrates all services for local development.
Profiles
Services are split into two groups:
| Profile | Services | Started by |
|---|---|---|
| (default) | django, db, redis, celeryworker, celerybeat, portals, docs, mailpit, reports | make up |
test | db-test, django-test, celerybeat-test, celeryworker-test, portals-test, e2e-v2 | make test-up / make test-run |
legacy-e2e | cypress | docker compose --profile legacy-e2e run --rm cypress |
make up never starts the test stack. The test stack is opt-in only.
Dev Services
| Service | Image / Build | Port | Description |
|---|---|---|---|
django | ./django-api | 8000 | Django REST API |
db | mysql:8.0.35 | 8100 | MySQL — dev database |
redis | redis:7.2.4 | — | Cache + Celery broker |
celeryworker | ./django-api | — | Celery task worker |
celerybeat | ./django-api | — | Periodic task scheduler |
portals | node:20-alpine | 3000 | React SPA (Vite dev) |
docs | node:20-alpine | 4173 | VitePress docs |
mailpit | axllent/mailpit | 8025 | Local email catcher |
reports | python:3-alpine | 8090 | Serves Cypress HTML reports |
Test Services (--profile test)
| Service | Image / Build | Port | Description |
|---|---|---|---|
db-test | mysql:8.0.35 | — | MySQL — isolated test database (nhc_test) |
django-test | ./django-api | 8001 | Django REST API pointed at nhc_test |
celerybeat-test | ./django-api | — | Beat for test stack |
celeryworker-test | ./django-api | — | Worker for test stack |
portals-test | node:20-alpine | 3001 | React SPA pointed at port 8001 |
e2e-v2 | cypress/included:13.15.1 | — | Cypress runner (testing-v2) |
Common Commands
# First-time setup — copies .env files and builds images
make build
# Start dev services
make up
# Stop dev services
make down
# Start test stack
make test-up
# Seed test database (run once or after make clean)
make test-seed
# Run full E2E suite (seeds + Cypress)
make test-run
# Stop test stack
make test-down
# Rebuild after dependency changes
docker compose build django
# Seed the dev database (fixtures + test users)
make seed
# Run Django management commands
docker compose run --rm django python manage.py migrate
docker compose run --rm django python manage.py shell
# Run management commands against the test database
docker compose --profile test run --rm django-test python manage.py migrate
# Run backend tests
docker compose run --rm django pytest
# View Cypress HTML report (after a run)
docker compose up reports
# then open http://localhost:8090
# Tail logs for a specific service
docker compose logs -f celeryworker
docker compose logs -f portalsMail service (Mailpit)
All outgoing emails from Django are intercepted by Mailpit in local and staging environments. No emails reach real recipients.
Web UI: http://localhost:8025 — view all sent emails after make up.
How it works:
- Django's
EMAIL_BACKENDdefaults tosmtpinlocal.py EMAIL_HOST=mailpit/EMAIL_PORT=1025(set inbase.py) routes mail to the Mailpit container- Mailpit acts as an SMTP sink and exposes a UI to inspect what was sent
Switching to a real SMTP provider (production):
Set these in the production environment:
DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.yourprovider.com
EMAIL_PORT=587
EMAIL_HOST_USER=your-smtp-user
EMAIL_HOST_PASSWORD=your-smtp-password
EMAIL_USE_TLS=TrueSwitching to SendGrid (production):
DJANGO_EMAIL_BACKEND=anymail.backends.sendgrid.EmailBackend
SENDGRID_API_KEY=your-keyVolumes
| Volume | Mounted at | Purpose |
|---|---|---|
db_data | /var/lib/mysql | Persist dev MySQL data |
db_test_data | /var/lib/mysql | Persist test MySQL data |
django_venv | /app/.venv | Persist Poetry virtualenv (dev) |
django_test_venv | /app/.venv | Persist Poetry virtualenv (test) |
portals_node_modules | /app/node_modules | Isolate portals deps from host bind mount |
portals_test_node_modules | /app/node_modules | Isolate portals-test deps |
docs_node_modules | /app/node_modules | Isolate docs deps from host bind mount |
cypress_node_modules | /e2e/node_modules | Isolate legacy Cypress deps |
cypress_v2_node_modules | /e2e/node_modules | Isolate testing-v2 Cypress deps |
Environment Files
Each service reads its own .env file. make build copies the .example files automatically on first run. See Environment Variables for details.
Known Gotchas
createsuperuser error on restart
django/compose/local/start.sh runs python manage.py createsuperuser --noinput on every container start. On the first run this creates the admin user fine. On any subsequent restart, Django prints:
CommandError: That email address is already taken.This is a non-fatal error — the script continues and the server starts normally. You can safely ignore it in the logs. It is pre-existing behaviour in the original start script.
drf-spectacular schema errors on startup
During collectstatic, drf-spectacular introspects every view to build the OpenAPI schema. Some views require an authenticated, org-scoped request to resolve their serializer — running without one causes errors like:
Error [FormViewSet]: exception raised while getting serializer.
(Exception: 'AnonymousUser' object has no attribute 'is_admin')
Error [ContactFormView]: exception raised while getting serializer.
(Exception: 'Request' object has no attribute 'organization')
Error [PingView]: unable to guess serializer.These are non-fatal — drf-spectacular skips the affected views and the server starts normally. The OpenAPI schema will be incomplete for those endpoints until the underlying views are made schema-safe. See To Review for the open investigation item.
Node service cold start is slow
portals and docs run pnpm install every time the container starts from scratch (i.e. after make clean or a first run). Subsequent restarts skip the install because node_modules is persisted in a named volume. If you see the SPA or docs take a minute to come up on first boot, this is why.