version: "3.8" x-app-environment: &app-environment APP_ENV: production DATABASE_HOST: db DATABASE_NAME: dtf DATABASE_USER: dtf_app # Must differ from POSTGRES_PASSWORD: the runtime role is DML-only, and reusing # the administrator credential would make that restriction meaningless. DATABASE_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD} S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET} AWS_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:?set R2_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:?set R2_SECRET_ACCESS_KEY} AWS_DEFAULT_REGION: auto # Optional at deploy time so a missing Kanban credential cannot make the # entire Swarm stack invalid. The Kanban login endpoint fails closed until # this value is configured. OPERATOR_EMAIL: ${OPERATOR_EMAIL:-} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD} # fake until Mercado Pago is configured; mercadopago requires MP_ACCESS_TOKEN # and MP_WEBHOOK_SECRET or the API and worker refuse to start. Test # credentials (TEST-...) until the sandbox flows have passed. The card form # appears only with MP_PUBLIC_KEY, and then needs PAYMENT_CSP_SOURCES too. PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake} MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-} # The "assinatura secreta" from the webhook settings in Mercado Pago. MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-} # https:///api/payments/webhook, sent with every payment. MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-} MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-} # The fake adapter's secret. Optional: without it the webhook verifies # nothing and therefore accepts nothing, which is the correct state until a # provider is connected. Never set it to a value anyone could guess. PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} # fake offers pickup only. jadlog prices delivery with Jadlog and needs the # credentials below and the package weight from the client; it refuses to # start without them. The credentials alone are enough for the console # check, python -m app.jadlog_probe, on the worker. FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake} JADLOG_TOKEN: ${JADLOG_TOKEN:-} # The "Usuário" Jadlog issued: the CNPJ that contracts the freight. JADLOG_CNPJ: ${JADLOG_CNPJ:-} JADLOG_CONTA: ${JADLOG_CONTA:-} JADLOG_CONTRATO: ${JADLOG_CONTRATO:-} # Package weight in kg: a base plus each billed metre of film. JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-} JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-} # Working days of production added to Jadlog's delivery time. FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0} # A cart the Site priced is approved at checkout and can be paid at once; # orders above QUOTE_AUTO_MAX_METRES, or with a grade the Site did not # compute, wait for an operator on the Kanban (app/quote_review.py). QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true} QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50} # Order creation in Tiny stays off until it has been tested against the # client's account (Tiny has no sandbox). The application credentials can be # set now: they let an operator connect Tiny from the Kanban, and the worker # keeps that connection alive. Callback: https:///api/operator/tiny/callback 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:-} # The client's "retirar pessoalmente" forma de envio id, on pickup orders. TINY_FORMA_ENVIO_RETIRADA: ${TINY_FORMA_ENVIO_RETIRADA:-} # Sets "Aprovada" on paid orders and "Pronto para envio" on finished pickup # orders, which the client's Tiny -> n8n notices send to customers. Turn on # only together with TINY_ADAPTER=tiny and after n8n stops sending the # designer message for DTFIMP products. TINY_STATUS_UPDATES: ${TINY_STATUS_UPDATES:-false} WHATSAPP_ADAPTER: fake STORAGE_ADAPTER: s3-r2 PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN} PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN} ALLOWED_HOSTS: ${SITE_DOMAIN:?set SITE_DOMAIN},${KANBAN_DOMAIN:?set KANBAN_DOMAIN} ALLOWED_ORIGINS: https://${SITE_DOMAIN:?set SITE_DOMAIN},https://${KANBAN_DOMAIN:?set KANBAN_DOMAIN} COOKIE_SECURE: "true" MAX_UPLOAD_BYTES: "5368709120" UPLOAD_PART_BYTES: "8388608" # Sheets of several GB are the normal order, so room for many of them. STORAGE_QUOTA_BYTES: "${STORAGE_QUOTA_BYTES:-536870912000}" OWNER_UPLOAD_QUOTA_BYTES: "${OWNER_UPLOAD_QUOTA_BYTES:-53687091200}" MAX_PENDING_UPLOADS: "10" # ClamAV scans up to this; larger files (up to MAX_UPLOAD_BYTES) are released # after a file-format check instead (app/scanning.py). SCAN_MAX_BYTES: "2097152000" services: db: image: postgres:17-alpine environment: POSTGRES_DB: dtf POSTGRES_USER: dtf_admin POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} volumes: [postgres-data:/var/lib/postgresql/data] networks: [backend] healthcheck: test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'] interval: 10s timeout: 5s retries: 12 start_period: 20s deploy: replicas: 1 restart_policy: {condition: on-failure, delay: 10s} db-init: image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest} command: python -m app.bootstrap environment: DATABASE_ADMIN_HOST: db DATABASE_ADMIN_NAME: dtf DATABASE_ADMIN_USER: dtf_admin DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} APP_DB_USER: dtf_app APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD} # 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_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD} networks: [backend] deploy: replicas: 1 restart_policy: {condition: on-failure, delay: 5s, max_attempts: 20} scanner: image: clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4 entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf] configs: - source: clamd_config_2gb target: /etc/clamav/clamd.conf mode: 0444 networks: [backend] healthcheck: test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"] interval: 15s timeout: 5s retries: 20 start_period: 90s deploy: replicas: 1 restart_policy: {condition: on-failure, delay: 10s} resources: limits: {memory: 3G} api: image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest} environment: *app-environment networks: [backend, egress] read_only: true tmpfs: [/tmp] healthcheck: test: [CMD-SHELL, 'python -c "import os,urllib.request; r=urllib.request.Request(\"http://localhost:8000/health\",headers={\"Host\":os.environ[\"PUBLIC_HOST\"]}); urllib.request.urlopen(r,timeout=3)"'] interval: 15s timeout: 5s retries: 12 start_period: 30s deploy: replicas: 1 restart_policy: {condition: on-failure, delay: 5s} worker: image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest} command: python -m app.worker environment: *app-environment networks: [backend, egress] read_only: true tmpfs: [/tmp] healthcheck: test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health',timeout=3)"] interval: 15s timeout: 5s retries: 12 start_period: 90s deploy: replicas: 1 restart_policy: {condition: on-failure, delay: 5s} # Daily encrypted copy of the database to a bucket of its own, off this # server (ops/db_backup.py). Idle until the BACKUP_* settings are set. The # bucket's lifecycle rule removes old copies; this credential never deletes. backup: image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest} command: python -m ops.db_backup serve environment: DATABASE_ADMIN_HOST: db DATABASE_ADMIN_NAME: dtf DATABASE_ADMIN_USER: dtf_admin DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} # The R2 account endpoint (the same as R2_ENDPOINT) and a bucket and API # token for backups only, never the artwork bucket's. BACKUP_S3_ENDPOINT: ${BACKUP_S3_ENDPOINT:-} BACKUP_BUCKET: ${BACKUP_BUCKET:-} BACKUP_ACCESS_KEY_ID: ${BACKUP_ACCESS_KEY_ID:-} BACKUP_SECRET_ACCESS_KEY: ${BACKUP_SECRET_ACCESS_KEY:-} # The public key (age1...). Its private key stays off this server. BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-} # Hour of day in Brasília. BACKUP_HOUR: ${BACKUP_HOUR:-3} networks: [backend, egress] read_only: true tmpfs: [/tmp] deploy: replicas: 1 restart_policy: {condition: on-failure, delay: 30s} site: image: ${WEB_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-web}:${IMAGE_TAG:-latest} environment: WEB_INDEX: index.html PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN} S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} # Mercado Pago's card form loads from these origins; empty keeps the # Site at script-src 'self'. Set together with the Mercado Pago adapter. PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-} # The bank's confirmation page for debit and other 3-D Secure cards is # on the issuer's own domain, so it cannot be listed: "https:" lets # frames and form posts reach it (never scripts). Empty turns it off. PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-} networks: [backend] ports: - target: 8080 published: ${SITE_PORT:-18080} protocol: tcp mode: ingress healthcheck: test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health'] interval: 15s timeout: 5s retries: 12 start_period: 15s deploy: replicas: 1 restart_policy: {condition: on-failure, delay: 5s} kanban: image: ${WEB_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-web}:${IMAGE_TAG:-latest} environment: WEB_INDEX: kanban.html PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN} S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} PAYMENT_CSP_SOURCES: "" PAYMENT_CHALLENGE_SOURCES: "" networks: [backend] ports: - target: 8080 published: ${KANBAN_PORT:-18081} protocol: tcp mode: ingress healthcheck: test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health'] interval: 15s timeout: 5s retries: 12 start_period: 15s deploy: replicas: 1 restart_policy: {condition: on-failure, delay: 5s} configs: # Renamed whenever infra/clamd.conf changes: Swarm cannot update a deployed # config in place, and a redeploy with new content under the old name fails. clamd_config_2gb: file: ./infra/clamd.conf volumes: postgres-data: networks: backend: driver: overlay internal: true egress: driver: overlay