docker-compose.yml
Rama main · réplica de lectura del repositorio original.
# 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