# Localhost development stack. Builds from source, uses MinIO, fake providers and # disposable credentials. `docker-compose.yml` is the production/R2 stack and is # NOT usable locally; the two are deliberately separate files. # # docker compose -f compose.local.yaml up --build # # Defaults here mirror `.env.example`; copy it to `.env` only to customise. x-app: &app build: context: . dockerfile: local/Dockerfile environment: &environment APP_ENV: local DATABASE_URL: postgresql://${APP_DB_USER:-dtf_app}:${APP_DB_PASSWORD:-local-app-database-only}@db:5432/${POSTGRES_DB:-dtf_local} S3_ENDPOINT: http://storage:9000 S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}} S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork} AWS_ACCESS_KEY_ID: ${S3_APP_USER:-dtf_app} AWS_SECRET_ACCESS_KEY: ${S3_APP_PASSWORD:-local-app-storage-only} AWS_DEFAULT_REGION: us-east-1 OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only} # The browser reaches the API through the Site gateway, so the published # Site/Kanban origins must be accepted or every write is rejected 403. PUBLIC_ORIGIN: http://localhost:${SITE_PORT:-8080} ALLOWED_HOSTS: localhost,127.0.0.1 ALLOWED_ORIGINS: http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081} COOKIE_SECURE: "false" PAYMENT_ADAPTER: fake FREIGHT_ADAPTER: fake TINY_ADAPTER: fake WHATSAPP_ADAPTER: fake STORAGE_ADAPTER: s3-local MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500} MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120} UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608} STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:-53687091200} OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:-10737418240} MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10} SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728} networks: [local] init: true security_opt: [no-new-privileges:true] cap_drop: [ALL] read_only: true tmpfs: [/tmp] logging: driver: json-file options: {max-size: "10m", max-file: "3"} services: db: image: postgres:17-alpine environment: POSTGRES_DB: ${POSTGRES_DB:-dtf_local} POSTGRES_USER: ${POSTGRES_USER:-dtf_local} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-database-only} volumes: [postgres-data:/var/lib/postgresql/data] networks: [local] healthcheck: test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'] interval: 5s timeout: 3s retries: 30 storage: # quay.io, not Docker Hub: minio/minio there now answers anonymous pulls # with 401 authentication required, which breaks any runner that is not # logged in. Same image — identical image ID. Override MINIO_IMAGE to use # a mirror of your own. image: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z} command: server /data --console-address :9001 environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only} ports: - "127.0.0.1:${STORAGE_PORT:-9000}:9000" - "127.0.0.1:${STORAGE_CONSOLE_PORT:-9001}:9001" volumes: [storage-data:/data] networks: [local, edge] healthcheck: test: [CMD, mc, ready, local] interval: 5s timeout: 3s retries: 30 db-init: build: context: . dockerfile: local/Dockerfile command: python -m local.bootstrap environment: # Local only: the app role keeps a password distinct from the administrator. DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local} APP_DB_USER: ${APP_DB_USER:-dtf_app} APP_DB_PASSWORD: ${APP_DB_PASSWORD:-local-app-database-only} # The migration job seeds the first operator account from these, so an # existing deployment keeps its Kanban login after the accounts table # lands. Without them there would be no account at all and login would # fail closed with 503. OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only} networks: [local] depends_on: db: {condition: service_healthy} restart: on-failure storage-init: build: context: . dockerfile: local/Dockerfile.storage-init args: MINIO_IMAGE: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z} entrypoint: [/bin/sh, /init.sh] environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only} S3_APP_USER: ${S3_APP_USER:-dtf_app} S3_APP_PASSWORD: ${S3_APP_PASSWORD:-local-app-storage-only} S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork} networks: [local] depends_on: storage: {condition: service_healthy} restart: on-failure scanner: # Built, not bind-mounted: see local/Dockerfile.scanner. build: context: . dockerfile: local/Dockerfile.scanner args: CLAMAV_IMAGE: ${CLAMAV_IMAGE:-clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4} entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf] networks: [local] security_opt: [no-new-privileges:true] healthcheck: test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"] start_period: 60s interval: 10s timeout: 5s retries: 30 deploy: resources: limits: {memory: 3G} api: <<: *app command: uvicorn local.app:app --host 0.0.0.0 --port 8000 --no-access-log depends_on: db-init: {condition: service_completed_successfully} storage-init: {condition: service_completed_successfully} healthcheck: test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] interval: 5s timeout: 3s retries: 30 worker: <<: *app command: python -m local.worker depends_on: api: {condition: service_healthy} scanner: {condition: service_healthy} healthcheck: test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health')"] interval: 5s timeout: 3s retries: 12 site: build: context: . dockerfile: local/Dockerfile.web environment: S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}} ports: # Published ports are host-wide even bound to loopback, so on a shared # machine any of them can collide with something unrelated. CI overrides # every one; see .gitea/workflows/deploy.yml. - "127.0.0.1:${SITE_PORT:-8080}:80" # Convenience only: the API through its own gateway. No test uses it. - "127.0.0.1:${API_PORT:-8000}:81" networks: [local, edge] depends_on: api: {condition: service_healthy} healthcheck: test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1/health] interval: 5s timeout: 3s retries: 12 kanban: build: context: . dockerfile: local/Dockerfile.web environment: WEB_INDEX: kanban.html S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}} ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"] networks: [local, edge] depends_on: api: {condition: service_healthy} healthcheck: test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1/health] interval: 5s timeout: 3s retries: 12 volumes: postgres-data: storage-data: networks: local: internal: true edge: