Dokploy routes the domain to port 3000, but the container only bound 8000, so the proxy had nothing to talk to and the domain returned 502 with a perfectly healthy process behind it. The frontend image already solved this by answering on both 80 and 3000 (`listen 80; listen 3000;` in nginx.conf). Do the same here rather than swap one guess for another: 3000 is what the platform routes to, and 8000 is what the README, the vite dev proxy and docker-compose all target, so binding both means the container works whichever one it is pointed at. uvicorn's CLI takes a single --port, but Server.run() accepts pre-bound sockets, so serve.py binds each port and hands the list to one uvicorn - no extra worker or second process to supervise. PORT still pins a single port for anyone who wants one; PORTS changes the pair. A port that cannot be bound is logged and skipped rather than being fatal, since losing one of the two should not take down a service the platform only routes to on the other. It exits non-zero only when nothing is listening at all, so a genuinely dead container is still reported as failed. The healthcheck moves into the same file and passes if either port answers, which keeps it from drifting out of sync with what is actually bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
4.5 KiB
Docker
98 lines
4.5 KiB
Docker
# Multi-stage, mirroring catalogue_frontend/Dockerfile: a build stage that
|
|
# resolves dependencies, then a clean runtime stage that copies in only the
|
|
# result. There it is `npm ci` -> dist/; here it is pip -> a virtualenv.
|
|
|
|
# ---- Build stage ----
|
|
FROM python:3.11-slim AS build
|
|
WORKDIR /app
|
|
|
|
# Dependencies land in a self-contained venv so the runtime stage can take that
|
|
# one directory and leave pip, its HTTP cache and the downloaded wheels behind.
|
|
#
|
|
# requirements.txt is copied on its own, ahead of the source, for the same
|
|
# reason the frontend stage copies package.json before the rest of the app:
|
|
# this layer is cached on the file's checksum, so editing a router does not
|
|
# reinstall torch.
|
|
#
|
|
# psycopg[binary] avoids needing libpq-dev; sentence-transformers/scikit-learn/
|
|
# scipy all ship prebuilt wheels for this image, so no compiler is needed and
|
|
# this stage installs no build toolchain. Playwright's Python package installs,
|
|
# but its browser binary is NOT installed here - it's only a last-resort
|
|
# image-search fallback (see requirements.txt); run `playwright install
|
|
# chromium` in the container if you need that specific fallback tier.
|
|
COPY requirements.txt .
|
|
RUN python -m venv /opt/venv \
|
|
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
|
|
|
|
|
|
# ---- Runtime stage ----
|
|
FROM python:3.11-slim AS runtime
|
|
WORKDIR /app
|
|
|
|
# PATH: putting the venv first is what makes a bare `python`/`uvicorn` resolve
|
|
# to it - there is no "activate" step in a container.
|
|
# PYTHONUNBUFFERED: without it Dokploy's log view stays empty until a buffer
|
|
# happens to fill, so startup errors surface minutes after the container died.
|
|
# PYTHONDONTWRITEBYTECODE: no .pyc to write into a read-only-ish image layer.
|
|
ENV PATH="/opt/venv/bin:$PATH"
|
|
ENV PYTHONUNBUFFERED=1
|
|
ENV PYTHONDONTWRITEBYTECODE=1
|
|
|
|
COPY --from=build /opt/venv /opt/venv
|
|
|
|
COPY app ./app
|
|
COPY cli ./cli
|
|
COPY scripts ./scripts
|
|
COPY data ./data
|
|
COPY serve.py .
|
|
|
|
# Pristine copies of everything the app also WRITES to, kept at a path that is
|
|
# never mounted over.
|
|
#
|
|
# /app/data/seed_catalogs and /app/app/intelligence/artifacts both need volumes
|
|
# (products added through the UI are appended to the first, retrained models
|
|
# are written to the second - otherwise a redeploy throws both away). But
|
|
# mounting a volume there hides the copies shipped in this image: a *named*
|
|
# volume is seeded from the image on first use, a *bind* mount is not, and
|
|
# Dokploy offers both. A bind mount would leave the API with zero seed catalogs
|
|
# and zero trained models, with nothing in the logs saying why.
|
|
#
|
|
# So keep a second copy here. On startup restore_bundled_assets()
|
|
# (app/infrastructure/persistence.py) copies in whatever the mounted directory
|
|
# is missing, and never overwrites what is already there.
|
|
RUN mkdir -p /app/.bundled \
|
|
&& cp -a /app/data/seed_catalogs /app/.bundled/seed_catalogs \
|
|
&& cp -a /app/app/intelligence/artifacts /app/.bundled/artifacts
|
|
|
|
# Declared so `docker run` without an explicit -v still gets an anonymous
|
|
# volume rather than writing into the container layer. Dokploy (and the compose
|
|
# file) name them properly; this is the floor, not the recommended setup.
|
|
VOLUME ["/app/data", "/app/app/intelligence/artifacts"]
|
|
|
|
# Answers on BOTH ports, the same way the frontend image does (nginx.conf has
|
|
# `listen 80; listen 3000;`). 3000 is what Dokploy routes a domain to; 8000 is
|
|
# what this project's README, the vite dev proxy and docker-compose all use.
|
|
# Serving both means the container works whichever one the platform is pointed
|
|
# at, instead of returning 502 from a perfectly healthy process.
|
|
#
|
|
# serve.py binds both sockets and hands them to one uvicorn - see the note
|
|
# there. To pin a single port, set PORT (PORT=8080 serves only 8080); to change
|
|
# the pair, set PORTS.
|
|
ENV PORTS=3000,8000
|
|
EXPOSE 3000 8000
|
|
|
|
# Liveness only, and passes if EITHER port answers. /api/health always returns
|
|
# 200 - it reports Postgres and Ollama in the body as "degraded" rather than
|
|
# failing - which is deliberate: a check that went red whenever Postgres blinked
|
|
# would have Dokploy restart a perfectly healthy API in a loop.
|
|
#
|
|
# The 10s timeout is not padding: the handler probes Ollama over HTTP with a 3s
|
|
# timeout of its own, so an unreachable Ollama makes every check take ~3s.
|
|
# start-period covers first boot, where the venv is still cold.
|
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
CMD ["python", "serve.py", "--healthcheck"]
|
|
|
|
# Exec form: python is PID 1, so Docker's SIGTERM reaches it directly and a
|
|
# redeploy shuts down cleanly instead of waiting out the 10s kill timeout.
|
|
CMD ["python", "serve.py"]
|