# 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: infra/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: ${PUBLIC_ORIGIN:-http://localhost:${SITE_PORT:-8080}} ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} ALLOWED_ORIGINS: ${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" # Sandbox testing only: set PAYMENT_ADAPTER=mercadopago with the MP_* test # credentials, or TINY_ADAPTER=tiny with a TINY_TOKEN, in .env. Never real # production credentials on a developer machine. PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake} PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret} MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-} MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-} MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-} MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-} FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake} JADLOG_TOKEN: ${JADLOG_TOKEN:-} JADLOG_CNPJ: ${JADLOG_CNPJ:-} JADLOG_CONTA: ${JADLOG_CONTA:-} JADLOG_CONTRATO: ${JADLOG_CONTRATO:-} JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-} JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-} FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0} # Carts the Site priced are approved at checkout; larger ones wait for review. QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true} QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50} TINY_ADAPTER: ${TINY_ADAPTER:-fake} TINY_CLIENT_ID: ${TINY_CLIENT_ID:-} TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-} TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-} TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-} TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-} TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-} TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-} TINY_ECOMMERCE_ID: ${TINY_ECOMMERCE_ID:-} 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: # MinIO stopped publishing public images: since September 2026 both # Docker Hub (minio/minio) and quay.io answer anonymous pulls with 401, # which breaks any machine or runner without a cached copy. Chainguard's # build still pulls anonymously, ships sh and mc (the healthcheck and # storage-init need both) and runs as a non-root user. Pinned by digest # because Chainguard's free tier only publishes :latest. Override # MINIO_IMAGE to use a mirror of your own. image: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1} 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: infra/Dockerfile command: python -m app.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: infra/Dockerfile.storage-init args: MINIO_IMAGE: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1} 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: infra/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 app.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 app.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: infra/Dockerfile.web environment: S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}} # Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md. PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-} PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-} 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: infra/Dockerfile.web environment: WEB_INDEX: kanban.html S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}} PAYMENT_CSP_SOURCES: "" PAYMENT_CHALLENGE_SOURCES: "" 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 browser-tests: profiles: [ci] build: context: . dockerfile: infra/Dockerfile.browser-tests environment: CHROME_BIN: /usr/bin/chromium CHROME_NO_SANDBOX: "1" CHROME_TRUST_TEST_ORIGINS: "1" SITE_BROWSER_ORIGIN: http://site KANBAN_BROWSER_ORIGIN: http://kanban OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only} shm_size: 1gb networks: [local] depends_on: site: {condition: service_healthy} kanban: {condition: service_healthy} storage: {condition: service_healthy} security_opt: [no-new-privileges:true] cap_drop: [ALL] volumes: postgres-data: storage-data: networks: local: internal: true edge: