# syntax=docker/dockerfile:1.6 # ── Stage 1: builder ───────────────────────────────────────────────────────── # Installs all Python dependencies (including native extensions) into a venv # that is copied to the lean runtime image, keeping the final layer small. FROM python:3.11-slim AS builder # Build dependencies required by native extensions (pymupdf, faiss-cpu) RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ libgomp1 \ && rm -rf /var/lib/apt/lists/* # Quiet pip and prevent root-action hand-wringing in build logs. ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_ROOT_USER_ACTION=ignore \ PIP_NO_CACHE_DIR=1 WORKDIR /build # Hatchling resolves `readme = "README.md"` and `packages = ["app"]` from # pyproject.toml during metadata generation, so all three must be present # *before* `pip install` runs. The build-cache key for this RUN step # therefore changes whenever pyproject.toml, README.md, or app/ source # changes — accepted, to keep the install step correct. COPY pyproject.toml README.md ./ COPY app/ ./app/ # Production image installs the runtime project only — NO `[dev]` extras. # Dev tools (pytest, mypy, black, ruff, httpx) are ~150 MB of code paths # that production never executes; shipping them widens the attack surface # and slows cold pulls on free hosting tiers. # # Non-editable install builds a wheel and drops it into site-packages, so the # `.pth` editable link doesn't dangle when the venv is copied into stage 2. RUN python -m venv /opt/venv && \ /opt/venv/bin/pip install --upgrade pip && \ /opt/venv/bin/pip install ".[temporal]" # ── Stage 2: production runtime ─────────────────────────────────────────────── FROM python:3.11-slim AS production RUN apt-get update && apt-get install -y --no-install-recommends \ libgomp1 \ curl \ && rm -rf /var/lib/apt/lists/* # Hugging Face Spaces requires the container to run as a user with UID 1000. # Local docker / docker-compose works fine with this UID too, so we standardise # on it here. Drop `-r` (system flag) — UID 1000 is a regular user, not a # system account, and combining `-r` with `-u 1000` is contradictory. RUN groupadd -g 1000 appgroup && \ useradd -u 1000 -g appgroup -d /home/user -m -s /sbin/nologin appuser WORKDIR /app COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ HOME=/home/user \ PIP_DISABLE_PIP_VERSION_CHECK=1 COPY --chown=appuser:appgroup . . # Pre-create EVERY writable runtime path the application uses by default, # including the legacy paths that docker-compose mounts named volumes onto # (/mnt/data/uploads, /data, /data/faiss_index). When Docker mounts a named # volume to a path that did NOT exist in the image, the resulting volume is # owned by root — and the appuser (UID 1000) cannot write to it, so the very # first POST /upload fails with PermissionError. Pre-creating the dirs as # appuser-owned makes Docker initialise each new volume with the right # ownership, fixing the regression. RUN mkdir -p /home/user/.report_genius/uploads \ /home/user/.report_genius/faiss_index \ /home/user/.report_genius/cache \ /mnt/data/uploads \ /data/faiss_index \ /tmp/section_cache && \ chown -R appuser:appgroup /home/user /mnt/data /data /tmp/section_cache USER appuser # Defaults tuned for a public free-tier deployment. Anything here is overridable # at runtime via the platform's secret/env-var manager. # # - DEV_MODE=false: production logging (no SQLAlchemy echo) and triggers the # strict-secret startup guard in app/main.py. # - KNOWLEDGE_BASE_ENABLED=false: source PDFs (Behrang RICS Documents, # RAW Context) are gitignored; the seeder would only log "no files found". # Skipping it removes a noisy startup task and the app surfaces a WARNING # to make the trade-off visible in deploy logs. # - DATABASE/index/upload paths land under $HOME so the container works out # of the box on Hugging Face Spaces and on local docker without any extra # mount config. docker-compose overrides them via env vars. # - PORT=7860 matches the Hugging Face Spaces convention; docker-compose # pins it to 8000 to preserve existing local URLs. # - UVICORN_WORKERS=1 is correct for the 1-vCPU HF Spaces free tier; bigger # hosts can override at runtime to (2 * cores) + 1. ENV PORT=7860 \ UVICORN_WORKERS=1 \ DEV_MODE=false \ KNOWLEDGE_BASE_ENABLED=false \ DATABASE_URL=sqlite+aiosqlite:////home/user/.report_genius/dev.db \ FAISS_INDEX_PATH=/home/user/.report_genius/faiss_index \ UPLOAD_DIR=/home/user/.report_genius/uploads \ CACHE_DIR=/home/user/.report_genius/cache # Documenting both ports — 7860 (HF Spaces default) and 8000 (docker-compose # override). EXPOSE is informational only; it doesn't bind. EXPOSE 7860 8000 # Image-level healthcheck so any orchestrator (HF Spaces, Kubernetes, docker # Swarm, plain `docker run`) sees readiness without needing a separate # compose file. start-period=45s gives the lifespan hook time to pre-warm the # embedding model and FAISS store on cold boot. HEALTHCHECK --interval=30s --timeout=10s --start-period=45s --retries=3 \ CMD curl -fsS "http://127.0.0.1:${PORT:-7860}/health" || exit 1 # JSON exec form with an inner `exec` so uvicorn replaces /bin/sh as PID 1. # Without this, SIGTERM (sent by Docker / HF Spaces on every redeploy) goes # to /bin/sh, which doesn't forward it — uvicorn never gets the chance to # drain in-flight requests and gets SIGKILL after the 10s grace window, # corrupting FAISS writes and dropping open generations. CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} --workers ${UVICORN_WORKERS:-1} --loop uvloop --access-log"]