# Base compose stack, and the install path. Uses ${VAR:-default} throughout so # the stack boots with zero config — but those defaults are DEV defaults, and # two of them (DB_PASSWORD, SECRET_KEY) are published in this file. Copy # .env.example to .env and set them before running this anywhere real. # # To run FabledCurator: # # docker compose -f docker-compose.yml up -d # # The -f is load-bearing. Without it Compose auto-merges # docker-compose.override.yml, which replaces every image: with a local # build: and turns on DEBUG logging — the contributor path. Naming this file # explicitly skips the override and pulls the published :latest images. # # FabledCurator has no authentication. Whatever can reach ${PORT} is an # administrator, including over the stored Patreon/SubscribeStar/Pixiv session # cookies. Do not publish this port beyond a network you trust — see # "Before you expose it" in README.md. # Rolling-deploy safety (Swarm / `docker stack deploy`): update one task at a # time, START the new task before stopping the old (zero-downtime via the ingress # mesh), and if the new task doesn't reach a healthy state within `monitor`, roll # back to the previous image automatically. `monitor` is sized above web's # healthcheck start_period so a broken image that never goes healthy is caught. # Plain `docker compose up` ignores `deploy:` (it warns + skips), so the dev # override is unaffected. Referenced by each long-lived service below. x-deploy-policy: &deploy_policy update_config: order: start-first failure_action: rollback monitor: 90s rollback_config: order: start-first restart_policy: condition: any delay: 10s # Worker liveness: ping THIS container's celery node over the broker. Lenient # (60s interval, 3 retries, 60s start_period) so a transient broker blip never # false-flags a worker into a rollback. `$$HOSTNAME` → `$HOSTNAME` for the shell; # celery's default node name is celery@ (the container id). x-celery-healthcheck: &celery_healthcheck test: ["CMD-SHELL", "celery -A backend.app.celery_app:celery inspect ping -d celery@$$HOSTNAME --timeout 10 >/dev/null 2>&1"] interval: 60s timeout: 15s retries: 3 start_period: 60s services: redis: image: redis:7-alpine volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_USER: ${DB_USER:-fabledcurator} POSTGRES_PASSWORD: ${DB_PASSWORD:-fabledcurator_dev} POSTGRES_DB: ${DB_NAME:-fabledcurator} volumes: - postgres_data:/var/lib/postgresql/data # Docker's default /dev/shm is 64MB; VACUUM (ANALYZE) + parallel queries # allocate a POSIX dynamic-shared-memory segment in /dev/shm and fail with # "could not resize shared memory segment ... No space left on device" # (operator-flagged 2026-06-07, vacuum_analyze on import_task needed 67MB). # NOTE: a `shm_size:` key is SILENTLY IGNORED under Docker Swarm # (`docker stack deploy` — the prod deployment here), so size /dev/shm via # a tmpfs mount instead — honored by both Swarm and plain Compose. - type: tmpfs target: /dev/shm tmpfs: size: 536870912 # 512 MB healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fabledcurator}"] interval: 10s timeout: 5s retries: 5 web: # :latest, NOT :dev — this file IS the install path. # # `docker compose up -d` merges docker-compose.override.yml, which sets # build: for all five app services, and a build: wins over image:. So a # contributor never pulls this tag and is unaffected by what it says. # # The tag is consulted only on `docker compose -f docker-compose.yml up -d` # — the documented production path, which skips the override. That is a # stranger installing the product, and they must land on the stable channel. # # :latest is main, which IS production (rule 147). :dev is the rolling # bleeding-edge channel we work out of, republished several times a day with # no stability promise. This file pinned :dev on all five services until # 2026-08-31 (#3270), so the documented install shipped development builds. # It went unnoticed because nobody who works on the project takes this path: # the operator deploys from a swarm stack file, contributors get the # override. Do not "fix" this back to :dev while debugging — use the # override, or -f with an explicit tag on the command line. image: git.fabledsword.com/bvandeusen/fabledcurator:latest command: ["web"] # Graceful shutdown: give the container time to drain in-flight work on a # deploy (docker SIGTERMs, then SIGKILLs after this window — default is only # 10s, far too short for real jobs). Hypercorn/Celery both warm-shut-down on # SIGTERM; per-lane values sized to typical task length. Anything that still # outruns the window is re-queued (task_reject_on_worker_lost) and re-driven # by the 5-min recovery sweeps, so a kill never corrupts. web = short HTTP # requests + the occasional file download. stop_grace_period: 30s # Liveness for rolling deploys: /api/health is a no-DB 200 (just proves the # app booted + serves HTTP after `alembic upgrade head`). start_period covers # the migration + boot so a slow start isn't mis-flagged. healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/api/health', timeout=5).status==200 else 1)\""] interval: 15s timeout: 6s retries: 3 start_period: 40s deploy: *deploy_policy ports: - "${PORT:-8080}:8080" environment: &app_env DB_USER: ${DB_USER:-fabledcurator} DB_PASSWORD: ${DB_PASSWORD:-fabledcurator_dev} DB_HOST: postgres DB_PORT: "5432" DB_NAME: ${DB_NAME:-fabledcurator} CELERY_BROKER_URL: redis://redis:6379/0 CELERY_RESULT_BACKEND: redis://redis:6379/0 SECRET_KEY: ${SECRET_KEY:-dev_secret_key_not_for_production_change_me} LOG_LEVEL: ${LOG_LEVEL:-INFO} volumes: - ./images:/images - ./import:/import # /import is the staging area for ingesting a library you already have # on disk. Drop files in ./import, or bind-mount an existing directory # under it as below, then trigger the scan: # # curl -X POST http://localhost:8080/api/import/trigger # # Read-only is sufficient — FC copies into /images during the scan. The # worker + scheduler services mount the same /import so the scan can run # on whichever lane picks it up. # # There is no UI button for this: the manual-scan surface was retired # 2026-07-02 once imports arrived via downloads + the extension, which is # true for an established install and not for a new one. Tracked in #3367. # - /srv/media/my-library:/import/my-library:ro depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } worker: image: git.fabledsword.com/bvandeusen/fabledcurator:latest command: ["worker"] # Drain in-flight import/thumbnail/download tasks before SIGKILL on deploy. stop_grace_period: 90s healthcheck: *celery_healthcheck deploy: *deploy_policy environment: <<: *app_env CELERY_QUEUES: default,import,thumbnail,download CELERY_CONCURRENCY: "2" # /downloads dropped — nothing in the app references it (operator-flagged # 2026-06-07: it wasn't mapped in prod and everything worked). volumes: - ./images:/images - ./import:/import depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } scheduler: image: git.fabledsword.com/bvandeusen/fabledcurator:latest command: ["scheduler"] # Quick maintenance/scan lane + beat — short tasks, modest drain window. stop_grace_period: 60s healthcheck: *celery_healthcheck deploy: *deploy_policy environment: <<: *app_env CELERY_QUEUES: maintenance,scan volumes: - ./images:/images - ./import:/import depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } # Dedicated lane for long one-shot maintenance (DB backups, library audits, # admin maintenance). Kept off the scheduler's quick `maintenance` lane so a # 30-min backup or a multi-chunk audit can never starve the 5-min recovery # sweeps / vacuum (operator-flagged 2026-06-07). One slot — these are heavy. maintenance-long: image: git.fabledsword.com/bvandeusen/fabledcurator:latest command: ["worker"] # Longest lane (DB backups, library audits, translation backfill) — give it # the most room to finish a chunk gracefully. Chunked + idempotent, so a job # that still outruns this resumes cleanly next run rather than corrupting. stop_grace_period: 180s healthcheck: *celery_healthcheck deploy: *deploy_policy environment: <<: *app_env CELERY_QUEUES: maintenance_long CELERY_CONCURRENCY: "1" # Only /images: backups write to /images/_backups, audits read /images, and # the admin tasks (re-extract/cascade-delete/normalize) operate on /images. volumes: - ./images:/images depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } ml-worker: image: git.fabledsword.com/bvandeusen/fabledcurator-ml:latest command: ["ml-worker"] # A single GPU inference pass can run tens of seconds — let it finish. stop_grace_period: 120s healthcheck: *celery_healthcheck deploy: *deploy_policy environment: <<: *app_env volumes: - ./images:/images:ro - ./models:/models depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } volumes: redis_data: postgres_data: