# The dev stack holds real data on some installs, and the django container parses # uploaded files with Ghostscript and Pillow. Without root or capabilities, a # compromised parser cannot take ownership of files or use root's reach over the # bind mounts. Still within its reach: everything this UID owns on those mounts, # including ./app, and the host's loopback services (network_mode: host). x-unprivileged: &unprivileged user: "${MY_PRACTICE_UID:-1000}:${MY_PRACTICE_GID:-1000}" cap_drop: - ALL security_opt: - no-new-privileges:true services: postgres: image: postgres:17-alpine container_name: my-practice-db restart: unless-stopped environment: - POSTGRES_DB=${POSTGRES_DB:-my_practice} - POSTGRES_USER=${POSTGRES_USER:-my_practice} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme123} # Loopback only: the dev password is a published default, and a bare # "5432:5432" listens on every interface (Docker also bypasses ufw). ports: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-my_practice} -d ${POSTGRES_DB:-my_practice}'] interval: 10s timeout: 5s retries: 5 # One-shot, before the unprivileged services start: hands them the files that # earlier root containers left behind, and the volumes an image seeds as root. # Only this service keeps a capability to change ownership. It has no network. fix-ownership: image: my-practice-django pull_policy: never restart: "no" network_mode: none cap_drop: - ALL cap_add: - CHOWN - DAC_READ_SEARCH security_opt: - no-new-privileges:true volumes: - ./app:/app - node_modules_django:/app/node_modules - static_data:/app/staticfiles - ${MY_PRACTICE_DATA_DIR:-../my-practice-data}/media:/app/media command: - sh - -c - >- find /app ! -user ${MY_PRACTICE_UID:-1000} -exec chown -h ${MY_PRACTICE_UID:-1000} {} + && find /app ! -group ${MY_PRACTICE_GID:-1000} -exec chgrp -h ${MY_PRACTICE_GID:-1000} {} + django: <<: *unprivileged image: my-practice-django build: context: ./app target: development container_name: my-practice-django restart: unless-stopped network_mode: host environment: # This UID has no passwd entry, so nothing else gives it a writable home # (fontconfig and npm write their caches there). - HOME=/tmp - DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY:-django-insecure-dev-key} - DJANGO_DEBUG=${DJANGO_DEBUG:-True} - DJANGO_LANGUAGE_CODE=${DJANGO_LANGUAGE_CODE:-de-de} - DJANGO_TIME_ZONE=${DJANGO_TIME_ZONE:-Europe/Berlin} - DJANGO_SESSION_IDLE_HOURS=${DJANGO_SESSION_IDLE_HOURS:-8} - REQUIRE_TWO_FACTOR=${REQUIRE_TWO_FACTOR:-false} - POSTGRES_DB=${POSTGRES_DB:-my_practice} - POSTGRES_USER=${POSTGRES_USER:-my_practice} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme123} - POSTGRES_HOST=127.0.0.1 - POSTGRES_PORT=5432 - USE_PROTON_BRIDGE=${USE_PROTON_BRIDGE:-false} - EMAIL_HOST=${EMAIL_HOST:-127.0.0.1} - EMAIL_PORT=${EMAIL_PORT:-1025} - EMAIL_HOST_USER=${EMAIL_HOST_USER:-} - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-} - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-} - GOOGLE_CALENDAR_CLIENT_ID=${GOOGLE_CALENDAR_CLIENT_ID:-} - GOOGLE_CALENDAR_CLIENT_SECRET=${GOOGLE_CALENDAR_CLIENT_SECRET:-} - FERNET_KEY=${FERNET_KEY:-} - MY_PRACTICE_DATA_DIR=/my-practice-data - MY_PRACTICE_REPO_ROOT=/repo extra_hosts: - "host.docker.internal:host-gateway" volumes: - ./app:/app # Named volume so the image's `npm install` (app/Dockerfile's development # stage) isn't shadowed by the ./app:/app bind mount above — the host # checkout has no node_modules/ of its own (see app/.dockerignore). # Separate volume per service: both containers populate it from their # own image on first start, and sharing one volume races the two. - node_modules_django:/app/node_modules # test_release_guardrails compares version strings across prod.py and # docker-compose.prod.yml at the repo root, which only ./app:/app can't # reach. Mounted as a directory, not per file: a single-file bind mount # pins the inode, and `sed -i` or an editor save replaces the file, so # the container kept seeing the pre-bump version until recreated. - .:/repo:ro # Backups and media live OUTSIDE the repo at $MY_PRACTICE_DATA_DIR (set in .env). # Default: ../my-practice-data (sibling directory next to the repo clone). - ${MY_PRACTICE_DATA_DIR:-../my-practice-data}/backups:/app/backups:ro - static_data:/app/staticfiles - ${MY_PRACTICE_DATA_DIR:-../my-practice-data}/media:/app/media - ${MY_PRACTICE_DATA_DIR:-../my-practice-data}/documents:/my-practice-data/documents - ${MY_PRACTICE_DATA_DIR:-../my-practice-data}/questionnaires:/my-practice-data/questionnaires healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8000/accounts/login/"] interval: 5s timeout: 5s retries: 24 start_period: 10s depends_on: fix-ownership: condition: service_completed_successfully postgres: condition: service_healthy # gunicorn binds loopback, like postgres above: network_mode: host makes the # bind address the only thing between the DEBUG stack (with the real data # directories mounted) and the local network. # compilemessages: the .mo catalogs are gitignored, and the ./app bind mount # hides the copy the image compiled, so the container compiles its own on # start. --reload-extra-file then picks up a recompile (e.g. from the # post-merge hook) without a restart; gunicorn's --reload only watches .py. command: > sh -c "python manage.py migrate --noinput && python manage.py collectstatic --noinput --ignore tailwind.css && python manage.py compilemessages -v 0 && gunicorn --bind 127.0.0.1:8000 --workers 1 --timeout 300 --reload --reload-extra-file locale/de/LC_MESSAGES/django.mo --reload-extra-file locale/en/LC_MESSAGES/django.mo config.wsgi:application" tailwind: <<: *unprivileged build: context: ./app target: development container_name: my-practice-tailwind restart: unless-stopped environment: - HOME=/tmp depends_on: fix-ownership: condition: service_completed_successfully volumes: - ./app:/app - node_modules_tailwind:/app/node_modules working_dir: /app command: npm run watch:css volumes: postgres_data: driver: local static_data: driver: local node_modules_django: driver: local node_modules_tailwind: driver: local