# 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 # 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"] # The port uvicorn binds. Overridable because Dokploy assigns the container # port per service - the frontend image answers on both 80 and 3000 for the # same reason. A single process cannot listen twice, so this is the knob: # PORT=3000 in the service's environment, if you standardise on 3000. ENV PORT=8000 EXPOSE 8000 # Liveness only. /api/health always answers 200 - it reports Postgres and # Ollama in the body as "degraded" rather than failing - which is deliberate # here: a healthcheck 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 -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8000')+'/api/health',timeout=8)" || exit 1 # `exec` matters: without it the shell stays PID 1 and Docker's SIGTERM never # reaches uvicorn, so every deploy waits out the 10s kill timeout instead of # shutting down cleanly. sh is only here to expand $PORT. # # --proxy-headers/--forwarded-allow-ips: this container is never reached # directly - nginx (and, in prod, Dokploy's Traefik) sit in front of it. # Without these, uvicorn ignores X-Forwarded-Proto and reports every request as # plain http, so any redirect or generated absolute URL would downgrade an # https request. CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} --proxy-headers --forwarded-allow-ips '*'"]