Skip to content

Deployment

This project runs entirely on free tiers. That is a deliberate constraint, and it matches how comparable open-source sports-data projects actually operate.

What runs where

Need Service Cost
Scheduling GitHub Actions cron free on public repositories
Artifact storage GitHub Releases, Hugging Face Datasets free
Inference API FastAPI in Docker on Hugging Face Spaces free CPU tier
Dashboard Streamlit on Hugging Face Spaces free CPU tier
Documentation MkDocs on GitHub Pages free
Drift reporting Evidently report rendered into the docs site free

What this costs you

Honest accounting of what a free tier does not provide:

  • No autoscaling. One container, one machine.
  • Cold starts. A free Space sleeps when idle. First request after a quiet period takes several seconds.
  • No live-traffic drift detection. Drift is computed against new data on a schedule, not against production requests, because there is not enough production traffic to compute anything from.
  • No zero-downtime deploys. A redeploy is a restart.

None of these matter for a project serving a research metric. All of them would matter for a product.

Kubernetes

The repository contains KServe manifests under deploy/kubernetes/. They are validated in continuous integration and tested against a kind cluster, which runs Kubernetes inside Docker on a laptop at no cost.

They are not used in production, because production here is one container serving occasional requests, and Kubernetes exists to solve a problem this project does not have. They exist so the deployment path is real and tested rather than hypothetical.

Container

The image is built from deploy/Dockerfile, multi-stage, non-root, with the model baked in at build time rather than downloaded at start. Startup that depends on a network fetch is startup that fails when the network does.

The serving layer, as built

src/pippen/api/app.py is a FastAPI application over the tables that ship inside the wheel. Five endpoints, no model loading, and nothing computed per request. A response is a filtered read of a 228 KB Parquet file, so the cold start on a sleeping free tier is process boot rather than numpyro importing jax.

Endpoint Returns
GET /health Liveness, version, and the seasons the process is serving
GET /ratings Players ranked by impact per 100 possessions
GET /ratings/{player_id} One player across every window they appear in
GET /reliability Measured split-half reliability of each box-score metric
GET /possession-coefficient/{season} The measured value, against the 0.44 convention

No endpoint returns a per-player interval, and the OpenAPI description says so rather than leaving a caller to notice. ADR 0007 has the reasoning: the only candidate was a bootstrap standard error on a ridge coefficient, and Phase 2 measured that it rises with possessions rather than falling.

Container

deploy/Dockerfile builds in two stages. The first produces a wheel, the second installs only that wheel, so the runtime image carries no build backend, no test suite and no git history. It runs as uid 10001, and because the wheel already contains the computed tables the container needs no volume, no download on boot and no network access at all.

Verified locally: the image builds from a clean checkout, /health answers, id -u reports 10001, and a write to / is refused.

One number worth knowing before this is deployed anywhere with a size limit. The image is 939 MB, of which 588 MB is the dependency install. import pippen needs pandas and pyarrow, but scipy, scikit-learn, pandera, typer and rich are all core dependencies, so every install pulls the full RAPM computation stack to serve a static table.

Kubernetes

deploy/kubernetes/inferenceservice.yaml is a KServe InferenceService, scale-to-zero, one request per replica, read-only root filesystem with every capability dropped. Nothing applies it to a cluster: this project runs on free tiers and a cluster solves no problem it has. CI validates it with kubeconform against the KServe CRD schema, so the manifest is checked rather than claimed.