# shopdb-flask single-site docker-compose template. # # Per ADR-004, each adopting facility runs its own stack. This template # brings up MySQL + the API container and exposes the API on port 5001. # The Vue frontend is served separately by the API in production builds # (see register_frontend_routes in shopdb/__init__.py); for dev, run # `npm run dev` in frontend/ on a separate port. # # Usage: # cp .env.example .env # # edit .env with site-specific secrets and origins # docker compose up -d # # Refresh after pulling new code: # docker compose build api # docker compose up -d api services: db: image: mysql:8.4 # utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the # image default. Keeps every site's schema on the same charset/collation. command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} MYSQL_DATABASE: shopdb_flask MYSQL_USER: shopdb MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} volumes: - db_data:/var/lib/mysql # Bind MySQL to loopback only. The api container reaches db over the # compose network regardless of this mapping; the published port is just # for local admin tools (mysqldump, a client on the host). Exposing 3306 # on all interfaces would put the database on the facility network. ports: - "127.0.0.1:${MYSQL_PORT:-3306}:3306" healthcheck: # 127.0.0.1, not localhost: localhost means the unix socket, and the # entrypoint's init pass answers on the socket while running the server # with --skip-networking. A socket ping therefore reports healthy DURING # init, and the api/migrate services start against a server that is about # to restart. Over TCP the probe stays red until the real server listens. test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s timeout: 5s retries: 5 api: build: . restart: unless-stopped depends_on: db: condition: service_healthy environment: FLASK_ENV: production DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4 SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set} JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set} CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set} LOG_LEVEL: ${LOG_LEVEL:-INFO} ZABBIX_URL: ${ZABBIX_URL:-} ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-} COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-} # This file is the source-build stack. Released channels ship through # the pull/airgap compose files, not this one. So default the badge label # to DEV here - a raw source build is by definition not a release - while # still allowing an override from .env. SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-DEV} ports: - "${API_PORT:-5001}:5001" # Terminate TLS in gunicorn with the mounted self-signed cert so the browser # sees a secure context. navigator.clipboard and other secure-context-only # web APIs refuse to run over plain http on a non-localhost origin, so the # test box (accessed by FQDN) needs real https. A site swaps in its own cert # under ./certs or fronts this with its IIS/nginx proxy. command: - gunicorn - --bind - 0.0.0.0:5001 # Threaded workers, not the blocking sync default. An agent that fails # the TLS handshake retries in a loop, and with 4 sync workers a handful # of those pinned every worker: real UI requests queued 20+ seconds while # the CPU sat idle. A stuck connection now holds one THREAD, and 32 # (4 x 8) have to be exhausted before a person waits. # # Found in production at the other site (their commit db75ca7). Their # server, our agent - so the same client behaviour will reach any site # running the endpoint agent, and the fix belongs in the product rather # than on one branch. - --worker-class - gthread - --workers - "4" - --threads - "8" # 30, not 60: a hung connection frees its thread in half the time, which # is what decides whether a retry storm is absorbed or queues. - --timeout - "30" - --access-logfile - "-" - --error-logfile - "-" - --certfile - /app/certs/server.crt - --keyfile - /app/certs/server.key - wsgi:app healthcheck: # Match the TLS listener: probe over https and accept the self-signed cert # (-k). The Dockerfile default probes http and would stay red once gunicorn # only speaks TLS. test: ["CMD", "curl", "--silent", "--insecure", "https://localhost:5001/api/auth/login", "-X", "POST", "-H", "Content-Type: application/json", "-d", "{}"] interval: 30s timeout: 5s start_period: 10s retries: 3 volumes: - ./plugins:/app/plugins:ro # Standalone operator scripts shipped inside generated bundles (the # endpoint-agent uninstaller). Read-only, same pattern as plugins: the # site-agent packaging route reads uninstall-shopdbagent.ps1 from here at # download time, so it must be visible in the container. - ./scripts:/app/scripts:ro # Self-signed TLS material for the dev/test https listener. Read-only; # generated on the host under ./certs and never baked into the image. - ./certs:/app/certs:ro # /app/instance is WRITTEN state, not code: plugins.json (which plugins # this site has enabled), uploaded floor plans, branding, model and # application images, employee photos, warranty proofs, slides, # printed-part files, and the Dell OAuth token. Without this volume a # `docker compose build api && up -d api` recreates the container and # takes all of it with it, so the site comes back with its plugins # disabled and MySQL rows pointing at files that no longer exist. - instance_data:/app/instance volumes: db_data: instance_data: