# Single-image deployment for Hugging Face Spaces (free CPU tier). # # Why this exists next to docker-compose.yml: a Space is exactly ONE # container, so the compose topology (nginx + backend) cannot run there. # Instead FastAPI serves the built SPA itself via StaticFiles (see the # STATIC_DIR mount in backend/app/main.py). Same app, three serving # topologies — Vite proxy in dev, nginx in compose, FastAPI static here — # and the frontend never changes because it only calls relative /api paths. # ---- Stage 1: build the static SPA bundle ----------------------------------- FROM node:24-alpine AS frontend-build WORKDIR /build COPY frontend/package*.json ./ RUN npm ci COPY frontend/ ./ RUN npm run build # ---- Stage 2: FastAPI serving both the SPA and the API ----------------------- FROM python:3.13-slim # Spaces runs the container as uid 1000, NOT root — /root is unwritable # there. Create the same uid at build time so everything (pip installs, # the HF cache, the app) lives in a home this user owns. RUN useradd -m -u 1000 user USER user ENV HOME=/home/user \ PATH=/home/user/.local/bin:$PATH \ HF_HOME=/home/user/.cache/hf WORKDIR /home/user/app # CPU-only torch wheel: ~10x smaller than the default CUDA build, and the # free Space has no GPU anyway. requirements-docker.txt excludes torch so # we install it once from the CPU index, then the rest normally. COPY --chown=user backend/requirements-docker.txt . RUN pip install --no-cache-dir torch==2.12.1 --index-url https://download.pytorch.org/whl/cpu \ && pip install --no-cache-dir -r requirements-docker.txt # Bake the model weights INTO the image. A free Space has ephemeral disk and # sleeps after ~48h idle, so anything downloaded at runtime is re-downloaded # on every cold start before the health check can pass. Baked image layers, by # contrast, survive restarts. # # Sentiment models bake into the HF cache via their real Hub names (which double # as the registry's fallback source id). Both ENABLED_MODELS sentiment entries # are baked: the Compare tab loads distilbert on first use. RUN python -c "\ from transformers import AutoModelForSequenceClassification, AutoTokenizer; \ names = ['cardiffnlp/twitter-roberta-base-sentiment-latest', \ 'distilbert/distilbert-base-uncased-finetuned-sst-2-english']; \ [(AutoTokenizer.from_pretrained(n), AutoModelForSequenceClassification.from_pretrained(n)) for n in names]" # Detectors are baked differently — into local model directories, not the HF # cache. Each registry detector sets a local_path, and resolve_model_source() # checks that on-disk dir FIRST (falling back to the Hub name only if it is # absent), so runtime resolves detectors from local weights. snapshot_download # each real detector repo into the exact path resolve_model_source() computes at # runtime (models/ sibling of the app dir). The registry names are the real Hub # repos too (the same ids used as the snapshot_download source), so the Hub-name # fallback would resolve as well — exactly like the sentiment models above. # # All THREE detectors are baked, not lazy-loaded: the AI Detector tab's default # action runs /api/ai-detect/compare with no model_ids, which scores every # detector at once — so a first click needs all three regardless, and on # ephemeral disk any un-baked weight would re-download on every cold start. # ~3GB of DeBERTa-v3-large + two RoBERTa checkpoints; the free Space's 16GB RAM # fits all five models (~3.4GB resident). *.bin is skipped because every repo # ships model.safetensors — this drops redundant pytorch_model.bin/training_args.bin. RUN python -c "\ from huggingface_hub import snapshot_download; \ repos = {'desklib/ai-text-detector-v1.01': 'desklib-ai-text-detector-v1.01', \ 'fakespot-ai/roberta-base-ai-text-detection-v1': 'fakespot-roberta-base-ai-text-detection-v1', \ 'Oxidane/tmr-ai-text-detector': 'oxidane-tmr-ai-text-detector'}; \ [snapshot_download(repo, local_dir=f'/home/user/models/{d}', ignore_patterns=['*.bin']) for repo, d in repos.items()]" COPY --chown=user backend/app ./app COPY --chown=user --from=frontend-build /build/dist ./static # STATIC_DIR turns on the FastAPI StaticFiles mount; PUBLIC_DEPLOY arms the # slowapi rate limiter; ENABLED_MODELS is the registry allowlist. All five baked # models are enabled — the two sentiment models plus all three AI detectors — so # both the Compare and AI Detector tabs work live; any other registry model # (finbert, xlm-twitter) still 403s. HF_HUB_OFFLINE is set only now — AFTER the # bake steps — so runtime never touches the network: startup either finds the # baked weights (sentiment in the HF cache, detectors in local dirs) or fails loudly. ENV STATIC_DIR=/home/user/app/static \ PUBLIC_DEPLOY=1 \ ENABLED_MODELS=twitter-roberta,distilbert-sst2,desklib-ai-detector,fakespot-ai-detector,oxidane-ai-detector \ HF_HUB_OFFLINE=1 EXPOSE 7860 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]