MicProtocol / apps /api /Dockerfile
Benaah's picture
Upload folder using huggingface_hub
6abfd9d verified
Raw History Blame Contribute Delete
6.9 kB
# ═══════════════════════════════════════════════════════════
# 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"]