# ============================================================================ # MULTI-STAGE DOCKERFILE: Development + Production # ============================================================================ # What is multi-stage build? # - ONE Dockerfile that can create DIFFERENT images for dev vs prod # - Uses "FROM ... AS stage-name" to define stages # - Build specific stage with: `docker build --target stage-name -t image:tag .` # # Benefits: # - Single source of truth (one Dockerfile, not two separate files) # - Share common setup (base stage) between dev and prod # - Actually different images (not just different docker-compose configs) # # Reference: docker_images_vs_services.md - Approach B (Multi-Stage Dockerfile) # ============================================================================ # ============================================================================ # STAGE 1: BASE (Shared by both dev and prod) # ============================================================================ # Why separate base stage? # - Avoid duplicating common setup (Python install, uv setup, dependencies) # - Changes here affect both dev and prod # - Faster builds (shared layers cached once) # ============================================================================ FROM cgr.dev/chainguard/python:latest-dev AS base # Chainguard sets ENTRYPOINT ["python"] — clear it so exec-form CMD works directly. ENTRYPOINT [] # Set working directory to project root # NOTE: Changed from /app to /project to support absolute imports (app.*) # This allows imports like "from app.utils.config import get_logger" # to work consistently in both IDE and Docker environments WORKDIR /project # Install uv (modern Python package manager) # COPY --from=IMAGE means: copy FROM another image (not from host) # Source: /uv (binary from astral-sh's image) # Destination: /usr/local/bin/uv (system PATH in our image) # Why uv? Faster than pip, uses lockfile (uv.lock) for reproducible builds COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv # Copy dependency files first (for layer caching optimization) # Why before code? Dependencies change less frequently than code # If only code changes, Docker reuses this cached layer (faster rebuilds) # # What are these files? # - pyproject.toml = Python project metadata + dependency list (like package.json) # - uv.lock = Exact pinned versions of all dependencies (like package-lock.json) COPY pyproject.toml uv.lock ./ # Chainguard runs as nonroot (uid 65532) — cannot write to system Python paths. # Install into /project/.venv (owned by nonroot) and put it on PATH. RUN uv venv /project/.venv && \ uv pip install --python /project/.venv/bin/python -r pyproject.toml ENV PATH="/project/.venv/bin:$PATH" # Avoid HuggingFace Hub Xet transfer stalls during startup embedding warmup. ENV HF_HUB_DISABLE_XET=1 # Copy application code # NOTE: Changed to copy entire project instead of just app/ directory # This maintains the project structure: /project/app/, /project/pyproject.toml, etc. # Allows absolute imports like "from app.utils.config import get_logger" to work # # Source: ./ (entire project root on host) # Destination: . (current WORKDIR, which is /project inside container) # # Result: Files land at /project/app/main.py, /project/app/models/, etc. # Note: chroma_data/ is excluded via .dockerignore — created below with correct ownership. COPY . . # Pre-create ChromaDB data directory as nonroot so the named volume is initialised # with correct ownership. Without this, Docker creates the volume mount point as root, # and the nonroot process cannot write the SQLite file — causing 500 errors on all KB writes. RUN mkdir -p /project/chroma_data # ============================================================================ # Why we DON'T set CMD or ENV here? # - Base stage is INCOMPLETE - not meant to run directly # - Each final stage (development/production) will set their own CMD and ENV # - This keeps base stage flexible for different use cases # ============================================================================ # ============================================================================ # STAGE 2: DEVELOPMENT # ============================================================================ # When to use this stage? # - Local development on host machine # - Need hot reload (code changes auto-restart server) # - Need debug mode (verbose logging, error traces) # - Don't care about image size or optimization # # How to build: docker build --target development -t smart-chatbot:dev . # ============================================================================ FROM base AS development # Dev-group dependencies (QA tooling like the anthropic SDK) — installed only # in the development image so the production image stays lean RUN uv pip install --python /project/.venv/bin/python --group dev # Set environment variables for development # DEBUG=true enables: # - Verbose logging # - Detailed error traces in API responses # - Development-only features ENV DEBUG=true # Development-specific command # Flags explained: # uvicorn = ASGI server for FastAPI (available via /project/.venv on PATH) # app.main:app = Import path (app/main.py file, app object) - using absolute import # --reload = Watch for code changes, auto-restart server (hot reload) # --host 0.0.0.0 = Listen on ALL network interfaces (required for Docker) # --port 8000 = Port number (matches EXPOSE, nginx proxy_pass target) # # NOTE: Changed from "main:app" to "app.main:app" to match absolute import structure # This works because WORKDIR is /project (contains app/ directory) # # Why different from production CMD? # - --reload is EXPENSIVE (file watching, auto-restart) - only for development # - Production uses gunicorn (multi-worker, more robust) # # Note: No "uv run" needed — venv bin is on PATH via ENV PATH above CMD ["uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"] # Port documentation (doesn't actually open ports) # How this works with docker-compose in DEVELOPMENT: # # smart-chatbot: # image: smart-chatbot:dev # ports: # - "8000:8000" ← EXPOSED to host # volumes: # - ./app:/app ← Bind mount (code changes reflect immediately) # # Access options: # - Direct: http://localhost:8000 # - Via nginx proxy: http://smart-chatbot.local (requires /etc/hosts + nginx config) EXPOSE 8000 # ============================================================================ # STAGE 3: PRODUCTION # ============================================================================ # When to use this stage? # - Deploying to server (Railway, AWS, DigitalOcean, etc.) # - Need performance (multi-worker, optimized) # - Need security (debug off, minimal attack surface) # - Care about image size and resource usage # # How to build: docker build --target production -t smart-chatbot:prod . # ============================================================================ FROM base AS production # Install curl for Docker health checks (Chainguard runs as nonroot — switch to root for apk) USER root RUN apk add --no-cache curl USER nonroot # Set environment variables for production # DEBUG=false disables: # - Verbose logging (only errors/warnings logged) # - Detailed error traces (attackers can't see internal structure) # - Development-only features ENV DEBUG=false # Production-specific command # Why gunicorn instead of uvicorn? # - Gunicorn = Production-grade WSGI server with process management # - Can run MULTIPLE workers (parallel request handling) # - Better stability, graceful shutdowns, worker health monitoring # # Flags explained: # gunicorn = WSGI server (wraps uvicorn workers, available via /project/.venv on PATH) # app.main:app = Import path (app/main.py file, app object) - using absolute import # -k uvicorn.workers.UvicornWorker = Use Uvicorn worker class (for ASGI support) # -w 4 = 4 worker processes (adjust based on CPU cores) # -b 0.0.0.0:8000 = Bind to all interfaces, port 8000 # # NOTE: Changed from "main:app" to "app.main:app" to match absolute import structure # This works because WORKDIR is /project (contains app/ directory) # # Worker count rule of thumb: (2 × CPU_cores) + 1 # Example: 2-core server → 5 workers, 4-core server → 9 workers # # Note: No "uv run" needed — venv bin is on PATH via ENV PATH above CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "4", "-b", "0.0.0.0:8000"] # Port documentation (doesn't actually open ports) # How this works with docker-compose in PRODUCTION: # # smart-chatbot: # image: smart-chatbot:prod # expose: # - 8000 ← INTERNAL ONLY (only other containers can access) # # NO "ports:" section = NOT accessible from host/internet # # nginx: # ports: # - "80:80" ← Only nginx exposed to internet # # nginx.conf: proxy_pass http://smart-chatbot:8000 # # Request flow: Internet → nginx:80 → smart-chatbot:8000 (internal) EXPOSE 8000 # ============================================================================ # STAGE 4: HUGGINGFACE SPACE # ============================================================================ FROM base AS huggingface ENV DEBUG=false ENV ENVIRONMENT=production # HuggingFace Spaces default port is 7860 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"] EXPOSE 7860 # ============================================================================ # HOW TO USE THIS DOCKERFILE # ============================================================================ # # BUILD DEVELOPMENT IMAGE: # docker build --target development -t smart-chatbot:dev . # # BUILD PRODUCTION IMAGE: # docker build --target production -t smart-chatbot:prod . # # BUILD BOTH (for testing): # docker build --target development -t smart-chatbot:dev . # docker build --target production -t smart-chatbot:prod . # # VERIFY IMAGES EXIST: # docker images | grep smart-chatbot # # RUN DEVELOPMENT CONTAINER (standalone, no compose): # docker run -p 8000:8000 -v $(pwd)/app:/project/app smart-chatbot:dev # # RUN PRODUCTION CONTAINER (standalone, no compose): # docker run -p 8000:8000 smart-chatbot:prod # # ============================================================================ # WHAT'S THE DIFFERENCE BETWEEN IMAGES? # ============================================================================ # # smart-chatbot:dev (DEVELOPMENT) # ✅ Has: --reload flag (hot reload for code changes) # ✅ Has: DEBUG=true (verbose logging, error traces) # ✅ Has: Single-worker uvicorn (simpler debugging) # ❌ Slower, higher memory usage, less secure # # smart-chatbot:prod (PRODUCTION) # ✅ Has: Multi-worker gunicorn (parallel requests, better performance) # ✅ Has: DEBUG=false (minimal logging, no error traces) # ✅ Has: Optimized for speed and security # ❌ No hot reload (must rebuild image for code changes) # # ============================================================================ # WHAT ABOUT DOCKER-COMPOSE? # ============================================================================ # You can use these images in docker-compose.yml: # # DEVELOPMENT (docker-compose.dev.yml): # services: # smart-chatbot: # image: smart-chatbot:dev ← Uses development stage # ports: # - "8000:8000" ← Exposed to host (localhost:8000) # volumes: # - ./app:/app ← Bind mount (hot reload works!) # # PRODUCTION (docker-compose.prod.yml): # services: # smart-chatbot: # image: smart-chatbot:prod ← Uses production stage # expose: # - 8000 ← Internal only (nginx access only) # # NO volumes (code baked into image) # # nginx: # image: nginx:alpine # ports: # - "80:80" ← Only nginx exposed to internet # # ============================================================================