A live public URL on Hugging Face Spaces where anyone can paste their skills and get ranked careers with a skill-gap breakdown; a GitHub repository (public or private, your choice) with pinned dependencies, tests, a Dockerfile, a CI workflow that runs the evaluation harness and redeploys the Space on every merge, and a README with an "Open in Spaces" badge. The pattern transfers to any Streamlit or Gradio ML app.
- The SkillMatch AI repository (a Streamlit app with
app.py,src/,data/careers.csv,evaluate.pyandrequirements.txt); any similar Streamlit ML app works the same way - Git, Python 3.11 or 3.12, and Docker Desktop installed (the DevOps zero-to-production project covers installation)
- A free GitHub account and a free Hugging Face account
- About 2 GB of disk for the PyTorch CPU wheel and the model cache
- Hugging Face Spaces — free CPU hosting for Streamlit apps with a public URL, built straight from a git repository ↗
- Streamlit — the app's UI framework; Spaces has a native Streamlit runtime ↗
- sentence-transformers + PyTorch (CPU) — the embedding model; the CPU wheel keeps installs small and fast ↗
- pytest — tests for the recommender and the evaluation gate in CI ↗
- GitHub Actions — run tests on pull requests and push to the Space on merge ↗
- Docker — a portable image for anyone who wants to run it without Python, and an alternative Space type ↗
skillmatch-ai/
├── .github/workflows/
│ ├── ci.yml # tests + evaluation gate on every PR
│ └── deploy.yml # push to the Hugging Face Space on merge to main
├── .streamlit/
│ └── config.toml # theme and server settings
├── app.py # Streamlit UI (existing)
├── src/ # embedder, preprocess, recommender, skill_gap (existing)
├── data/careers.csv # curated career dataset (existing)
├── tests/
│ ├── test_recommender.py
│ └── test_app_smoke.py
├── evaluate.py # Top-K accuracy harness (existing, now emits JSON)
├── requirements.txt # pinned, CPU-only torch
├── Dockerfile
├── .dockerignore
├── .gitignore
└── README.md # Spaces front matter + badges + how to runTick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.
Run it locally and understand what it needs
A clean clone runs on your machine, and you know exactly which files, downloads and caches the app depends on — the things a hosting platform must also provide.
- Clone the repository into a fresh directory and open it in your editor. If the repository is private, cloning with the GitHub CLI handles authentication.bash
gh repo clone prashanthaitha24/skillmatch-ai cd skillmatch-ai code . - Create a virtual environment and install the dependencies. The first install pulls PyTorch, which is large; that is one of the things you will fix in the next phase.bash
python3.12 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install -r requirements.txtWindows PowerShell:.venv\Scripts\Activate.ps1. - Run the evaluation harness first. It loads the model (downloading about 90 MB from the Hugging Face Hub on first use) and reports Top-K accuracy on the hand-labelled cases; it is the fastest proof the pipeline works.bash
python evaluate.pyCheck: It prints an accuracy figure and no traceback. Adata/career_embeddings.npzcache file now exists (it is git-ignored). - Start the Streamlit app and try the one-click example profiles.bash
streamlit run app.pyCheck: The browser opens at http://localhost:8501, the spinner says it is loading the SBERT model, and a profile returns ranked careers with matched and missing skills. - Write down the runtime dependencies you just observed, because a hosting platform has to satisfy each one: Python packages (including PyTorch), outbound access to the Hugging Face Hub to download the model on first run, a writable directory for the model cache and for
data/career_embeddings.npz, and roughly 1 to 2 GB of RAM while the model is loaded.Hugging Face Spaces provides all four on its free CPU tier, which is why it is the first deployment target.
Make it reproducible and testable
Pinned dependencies that install quickly anywhere, a couple of tests, an evaluation script that can gate a pipeline, and Streamlit configuration in the repository.
- Create a branch for the deployment work.bash
git switch -c feat/deploy - Replace the loose
>=requirements with pinned versions, and install the CPU-only PyTorch wheel: it is a fraction of the size of the default build and all the app needs. The extra index line is honoured by pip inside a requirements file.text--extra-index-url https://download.pytorch.org/whl/cpu torch==2.5.1+cpu sentence-transformers==3.3.1 scikit-learn==1.6.1 pandas==2.2.3 numpy==2.2.1 streamlit==1.41.1 pypdf==5.1.0Save asrequirements.txt. If a version is unavailable when you read this, pick the current stable release of each package and keep the+cpusuffix on torch. On Apple Silicon the+cpuwheel is not published; for local installs on a Mac use plaintorch==2.5.1and keep the CPU line for Linux hosts by using a second filerequirements-linux.txt, or simply accept the default wheel locally. - Reinstall from the pinned file in a fresh environment and confirm the app still works. This is the install the Space and the CI runner will perform.bash
deactivate; rm -rf .venv python3.12 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt python evaluate.pyCheck: Install time and download size drop noticeably; the evaluation result is unchanged. - Add a small test module for the recommender: the top result for a clear data-science profile must be a data role, results must be ranked by similarity, and the skill gap must never list a skill the user already has.python
# tests/test_recommender.py from pathlib import Path import pytest from src.recommender import CareerRecommender DATA = Path(__file__).resolve().parents[1] / "data" / "careers.csv" @pytest.fixture(scope="module") def rec(): return CareerRecommender(DATA) # loads the model once for the module def test_data_profile_ranks_a_data_role_first(rec): matches = rec.recommend("I know Python, SQL, machine learning, and data visualization.", top_k=3) assert "Data" in matches[0].career_title or "Analyst" in matches[0].career_title def test_results_are_sorted_by_similarity(rec): matches = rec.recommend("I build React frontends with JavaScript, HTML and CSS.", top_k=5) sims = [m.similarity for m in matches] assert sims == sorted(sims, reverse=True) def test_gap_excludes_skills_already_present(rec): matches = rec.recommend("Python, SQL, Docker, Kubernetes, Terraform", top_k=3) for m in matches: assert not set(s.lower() for s in m.matched_skills) & set(s.lower() for s in m.missing_skills)Adjust the method name (recommend) and attribute names to matchsrc/recommender.pyif they differ; the README's description of the match object is the reference. - Add a smoke test that imports the Streamlit app in headless mode using Streamlit's testing API, so a broken import or a crash on load fails CI before anything is deployed.python
# tests/test_app_smoke.py from streamlit.testing.v1 import AppTest def test_app_renders_without_exception(): at = AppTest.from_file("app.py", default_timeout=120) at.run() assert not at.exception assert any("SkillMatch" in t.value for t in at.title) - Install pytest into a dev requirements file and run everything once.bash
printf -- '-r requirements.txt\npytest==8.3.4\n' > requirements-dev.txt pip install -r requirements-dev.txt pytest -qCheck: Four tests pass. The first run is slow while the model loads; later runs use the cache. - Make
evaluate.pyusable as a gate: print the Top-K accuracy as JSON and exit non-zero below a threshold. Append this to the end of the script (adapt the variable holding the accuracy to the script's own name).pythonimport json import sys THRESHOLD = 0.8 # minimum acceptable Top-3 accuracy on the labelled cases def report(accuracy: float, k: int) -> None: result = {"top_k": k, "accuracy": round(accuracy, 3), "threshold": THRESHOLD} print(json.dumps(result)) with open("eval_results.json", "w") as f: json.dump(result, f) if accuracy < THRESHOLD: print(f"FAIL: accuracy {accuracy:.3f} below threshold {THRESHOLD}", file=sys.stderr) sys.exit(1)Callreport(accuracy, k)where the script currently prints its result. Now a change to the dataset or the model that degrades ranking quality fails the pipeline instead of shipping silently. - Add a Streamlit configuration file so the app looks the same everywhere and never tries to open a browser or collect usage statistics on a server.toml
[server] headless = true enableCORS = false enableXsrfProtection = true [browser] gatherUsageStats = false [theme] base = "dark" primaryColor = "#8b5cf6"Save as.streamlit/config.toml. Keep.streamlit/secrets.tomlin.gitignore(it already is) even though this app needs no secrets. - Commit the work.bash
git add requirements.txt requirements-dev.txt tests .streamlit evaluate.py git commit -m "chore: pinned CPU deps, tests, evaluation gate, streamlit config"
Deploy to Hugging Face Spaces
A public URL running the app on the free CPU tier, built from a git push, with the GitHub repository staying private if you want it to.
- Create the Space in the browser: go to huggingface.co, sign in, click your avatar → New Space. Name it
skillmatch-ai, choose the Streamlit SDK, hardware CPU basic (free), visibility Public, and create it. Note the URL it gives you:https://huggingface.co/spaces/YOUR_HF_USER/skillmatch-ai.A Space is itself a git repository. You will push your code to it directly; the GitHub repository can remain private because the Space never reads from GitHub unless you tell it to. - Create a Hugging Face access token with write permission: avatar → Settings → Access Tokens → Create new token → type Write. Copy it once; it is the password for git pushes to the Space.Treat it like any secret: never commit it. You will also store it as a GitHub Actions secret in the next phase.
- Add the Space's front matter to the top of
README.md. Spaces read these fields to know how to run the app. Use the Streamlit version the Space creation page listed as supported (it shows the allowed range); keep it equal to the pin inrequirements.txtif that version is allowed.text--- title: SkillMatch AI emoji: 🧭 colorFrom: indigo colorTo: purple sdk: streamlit sdk_version: 1.41.1 app_file: app.py pinned: false license: mit --- # 🧭 SkillMatch AI (existing README content continues below)app_filemust point at the Streamlit entry point. If the Space rejects thesdk_version, change it to one from the supported list; the Space's build log tells you. - Add the Space as a second git remote and push the branch to the Space's
main. Git will ask for a username (your Hugging Face username) and a password (paste the write token).bashgit remote add space https://huggingface.co/spaces/YOUR_HF_USER/skillmatch-ai git push space feat/deploy:main --forceThe--forceis only needed on this first push because the new Space already contains a placeholder commit. On macOS the token is stored in Keychain by the git credential helper after the first push; on Linux considergit config credential.helper storeor use the token in the URL for a one-off. - Watch the build: open the Space page and click the Logs tab (or the "Building" status). It installs
requirements.txt, then starts Streamlit. The first start downloads the model from the Hub, which is fast because it stays inside Hugging Face's network.Check: The status turns to Running and the app loads athttps://YOUR_HF_USER-skillmatch-ai.hf.space(also embedded on the Space page). Try an example profile. - If the build fails, the log names the cause. The three usual ones: an unsupported
sdk_version(change it to a listed one), a package version that does not exist for Linux (pin to one that does), or an import error from a missing file (make suresrc/anddata/careers.csvwere pushed; check.gitignore).Spaces run Linux on x86-64, so thetorch==...+cpuwheel from the extra index works there even if you used the default wheel on a Mac. - Confirm the model cache behaves: reload the page a few times. The recommender is wrapped in
st.cache_resource, so the model loads once per process; subsequent visitors get instant results. Spaces on the free tier go to sleep after a period of inactivity and wake on the next visit with a cold start of about a minute — acceptable for a demo, and the Space settings show the sleep timer.Check: Second and later profile submissions return in well under a second. - Merge the branch on GitHub through a pull request so
mainmatches what is deployed.bashgit push -u origin feat/deploy gh pr create --fill && gh pr merge --squash --delete-branch git switch main && git pull
Automate: test on pull requests, deploy on merge
Every pull request runs the tests and the evaluation gate; every merge to main updates the Space automatically, so the demo never drifts from the code.
- Store the Hugging Face token as a GitHub Actions secret and your Space id as a variable.bash
gh secret set HF_TOKEN # paste the write token when prompted gh variable set HF_SPACE --body "YOUR_HF_USER/skillmatch-ai" - Create the CI workflow: install the pinned CPU dependencies with caching, run the tests, then run the evaluation gate and keep its JSON output as an artifact.yaml
name: ci on: pull_request: push: branches: [main] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip - run: pip install -r requirements-dev.txt - name: Cache the sentence-transformers model between runs uses: actions/cache@v4 with: path: ~/.cache/huggingface key: hf-models-all-MiniLM-L6-v2 - run: pytest -q - name: Evaluation gate (Top-K accuracy must clear the threshold) run: python evaluate.py - uses: actions/upload-artifact@v4 with: name: eval-results path: eval_results.jsonSave as.github/workflows/ci.yml. Caching the model directory keeps CI runs to a couple of minutes. - Create the deploy workflow: on every push to
main, after CI succeeds, push the same commit to the Space. Pushing the full history with--forcekeeps the Space an exact mirror ofmain.yamlname: deploy on: workflow_run: workflows: [ci] types: [completed] branches: [main] permissions: contents: read jobs: push-to-space: if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 lfs: true - name: Push main to the Hugging Face Space env: HF_TOKEN: ${{ secrets.HF_TOKEN }} HF_SPACE: ${{ vars.HF_SPACE }} run: | git push --force "https://user:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE}" HEAD:mainSave as.github/workflows/deploy.yml. The username in the URL is ignored when a token is supplied;useris a placeholder. If you ever add files over 10 MB, track them with git LFS, which Spaces require for large files. - Open a pull request with the workflows and watch CI run on it; then merge and watch the deploy workflow push to the Space and the Space rebuild.bash
git switch -c ci/pipelines git add .github git commit -m "ci: tests and evaluation gate on PRs, deploy to Spaces on merge" git push -u origin ci/pipelines gh pr create --fill && gh pr checks --watch gh pr merge --squash --delete-branch git switch main && git pull && gh run watchCheck: The Space's commit history (Files tab) shows your merge commit, and the app reflects any change you made. - Prove the gate works: in a branch, remove three careers from
data/careers.csvthat the evaluation cases expect, open a pull request, and confirm CI fails on the evaluation step. Close the pull request without merging.Check: TheEvaluation gatestep is red with the accuracy and threshold in its output; the Space is untouched.
Containerise it
A Docker image anyone can run with one command, and the option to host the same image as a Docker-type Space or on any container platform.
- Create a branch and write a multi-stage Dockerfile. The build stage installs the CPU wheels; the final stage is slim, non-root, and pre-downloads the model so the container starts without network access to the Hub.dockerfile
# syntax=docker/dockerfile:1 FROM python:3.12-slim AS build WORKDIR /src COPY requirements.txt . RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements.txt FROM python:3.12-slim RUN useradd --create-home --uid 10001 app WORKDIR /home/app COPY --from=build /venv /venv COPY app.py evaluate.py ./ COPY src ./src COPY data ./data COPY .streamlit ./.streamlit ENV PATH="/venv/bin:$PATH" HF_HOME=/home/app/.cache/huggingface PYTHONUNBUFFERED=1 # bake the model into the image so cold starts need no download RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" \ && chown -R app:app /home/app USER app EXPOSE 8501 HEALTHCHECK --interval=30s --timeout=5s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health')" CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"] - Add a
.dockerignoreso tests, caches and the virtual environment stay out of the image.text.git .venv .github tests __pycache__ *.pyc .pytest_cache data/career_embeddings.npz eval_results.json .streamlit/secrets.toml - Build and run the image, then open the app.bash
docker build -t skillmatch-ai:local . docker run --rm -p 8501:8501 skillmatch-ai:localCheck: http://localhost:8501 loads and returns results with the container's network disconnected (docker run --network none ...also works, proving the model is baked in). - Optional: host the container on Hugging Face instead of the Streamlit runtime. Create a second Space with the Docker SDK, set its front matter to
sdk: dockerandapp_port: 8501, and push the same repository. Docker Spaces are the route to custom system packages or to running the app on other platforms (Cloud Run, Fly.io, a VM) with the identical image.For a Docker Space the container must listen on the port named inapp_port, which this Dockerfile does. - Add the image build to CI so a broken Dockerfile is caught on pull requests. Append a job to
ci.yml.yamlimage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . push: false tags: skillmatch-ai:ci cache-from: type=gha cache-to: type=gha,mode=maxIndent it soimage:sits at the same level astest:underjobs:. - Commit and merge through a pull request; the deploy workflow updates the Space as before.bash
git add Dockerfile .dockerignore .github/workflows/ci.yml git commit -m "build: multi-stage Docker image with the model baked in" git push -u origin HEAD gh pr create --fill && gh pr checks --watch && gh pr merge --squash --delete-branch git switch main && git pull
Evaluate, monitor and publish
Know whether the recommender is actually good, see whether anyone uses it, and make the project easy to find and run.
- Grow the evaluation set. Add at least twenty labelled profiles to
evaluate.py(or move them totests/eval_cases.json), including tricky ones: abbreviations (ml,k8s), profiles that fit two careers, and a profile with skills from no career. Rerun the harness and adjust the threshold to a level the current model clears with a small margin.Check:python evaluate.pyprints the new accuracy; CI still passes; the threshold is written next to the number it was derived from. - Add a second metric to the harness: mean reciprocal rank (1 divided by the position of the expected career), which rewards ranking the right answer first, not just within the top K. Report both in the JSON.python
def mean_reciprocal_rank(ranked_titles: list[list[str]], expected: list[str]) -> float: total = 0.0 for titles, want in zip(ranked_titles, expected): for i, t in enumerate(titles, start=1): if t == want: total += 1 / i break return total / len(expected) - Add lightweight usage logging inside the app: one structured log line per recommendation request with the length of the input, the number of results and the top similarity — never the user's text itself. Streamlit's process logs are visible in the Space's Logs tab.python
import json import logging import time log = logging.getLogger("skillmatch") def log_request(profile_text: str, matches, started: float) -> None: log.info(json.dumps({ "event": "recommend", "input_chars": len(profile_text), "results": len(matches), "top_similarity": round(float(matches[0].similarity), 3) if matches else None, "latency_ms": int((time.time() - started) * 1000), }))Call it where the app computes recommendations, wrapping the call withstarted = time.time(). Logging inputs would make the demo a store of other people's résumés; the counts and latency are all you need to see usage and performance. - Use the Space's built-in analytics (the Space page shows views and likes) and its Logs tab for the request lines above. If you want persistence, write the same JSON lines to a Hugging Face dataset repository with the
huggingface_hublibrary; for a demo the logs tab is enough.Free Spaces have no uptime guarantee; if the demo matters for a job application, consider the paid always-on setting for the week it counts. - Update the README: an "Open in Spaces" badge at the top, the one-line local run, the Docker run, how to run the tests and the evaluation, and the accuracy and MRR figures with the date they were measured. Link to the lesson on RAG and evals in the AI track for readers who want the theory.text
[](https://huggingface.co/spaces/YOUR_HF_USER/skillmatch-ai) ## Try it Live demo: https://huggingface.co/spaces/YOUR_HF_USER/skillmatch-ai ## Run locally python3.12 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt streamlit run app.py ## Run with Docker docker build -t skillmatch-ai . docker run --rm -p 8501:8501 skillmatch-ai ## Tests and evaluation pip install -r requirements-dev.txt pytest -q python evaluate.py # Top-3 accuracy 0.92, MRR 0.85 on 24 labelled cases (measured 2026-09-21)Replace the figures with your own fromeval_results.json. - Decide on the GitHub repository's visibility. The Space is public either way. Making the GitHub repository public lets people read the code and the CI setup, which is the point if this is a portfolio piece: Settings → General → Danger Zone → Change visibility. If you keep it private, add a line to the README on the Space saying the source is available on request.Check: Merge the README through a pull request; the Space redeploys and shows the badge and the demo link at the top of its page.
Troubleshooting
- The Space build fails with a message about
sdk_versionnot being supported - Open the Space's Settings or the creation form to see the allowed Streamlit versions and set
sdk_versionin the README front matter to one of them; pinstreamlitinrequirements.txtto the same version. pipon the Space cannot findtorch==2.5.1+cpu- Make sure the
--extra-index-url https://download.pytorch.org/whl/cpuline is the first line ofrequirements.txt, and that the torch version exists on that index; if not, use the newest version listed there. - The app starts but shows a traceback about a missing
data/careers.csvorsrcmodule - The files were not pushed. Run
git ls-filesand check.gitignore; the dataset must be committed, only the.npzcache is ignored. - The first request on the Space takes over a minute or times out
- That is the model download on first start plus embedding the careers. It happens once per container start; keep
st.cache_resourceon the recommender. For instant cold starts use the Docker Space with the model baked into the image. - The deploy workflow fails with
authentication failedwhen pushing to the Space - The token must be a write token and stored as the
HF_TOKENsecret; regenerate it if in doubt. Check thatHF_SPACEisuser/space-name, not the full URL. - Pushing to the Space is rejected because a file is larger than 10 MB
- Spaces require git LFS for large files:
git lfs install,git lfs track "*.npz"(or the file type), commit.gitattributes, and push again. Better: do not commit generated caches at all. AppTestsmoke test times out in CI- Model loading exceeds the default timeout on a cold runner. The test sets
default_timeout=120; make sure the model cache step inci.ymlis beforepytest, and increase the timeout if the runner is slow. - The evaluation gate fails after adding new careers to the dataset
- New careers change the ranking of existing cases. Inspect which cases now fail, decide whether the new ranking is actually better (then update the expected label) or worse (then fix the career description), and keep the threshold honest.
Where to go from here
- Swap the embedding model for a stronger one (a larger sentence-transformers model) behind the same
Embedderinterface and compare accuracy and latency with the evaluation harness before switching the demo. - Add retrieval-augmented explanations: use the AI track's RAG lesson to generate a short "why this career" paragraph grounded in the career description, with the citation shown.
- Deploy the Docker image to a cloud container service with the Terraform and CI patterns from the DevOps zero-to-production project, and put the SRE track's SLOs on it.
- Add a privacy notice and a rate limit before promoting the demo widely; résumé text is personal data even if you never store it.
Did a step fail or feel unclear? Tell me which one →