# Self-host / prod stack. Boot the core with: docker compose --profile full up --build # Convert a model (job): docker compose run --rm converter /data/model.ifc /data/model.frag # # Resource limits (opt-in): every service below has a commented-out `deploy.resources.limits` # block keyed off env vars (AEC_*_MEM_LIMIT / AEC_*_CPUS — see .env.example for sizing guidance). # They are OFF by default on purpose: a hard memory cap that is too low will OOM-kill a legitimate # large-IFC conversion mid-run. Uncomment and set values sized for YOUR host before enabling. # `deploy.resources.limits` is honored by `docker compose up` (v2); no Swarm required. services: postgres: image: postgres:17 environment: POSTGRES_USER: "${POSTGRES_USER:-bim}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-bim}" POSTGRES_DB: "${POSTGRES_DB:-bim}" ports: ["5432:5432"] volumes: ["postgres-data:/var/lib/postgresql/data"] healthcheck: test: ["CMD-SHELL", "pg_isready -U bim"] interval: 5s timeout: 3s retries: 10 # deploy: # opt-in cap — uncomment + size for your host # resources: # limits: # memory: "${AEC_PG_MEM_LIMIT:-2g}" minio: # Pinned. An unpinned `minio/minio` resolves to :latest, so two developers -- or a developer and # CI -- can silently run different object stores, and a breaking upstream release arrives without # a commit. Dependabot's docker ecosystem keeps the pins fresh via PRs. image: minio/minio:RELEASE.2025-04-22T22-12-26Z command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: "${S3_ACCESS_KEY:-minioadmin}" MINIO_ROOT_PASSWORD: "${S3_SECRET_KEY:-minioadmin}" ports: ["9000:9000", "9001:9001"] volumes: ["minio-data:/data"] healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 5s timeout: 3s retries: 10 # deploy: # opt-in cap — uncomment + size for your host # resources: # limits: # memory: "${AEC_MINIO_MEM_LIMIT:-1g}" api: image: massing-api build: context: . dockerfile: services/api/Dockerfile depends_on: postgres: { condition: service_healthy } minio: { condition: service_healthy } environment: &api-env DATABASE_URL: "postgresql+psycopg://${POSTGRES_USER:-bim}:${POSTGRES_PASSWORD:-bim}@postgres:5432/${POSTGRES_DB:-bim}" S3_ENDPOINT: http://minio:9000 S3_ACCESS_KEY: "${S3_ACCESS_KEY:-minioadmin}" S3_SECRET_KEY: "${S3_SECRET_KEY:-minioadmin}" S3_BUCKET: "${S3_BUCKET:-aec-bim}" AEC_RBAC: "${AEC_RBAC:-0}" # set 1 to enforce project roles AEC_API_KEY: "${AEC_API_KEY:-}" # bearer token = admin (set in prod) AEC_AUTH_SECRET: "${AEC_AUTH_SECRET:-}" # signs login tokens (falls back to AEC_API_KEY) AEC_CORS_ORIGINS: "${AEC_CORS_ORIGINS:-http://localhost:5173}" UVICORN_WORKERS: "${UVICORN_WORKERS:-4}" # multi-worker (Postgres) so a reconvert can't freeze the API # JOB-WORKER-SPLIT: the heavy job kinds (COBie export, bundles, generative runs) are full-model # IFC parses. Run inside this container they compete for CPU and memory with every request it is # serving, so the compose stack runs them in the `worker` service below instead. Set this to # `inline` to go back to one container doing both — the code default is inline precisely so a # deployment that is NOT using this file is unaffected. AEC_JOB_WORKER: "${AEC_JOB_WORKER:-off}" APS_CLIENT_ID: "${APS_CLIENT_ID:-}" # optional paid Autodesk RVT bridge APS_CLIENT_SECRET: "${APS_CLIENT_SECRET:-}" ports: ["8000:8000"] volumes: ["ifc-data:/app/ifc"] # uploaded source IFCs persist across restarts healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] interval: 5s timeout: 3s retries: 20 profiles: ["full"] # deploy: # opt-in cap — uncomment + size for your host # resources: # keep generous: UVICORN_WORKERS=4 + an in-process reconvert # limits: # memory: "${AEC_API_MEM_LIMIT:-4g}" # cpus: "${AEC_API_CPUS:-2}" # JOB-WORKER-SPLIT — the durable queue, in its own container. Same image and same environment as # the API (the `&api-env` anchor above, so the two cannot drift apart), different command. # # Scale it independently of request serving, which is the entire point: # docker compose --profile full up -d --scale worker=3 # That is safe without any further change because job claim is a compare-and-swap and `pid_lock` # takes a Postgres advisory lock — two workers cannot both win one job, and a mutating job still # serialises against a concurrent API edit on the same project. # # No ports and no healthcheck: it serves nothing, so there is nothing to probe. "Is the queue # moving?" is a question about the oldest `queued` row, not about this container being up — a # wedged worker and an idle one look identical from outside. worker: image: massing-api build: context: . dockerfile: services/api/Dockerfile depends_on: postgres: { condition: service_healthy } minio: { condition: service_healthy } environment: <<: *api-env AEC_JOB_WORKER: "inline" # this process IS the worker; never `off` or it does nothing at all command: ["python", "-m", "aec_api.worker"] # The image bakes a HEALTHCHECK that curls localhost:8000/health. This service binds no port and # serves nothing, so that probe can never pass and the container sits permanently `unhealthy` — # which is worse than no signal, because it trains whoever is watching to ignore the column. The # comment above claimed there was no healthcheck; there was, inherited from the image. healthcheck: { disable: true } volumes: ["ifc-data:/app/ifc"] # same uploaded-IFC volume as the API — jobs re-parse those files profiles: ["full"] # deploy: # opt-in cap — size for your host; this is where the heavy parses # resources: # now live, so it wants the generous limit the API no longer needs # limits: # memory: "${AEC_WORKER_MEM_LIMIT:-4g}" # cpus: "${AEC_WORKER_CPUS:-2}" # one-shot demo seeder (api must be enabled, so pass both profiles): # docker compose --profile full --profile seed run --rm seed # DEV/TEST ONLY — never run against production. The script refuses if projects already exist # (see docs/PRODUCTION_CHECKLIST.md); --force overrides only for labs. seed: image: massing-api depends_on: api: { condition: service_healthy } command: ["python", "/app/services/api/seed_demo.py", "--url", "http://api:8000", "--user", "gc"] profiles: ["seed"] web: build: context: . dockerfile: apps/web/Dockerfile depends_on: api: { condition: service_started } ports: ["${WEB_PORT:-8080}:80"] profiles: ["full"] # deploy: # opt-in cap — uncomment + size for your host # resources: # nginx serving static assets — modest footprint # limits: # memory: "${AEC_WEB_MEM_LIMIT:-256m}" converter: build: context: . dockerfile: services/converter/Dockerfile volumes: ["./data:/data"] # drop IFCs here; .frag written here profiles: ["tools"] # job-style: `docker compose run --rm converter ...` # deploy: # opt-in cap — uncomment + size for your host # resources: # IFC→Fragments is memory-hungry: size well above your # limits: # largest model and against AEC_PUBLISH_WORKERS (PR #70), # memory: "${AEC_CONVERTER_MEM_LIMIT:-8g}" # since parallel jobs each hold a model in RAM. # cpus: "${AEC_CONVERTER_CPUS:-4}" volumes: postgres-data: minio-data: ifc-data: