docker-compose.yml
Rama main · réplica de lectura del repositorio original.
# 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: