# ═══════════════════════════════════════════════════════════ # GuestFlow API - Production Dockerfile # Multi-stage build for optimized production image # ═══════════════════════════════════════════════════════════ # ─── Stage 1: Pruner ─────────────────────────────────────── FROM node:20-slim AS pruner RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev sqlite3 libsqlite3-dev g++ make python3 && rm -rf /var/lib/apt/lists/* WORKDIR /app RUN npm install -g turbo@2.8.12 COPY . . RUN turbo prune --scope=@micprotocol/api --docker # ─── Stage 2: Dependencies ───────────────────────────────── FROM node:20-slim AS deps RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev sqlite3 libsqlite3-dev g++ make python3 openssl && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy pruned package.json and lockfile COPY --from=pruner /app/out/json/ . # Install pnpm RUN npm install -g pnpm@10.30.3 # Install dependencies RUN pnpm install --frozen-lockfile # ─── Stage 3: Builder ────────────────────────────────────── FROM node:20-slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev sqlite3 libsqlite3-dev g++ make python3 openssl && rm -rf /var/lib/apt/lists/* WORKDIR /app # Install pnpm RUN npm install -g pnpm@10.30.3 # Copy dependencies from deps stage (includes all node_modules, lockfile, and package.json files) COPY --from=deps /app /app # Copy pruned source files on top of it COPY --from=pruner /app/out/full/ . # Generate Prisma client RUN cd apps/api && npx prisma generate # Build application RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build --filter @micprotocol/api # Prune devDependencies to keep only production dependencies RUN CI=true pnpm install --prod --frozen-lockfile # ─── Stage 4: Production ───────────────────────────────── FROM node:20-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=4000 ENV MEILISEARCH_URL=http://localhost:7700 ENV MEILISEARCH_API_KEY=masterKey RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev openssl libgcc1 tini ffmpeg sqlite3 libsqlite3-dev netcat-openbsd python3 && rm -rf /var/lib/apt/lists/* # Copy Meilisearch binary from the official image COPY --from=getmeili/meilisearch:v1.9.0 /bin/meilisearch /usr/local/bin/meilisearch # Create non-root user RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nestjs # Copy entire pruned application and dependencies COPY --from=builder --chown=nestjs:nodejs /app /app # ─── Python clip-scoring engine ────────────────────────── # The BullMQ clip-scoring processor spawns # `python3 services/clip-scorer/pipeline.py` via execFile # (apps/api/src/queue/clip-scoring.processor.ts), so this image must # ship python3, the ML stack, and the pipeline source. torch is pinned # to the CPU wheel index: CUDA is unnecessary for the 224px ViT-B/32 # inference and would add ~2GB of NVIDIA runtime deps. # The venv is pinned to the system python: without it uv probes the # newest CPython it can download (3.15 here), for which torchvision has # no wheels — the clip-scoring stack then can't resolve. torch AND # torchvision must land in one resolution pass from the CPU index: # open-clip-torch declares both as deps, and a PyPI-resolved torchvision # breaks open_clip import with "operator torchvision::nms does not # exist" (same failure class as the standalone clip-scorer image). # Build with the monorepo root as context (docker-compose does exactly # that for the `api` and `worker` services) so services/clip-scorer is # in scope — turbo prune does not carry it (not a pnpm workspace). COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv COPY --chown=nestjs:nodejs services/clip-scorer/ /app/services/clip-scorer/ ENV PATH="/opt/venv/bin:$PATH" \ HF_HOME=/app/.hf-cache RUN uv venv --python /usr/bin/python3 /opt/venv \ && VIRTUAL_ENV=/opt/venv uv pip install --index-url https://download.pytorch.org/whl/cpu "torch>=2.0.0" "torchvision>=0.15.0" \ && VIRTUAL_ENV=/opt/venv uv pip install "open-clip-torch>=2.20.0" "transformers>=4.30.0" "Pillow>=10.0.0" "soundfile>=0.12.0" "pyloudnorm>=0.1.0" "numpy>=1.24.0" # Pre-baked CLIP weights come from the standalone clip-scorer image # (its build already downloaded the model) so this build does NOT need # Hub access. Placed after the venv layer so upgrading torch does not # force a weight re-copy (and vice versa). COPY --from=localhost/micprotocol-clip-scorer:audit-verify \ /root/.cache/huggingface/hub/ \ /app/.hf-cache/hub/ # Offline verification: fail the build if the baked cache ever stops # matching what open_clip 3.x expects to find. BART (transcript NLI) is # intentionally not baked — it is a ~1.6GB download and the pipeline # falls back to the heuristic scorer when it cannot download at runtime. RUN HF_HUB_OFFLINE=1 python3 -c "import open_clip; open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k')" # Create data directory for Meilisearch and set ownership RUN mkdir -p /app/meili_data && chown -R nestjs:nodejs /app # Switch to non-root user USER nestjs # Create startup script RUN echo '#!/bin/sh' > /app/start.sh && \ echo 'MEILI_HOST=$(echo ${MEILISEARCH_HOST:-localhost} | sed "s|http://||;s|https://||")' >> /app/start.sh && \ echo 'MEILI_PORT=$(echo $MEILI_HOST | cut -d: -f2)' >> /app/start.sh && \ echo '[ -z "$MEILI_PORT" ] && MEILI_PORT=7700' >> /app/start.sh && \ echo 'MEILI_HOST=$(echo $MEILI_HOST | cut -d: -f1)' >> /app/start.sh && \ echo 'echo "Waiting for Meilisearch at $MEILI_HOST:$MEILI_PORT..."' >> /app/start.sh && \ echo 'while ! nc -z $MEILI_HOST $MEILI_PORT; do' >> /app/start.sh && \ echo ' sleep 0.5' >> /app/start.sh && \ echo 'done' >> /app/start.sh && \ echo 'echo "Meilisearch is ready!"' >> /app/start.sh && \ echo 'node /app/apps/api/dist/main.js' >> /app/start.sh && \ chmod +x /app/start.sh # Expose ports EXPOSE 4000 EXPOSE 7700 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "require('http').get('http://localhost:4000/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))" # Start application ENTRYPOINT ["tini", "--"] CMD ["/app/start.sh"]