Spaces:
Sleeping
Deploying the Co-Study4Grid game to a HuggingFace Docker Space
This scaffolds a single-container deployment: one uvicorn process serves the FastAPI backend and the built React frontend (same origin) on port 7860, with the sample grids bundled so Game Mode works out of the box.
It is sized for one player per running Space (the backend holds a single active study — see the caveat at the bottom). For more players, each person clicks Duplicate this Space to get an isolated instance.
What was wired up
| File | Role |
|---|---|
Dockerfile (repo root) |
Multi-stage build: Vite SPA → Python runtime serving API + SPA + grids on :7860. |
.dockerignore (repo root) |
Trims the build context (no node_modules, .git, Overflow_Graph, …). |
deploy/huggingface/README.md |
The Space README (YAML frontmatter sdk: docker, app_port: 7860) + landing page. |
frontend/src/api.ts |
API base URL is now VITE_API_BASE_URL ?? http://127.0.0.1:8000 → empty in the image = same-origin. |
frontend/src/game/gameBridge.ts |
isGameMode() also honors VITE_GAME_MODE=1, so the Space boots straight into the game. |
expert_backend/main.py |
Serves the built SPA at / when COSTUDY4GRID_FRONTEND_DIST exists; honors $PORT. |
.gitattributes (repo root) |
Tracks *.zip / *.png / *.jpg via Git LFS so binaries can be pushed to the HF Space (its git endpoint rejects non-LFS binaries). |
config.default.json (repo root) |
Seeded into the image; the backend copies it to config.json on first boot (recommender defaults). |
The Dockerfile sets VITE_API_BASE_URL="" and VITE_GAME_MODE="1" for the
frontend build. Local dev (npm run dev) and the Vitest suite are unaffected
(both variables are unset there, so the old :8000 + ?game=1 behavior holds).
Large binaries: Git LFS / Xet
HuggingFace's git endpoint rejects files > 10 MiB and non-LFS binary
files. The European grid (pypsa_eur_eur220_225_380_400/network.xiidm, 22 MB)
therefore travels as a Git-LFS 2 MB), and the doc images travel as
LFS too. .zip (.gitattributes (repo root) declares this. The Dockerfile decompresses
the .zip back to network.xiidm at build time.
One-time, on your machine:
git lfs install
If your existing binaries were committed before .gitattributes existed,
migrate them once so they become LFS objects:
git add --renormalize . && git commit -m "migrate binaries to LFS"
Deploy steps
Create the Space — on huggingface.co: New → Space → Docker → Blank.
Push a single orphan snapshot to the Space. The branch history still contains the > 10 MiB raw
.xiidmfrom older commits, which HF would reject, so push one squashed commit of the current tree (binaries ride along via LFS — no need to delete them anymore):git lfs install # once git remote add space https://huggingface.co/spaces/<user>/<space> # once git checkout --orphan hf-deploy cp deploy/huggingface/README.md README.md # HF needs the frontmatter at root git add -A git commit -m "Deploy Co-Study4Grid game" git log --oneline hf-deploy # MUST be a single commit git -c protocol.version=0 push -f space hf-deploy:main git checkout -f claude/cool-bell-783agy git branch -D hf-deploy(
protocol.version=0works around afatal: expected 'acknowledgments'negotiation error some networks hit against HF.) HuggingFace reads the rootDockerfile+ the README frontmatter and builds; the first build is long (heavy scientific wheels), later builds reuse layers.Play — open the Space. It boots straight into the game shell (the
VITE_GAME_MODE=1build flag); the default session is the three European reference studies (Medium difficulty). Build without that flag for the bare workspace.
Automated redeploy on merge to main (GitHub Action)
.github/workflows/deploy-huggingface.yml runs the orphan-snapshot push
automatically. It is test-gated (D7): it fires on workflow_run when the
Tests workflow completes successfully on main (not merely on a merge),
checks out that exact tested commit with LFS, squashes it to one history-free
commit, and force-pushes it to the Space's main. A manual workflow_dispatch
always runs (the rollback path — see below).
Opt in by setting, in the GitHub repo Settings → Secrets and variables → Actions:
| Kind | Name | Value |
|---|---|---|
| Secret | HF_TOKEN |
a HuggingFace write access token with access to the Space |
| Variable | HF_SPACE |
the Space path, e.g. your-user/co-study4grid-game |
| Variable | HF_USERNAME |
(optional) the token owner's HF username — only when the Space is under an org and so differs from the owner part of HF_SPACE |
The job is inert (it logs a notice and exits cleanly) until both HF_TOKEN and
HF_SPACE are set, so merging the workflow doesn't break anything before you
opt in. The push reuses LFS objects already on the Space, so repeat deploys only
upload what changed.
Rolling back a bad deploy
The Space push is force-pushed and history-free, so the Space's own git log is
not a rollback trail. Instead, every successful deploy tags the exact commit it
shipped on origin as space-deploy-<UTC-timestamp>-<shortsha>. To roll back:
- Find the last good tag:
git tag --list 'space-deploy-*' | sort | tail. - Re-deploy that commit: Actions → Deploy to HuggingFace Space → Run
workflow, and pick the good tag (or its commit) as the ref. The manual
workflow_dispatchpath is not test-gated, so it redeploys immediately.
Reproducible Python closure (tracked follow-up)
The image still resolves the Python dependency tree at build time (floors in
pyproject.toml + the recommender floor), so a rebuild can pick up newer
transitive releases. To make the build reproducible, generate a lockfile on
Python 3.10 (matching the image base) and have both the Dockerfile and the
Tests workflow install from it:
# on Python 3.10:
pip install pip-tools
pip-compile --output-file requirements.lock pyproject.toml
Commit requirements.lock and pip install -r requirements.lock in both places
so "the Docker image mirrors CI" becomes literally true. (Not generated in-repo
yet: a lockfile must be resolved on the deployment's 3.10 interpreter, not a
dev 3.11, or it pins the wrong wheels.)
Lockdown profile (hosted deployments)
The Docker image sets COSTUDY4GRID_LOCKDOWN=1 (see the Dockerfile). On the
public Space the desktop-era filesystem RPCs — custom config-file path, session
save / list / load, and the native file picker — are disabled with a
403 {code: "LOCKED_DOWN"}, because they assume "the client is the operator on
their own machine" and would otherwise give an anonymous visitor read/write
access to the container filesystem. The read-only app config (GET /api/user-config, GET /api/config-file-path) stays available so the SPA
boots. Local/dev installs leave the variable unset and behave exactly as before.
See docs/architecture/deployment-trust.md.
Test the image locally first (recommended)
docker build -t costudy4grid-game .
docker run --rm -p 7860:7860 costudy4grid-game
# → open http://localhost:7860
The build has been validated end to end: the frontend bundle, the full
scientific stack (pypowsybl, ExpertOp4Grid, grid2op, pandapower,
lightsim2grid), backend import (42 routes), uvicorn on :7860, same-origin
SPA + /api/models, and the bundled fr225_400 grid loading via pypowsybl all
work. Image is ~370 MB.
Notes if you reproduce the build:
- The recommender is installed with
--no-depson purpose — its own dependency tree is self-conflicting (numpy>=2vs its transitivepypowsybl2grid'snumpy==1.26.4). The working runtime deps come frompip install .+overrides.txt. This mirrors CI. - If
docker pullof the base images hits Docker Hub's anonymous rate limit, eitherdocker loginor point the daemon at a pull-through mirror (/etc/docker/daemon.json:{"registry-mirrors": ["https://mirror.gcr.io"]}) and restart it. - If a dependency lacks a prebuilt wheel on the build host, add
build-essentialto theapt-get installline in the runtime stage and rebuild.
Caveat: one active study per instance
network_service and recommender_service are module-level singletons holding
one study's state, so a single Space is effectively single-tenant. Two
players hitting the same Space concurrently will clobber each other's network
load. For a public competition, prefer Duplicate this Space per player, or
refactor the backend to be session-scoped before a shared multi-player launch.