Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Deliberately no `container_name:` anywhere.
#
# Fixed container names are global to the Docker daemon, so a hardcoded
# `foilstack-web` collides with any other foilstack stack on the same machine
# and refuses to start — or worse, attaches you to the wrong database. Letting
# compose namespace names by project directory is what makes it safe to run
# this alongside anything else.

services:
  postgres:
    image: pgvector/pgvector:pg17
    restart: unless-stopped
    # Docker's default is 64 MB. A *parallel* HNSW build maps the whole of
    # maintenance_work_mem into shared memory, so the rule is shm >= that
    # setting, not merely "more than the default" — the index build fails with
    # "could not resize shared memory segment" rather than falling back. The
    # build budget in the index migration is set under this number. tmpfs
    # allocates on demand, so this reserves nothing until a build runs.
    shm_size: 1gb
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-foilstack}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-foilstack}
      POSTGRES_DB: ${POSTGRES_DB:-foilstack}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      # Bound to loopback: this is for `psql` and `pg_dump` on the host, not
      # for anything off the machine.
      - "127.0.0.1:${POSTGRES_PORT:-5434}:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-foilstack} -d ${POSTGRES_DB:-foilstack}"]
      interval: 10s
      timeout: 5s
      retries: 10

  web:
    build:
      context: .
      dockerfile: Dockerfile.web
      args:
        # Set it when you build and the footer names the commit that is
        # running; leave it and the footer simply omits it. Compose cannot run
        # a command here, so this comes from the environment:
        #
        #   GIT_SHA="$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty)" \
        #     docker compose up -d --build
        #
        # The `-dirty` half is not decoration. A build from a working tree with
        # uncommitted changes, labelled with a clean commit, claims to be code
        # it does not contain — which is exactly the question this label exists
        # to answer, answered wrongly. It has already happened once: a deploy
        # built before its own commit reported the commit before the fix, while
        # running the fix.
        GIT_SHA: ${GIT_SHA:-}
    restart: unless-stopped
    environment:
      FOILSTACK_DATA_DIR: /data
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-foilstack}:${POSTGRES_PASSWORD:-foilstack}@postgres:5432/${POSTGRES_DB:-foilstack}
      EMBEDDER_URL: http://embedder:8100
      # Has to match what the `embedder` service loads and what built the
      # vectors already in the catalogue. Vectors from two encoders are not
      # comparable and nothing raises — the search simply returns the wrong
      # card. Passed here as well as to the embedder because this is the side
      # that writes and queries `card_embeddings.model`.
      EMBED_MODEL: ${EMBED_MODEL:-facebook/dinov3-vitl16-pretrain-lvd1689m}
      FOILSTACK_AUTO_ACCEPT: ${FOILSTACK_AUTO_ACCEPT:-0.94}
      FOILSTACK_AUTO_ACCEPT_MARGIN: ${FOILSTACK_AUTO_ACCEPT_MARGIN:-0.04}
      FOILSTACK_MAX_ARCHIVE_MB: ${FOILSTACK_MAX_ARCHIVE_MB:-512}
      FOILSTACK_MAX_IMAGES: ${FOILSTACK_MAX_IMAGES:-5000}
      # Off by default: one person running this for their own shop should not
      # have to invent a password to reach their own inventory. Turn it on for
      # a deployment other people can reach, and set a real secret key.
      FOILSTACK_MULTI_USER: ${FOILSTACK_MULTI_USER:-false}
      FOILSTACK_SECRET_KEY: ${FOILSTACK_SECRET_KEY:-dev-insecure-change-me}
      FOILSTACK_SUPPORT_URL: ${FOILSTACK_SUPPORT_URL:-https://buymeacoffee.com/foilstack}
      FOILSTACK_SITE_URL: ${FOILSTACK_SITE_URL:-}
      # The `prices` service's interval, read here only by `/healthz/prices`
      # to decide when a quiet sync has become a dead one.
      FOILSTACK_SYNC_CHECK_EVERY: ${FOILSTACK_SYNC_CHECK_EVERY:-21600}
      # Everything the application reads has to be named here. Compose passes
      # only what this block lists, so a setting added to config.py and to
      # .env but not to this list is one that silently does nothing — and the
      # ones below are the levers you reach for when a public deployment is
      # going wrong, which is the worst possible time to find that out.
      FOILSTACK_ALLOW_REGISTRATION: ${FOILSTACK_ALLOW_REGISTRATION:-true}
      FOILSTACK_INVITE_CODE: ${FOILSTACK_INVITE_CODE:-}
      FOILSTACK_MAX_ACCOUNT_MB: ${FOILSTACK_MAX_ACCOUNT_MB:-0}
      FOILSTACK_CONTACT_EMAIL: ${FOILSTACK_CONTACT_EMAIL:-}
      FOILSTACK_LOGIN_ATTEMPTS: ${FOILSTACK_LOGIN_ATTEMPTS:-10}
      FOILSTACK_LOGIN_WINDOW_S: ${FOILSTACK_LOGIN_WINDOW_S:-900}
      FOILSTACK_SIGNUPS_PER_WINDOW: ${FOILSTACK_SIGNUPS_PER_WINDOW:-20}
      # Read by uvicorn itself, not by config.py: the addresses whose
      # X-Forwarded-For and X-Forwarded-Proto are believed. Docker gives
      # containers addresses in 172.16.0.0/12, so a proxy on this compose
      # network is trusted, and so is one on the host arriving through the
      # published port's gateway; a visitor reaching the port directly is not.
      # It was `*`, under which uvicorn believes the left-most forwarded
      # address — the one the visitor wrote. See `ratelimit.client_ip`.
      FORWARDED_ALLOW_IPS: "${FOILSTACK_FORWARDED_ALLOW_IPS:-127.0.0.1,172.16.0.0/12}"
    volumes:
      # Your scans. A bind mount rather than a named volume so you can see
      # them, back them up, and delete them. The database is a separate volume;
      # the `backup` service is what protects that.
      - ${FOILSTACK_DATA_DIR:-./data}:/data
    # --proxy-headers so the app sees the scheme the *browser* used, not the
    # plain HTTP hop from a TLS-terminating proxy. Without it every session
    # cookie is issued without `Secure` behind HTTPS, and issued *with* it
    # would be worse: the browser would refuse to send it back over HTTP and
    # login would silently loop.
    #
    # Which proxies are believed is `FORWARDED_ALLOW_IPS`, above, and not a
    # flag here: a flag beats the environment, and this one used to say `*`.
    command: ["uvicorn", "foilstack.web.app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
    # Loopback by default, like Postgres above. Single-user mode has no login,
    # so a port published on every interface is the whole application handed
    # to anyone on the network — and Docker writes its own iptables rules for
    # published ports, so a host firewall like ufw does not stand in front of
    # it. A proxy on this compose network reaches `web:8000` and never uses
    # this port. Loopback also keeps docker-proxy traffic, which arrives from
    # the gateway inside FORWARDED_ALLOW_IPS, from writing its own
    # X-Forwarded-For.
    ports:
      - "${FOILSTACK_BIND:-127.0.0.1}:${FOILSTACK_PORT:-8090}:8000"
    depends_on:
      postgres:
        condition: service_healthy
      embedder:
        condition: service_started

  # Dumps the database on a schedule, as a service rather than a cron job on the
  # host. A backup that depends on someone remembering to install a crontab is a
  # backup that exists on the machine it was set up on and nowhere else — this
  # one starts with `docker compose up` wherever the stack runs.
  backup:
    image: pgvector/pgvector:pg17
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    # Write as the human who owns the backup directory. The image runs as root
    # otherwise, and the dumps land root-owned in a user directory — readable,
    # but not something you can tidy up or rsync out without sudo.
    user: "${FOILSTACK_BACKUP_UID:-1000}:${FOILSTACK_BACKUP_GID:-1000}"
    environment:
      # Filenames are timestamps in local time. Without this the container
      # stamps UTC, so a dump taken at 21:27 lands as `20260822-0127` and sorts
      # *before* files taken earlier the same evening. Rotation reads mtime and
      # is unaffected; a person reading the directory is not.
      TZ: ${TZ:-UTC}
      PGPASSWORD: ${POSTGRES_PASSWORD:-foilstack}
      PG_USER: ${POSTGRES_USER:-foilstack}
      PG_DB: ${POSTGRES_DB:-foilstack}
      BACKUP_INTERVAL: ${FOILSTACK_BACKUP_INTERVAL:-86400}
      BACKUP_CHECK_EVERY: ${FOILSTACK_BACKUP_CHECK_EVERY:-3600}
      # Count, not days: the disk cost stays bounded and predictable no matter
      # how often the interval fires.
      BACKUP_KEEP: ${FOILSTACK_BACKUP_KEEP:-14}
      # A floor that catches a truncated or empty dump, not a size target.
      # Deliberately small: a fresh install with no catalogue ingested yet dumps
      # to a few kilobytes, and rejecting that as "too small" would report a
      # failure on the one install where nothing is wrong.
      BACKUP_MIN_BYTES: ${FOILSTACK_BACKUP_MIN_BYTES:-4096}
    volumes:
      # A host bind mount, not a named volume: a backup that
      # `docker compose down -v` can destroy is not a backup.
      - ${FOILSTACK_BACKUP_DIR:-./backups}:/backups
      # Read-only, and only so the scans can be mirrored out. The dump protects
      # the database; without this the restore brings back an inventory whose
      # every image is a broken link, because the rows survived and the JPEGs
      # they point at did not.
      - ${FOILSTACK_DATA_DIR:-./data}:/data:ro
    entrypoint: ["sh", "-c"]
    command:
      - |
        echo "backup sidecar: dump when the newest is older than $${BACKUP_INTERVAL}s, keep $${BACKUP_KEEP}"
        while true; do
          newest=$$(ls -1t /backups/foilstack-20*.sql.gz 2>/dev/null | head -1)
          age=$${BACKUP_INTERVAL}
          if [ -n "$${newest}" ]; then
            age=$$(( $$(date +%s) - $$(stat -c %Y "$${newest}") ))
          fi
          if [ "$${age}" -lt "$${BACKUP_INTERVAL}" ]; then
            sleep "$${BACKUP_CHECK_EVERY}"
            continue
          fi

          ts=$$(date +%Y%m%d-%H%M%S)
          f=/backups/foilstack-$${ts}.sql.gz
          ok=0
          # Every failure mode is checked and named. A dump that cannot be read
          # is not a backup, and finding that out at restore time is finding it
          # out too late.
          if ! pg_isready -h postgres -U "$${PG_USER}" -d "$${PG_DB}" -q; then
            echo "backup SKIPPED at $${ts}: database not reachable"
          elif ! pg_dump -h postgres -U "$${PG_USER}" -d "$${PG_DB}" --clean --if-exists -Z 9 -f "$${f}"; then
            echo "backup FAILED at $${ts}: pg_dump error"; rm -f "$${f}"
          elif ! gzip -t "$${f}" 2>/dev/null; then
            echo "backup FAILED at $${ts}: corrupt gzip"; rm -f "$${f}"
          elif [ "$$(stat -c %s "$${f}")" -lt "$${BACKUP_MIN_BYTES}" ]; then
            echo "backup FAILED at $${ts}: $$(stat -c %s "$${f}") bytes is too small"; rm -f "$${f}"
          else
            ok=1
          fi

          if [ "$${ok}" = 1 ]; then
            echo "backed up foilstack-$${ts}.sql.gz ($$(stat -c %s "$${f}") bytes)"
            cp "$${f}" /backups/foilstack-latest.sql.gz
            rm -f /backups/BACKUP_FAILING

            # Scans are mirrored, not tarred. They are write-once — a scan is
            # never edited after import — so `cp -n` copies each file exactly
            # once and every run after the first is nearly free. A dated
            # tarball would instead store a second, third and fourth copy of
            # bytes that never changed, and on a deployment with real users
            # that is tens of gigabytes to protect against an edit that cannot
            # happen.
            #
            # Deletions deliberately do not propagate. Someone discarding a
            # scan should not also remove it from the backup; the cost is a
            # mirror that grows slowly against the alternative, which is a
            # mirror that faithfully reproduces an accident.
            if [ -d /data/scans ]; then
              mkdir -p /backups/scans
              cp -an /data/scans/. /backups/scans/ 2>/dev/null || true
              echo "mirrored scans ($$(du -sh /backups/scans 2>/dev/null | cut -f1))"
            fi
            # Only ever after a good dump, and never matching `-latest` — the
            # glob starts at the timestamp's century digits on purpose.
            ls -1t /backups/foilstack-20*.sql.gz 2>/dev/null \
              | tail -n +$$(( $${BACKUP_KEEP} + 1 )) | xargs -r rm -f
          else
            # The one file to monitor. Its presence means the last run produced
            # nothing usable; a good run removes it.
            echo "$${ts} no usable dump produced" >> /backups/BACKUP_FAILING
          fi
          sleep "$${BACKUP_CHECK_EVERY}"
        done

  # Copies the backup directory somewhere that is not this machine.
  #
  # Everything above protects against the failures that happen inside the box:
  # a dropped table, `docker compose down -v`, someone deleting the wrong
  # thing. None of it survives the disk dying, because the dumps and the scan
  # mirror live on the same disk as the database they are copies of. This is
  # the service that fixes that, and it is the only one here that needs an
  # account somewhere else.
  #
  # Off unless asked for, because it cannot work without credentials and a
  # service that crash-loops on a fresh clone is worse than one that is absent:
  #
  #   rclone config                       # once, on the host — creates rclone.conf
  #   FOILSTACK_OFFSITE_REMOTE=r2:foilstack-backups \
  #     docker compose --profile offsite up -d
  #
  # Any rclone remote works. Object storage is the cheap answer at this size —
  # a few hundred megabytes of scans costs cents a month, and providers whose
  # egress is free make the restore free too.
  offsite:
    profiles: ["offsite"]
    image: rclone/rclone:latest
    restart: unless-stopped
    user: "${FOILSTACK_BACKUP_UID:-1000}:${FOILSTACK_BACKUP_GID:-1000}"
    environment:
      TZ: ${TZ:-UTC}
      # A plain default rather than `${VAR:?...}`. Compose interpolates every
      # service in the file whether or not its profile is active, so the
      # required-variable form fails `docker compose up` for someone who has
      # never heard of this service. The check belongs in the script below,
      # which only runs when the profile does.
      OFFSITE_REMOTE: ${FOILSTACK_OFFSITE_REMOTE:-}
      OFFSITE_EVERY: ${FOILSTACK_OFFSITE_EVERY:-86400}
    volumes:
      # The same directory twice, deliberately. `/backups` is the copy source
      # and stays read-only, because the one job of this container is to not be
      # able to damage the backups it is replicating. `/state` is the same
      # files writable, and exists only so the failure marker can be written —
      # it went to `/backups` once, which is read-only, so a failed run could
      # not raise the alarm and a good one could not clear a stale marker. The
      # monitoring was a no-op from the first commit.
      - ${FOILSTACK_BACKUP_DIR:-./backups}:/backups:ro
      - ${FOILSTACK_BACKUP_DIR:-./backups}:/state
      - ${FOILSTACK_RCLONE_CONFIG:-~/.config/rclone}:/config/rclone:ro
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        if [ -z "$${OFFSITE_REMOTE}" ]; then
          echo "offsite: set FOILSTACK_OFFSITE_REMOTE (e.g. r2:foilstack-backups) — nothing to copy to"
          exit 1
        fi
        echo "offsite: copying /backups to $${OFFSITE_REMOTE} every $${OFFSITE_EVERY}s"
        while true; do
          # `copy`, never `sync`. sync makes the remote match the local
          # directory, which means a local disk that has just wiped itself
          # would replicate that faithfully to the one copy that survived it.
          # The markers are excluded from the copy. `copy` never deletes, so
          # a marker replicated during an outage would sit on the remote for
          # good, outliving the failure it described by however long the
          # bucket lasts.
          if rclone copy /backups "$${OFFSITE_REMOTE}" \
               --config /config/rclone/rclone.conf \
               --exclude "OFFSITE_FAILING" --exclude "BACKUP_FAILING" \
               --stats-one-line -v; then
            echo "offsite ok at $$(date -Iseconds)"
            rm -f /state/OFFSITE_FAILING
          else
            echo "offsite FAILED at $$(date -Iseconds)"
            echo "$$(date -Iseconds) rclone copy failed" >> /state/OFFSITE_FAILING
          fi
          sleep "$${OFFSITE_EVERY}"
        done

  # Refreshes card prices and records the ones that moved.
  #
  # TCGCSV rebuilds exactly once a day and publishes the build timestamp, so
  # this checks that first and exits after a single request when nothing has
  # changed. The interval below is how often it *looks*, not how often it
  # pulls — see their usage guidelines at https://tcgcsv.com/docs.
  #
  # This is the one job here whose output cannot be recreated. Upstream mirrors
  # the current day only, so a day this does not run is a day of price history
  # gone for good.
  prices:
    build:
      context: .
      dockerfile: Dockerfile.web
      args:
        GIT_SHA: ${GIT_SHA:-}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      FOILSTACK_DATA_DIR: /data
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-foilstack}:${POSTGRES_PASSWORD:-foilstack}@postgres:5432/${POSTGRES_DB:-foilstack}
      TZ: ${TZ:-UTC}
      # "all" means every game in the catalogue, resolved at run time. A
      # hardcoded list here is a list someone has to remember to update after
      # each ingest, and forgetting leaves that game priced at whatever it was
      # first ingested with, permanently and silently.
      SYNC_GAMES: ${FOILSTACK_SYNC_GAMES:-all}
      SYNC_CHECK_EVERY: ${FOILSTACK_SYNC_CHECK_EVERY:-21600}
    volumes:
      - ${FOILSTACK_DATA_DIR:-./data}:/data
    entrypoint: ["sh", "-c"]
    command:
      - |
        echo "price sync: checking every $${SYNC_CHECK_EVERY}s for $${SYNC_GAMES}"
        while true; do
          failed=""
          for game in $${SYNC_GAMES}; do
            python -m foilstack.cli sync-prices --game "$${game}" || {
              echo "price sync FAILED for $${game}"
              failed="$${failed} $${game}"
            }
          done
          # The file to monitor, as BACKUP_FAILING is for backups: present
          # means the last pass failed, and a clean pass removes it. A failure
          # used to be one line in this log and nothing else, for the one job
          # whose missed days cannot be recovered. It cannot notice this loop
          # having stopped altogether — `/healthz/prices` is what does that.
          if [ -n "$${failed}" ]; then
            echo "$$(date -Iseconds) sync failed for$${failed}" >> /data/PRICES_FAILING
          else
            rm -f /data/PRICES_FAILING
          fi
          sleep "$${SYNC_CHECK_EVERY}"
        done

  embedder:
    build:
      context: .
      dockerfile: Dockerfile.embedder
    restart: unless-stopped
    environment:
      EMBED_MODEL: ${EMBED_MODEL:-facebook/dinov3-vitl16-pretrain-lvd1689m}
      # The default model is a *gated* repo: you must accept its terms on
      # Hugging Face and supply a token the first time, or the download fails
      # with a 401 that reads like a network error. Once the weights are in
      # your cache the token is no longer needed.
      HF_TOKEN: ${HF_TOKEN:-}
      HF_HOME: /tmp/hf
      HF_HUB_CACHE: /models/hub
    volumes:
      # Model weights come from the host cache, read-only: this container
      # cannot corrupt what other tools on your machine are using.
      - ${HF_CACHE_DIR:-~/.cache/huggingface}/hub:/models/hub:ro
    tmpfs:
      - /tmp
    # No published port. The encoder is reachable from the web service and
    # nothing else, because it is the one process here holding a GPU and a
    # model, and it authenticates nobody.

volumes:
  postgres-data: