The ML stack of a single GPU node at CML: a GPU queue, model serving behind a standard chat-completions HTTP API, one MLflow tracking server, and a Cloudflare tunnel behind Access.
| Path | What it is |
|---|---|
bin/cmlops |
The one command on PATH: cmlops gate and cmlops serve |
compose.yaml |
Every long-running service: it includes mlflow/ and tunnel/ |
mlflow/ |
The MLflow tracking server |
tunnel/ |
The Cloudflare tunnel connector |
infra/ |
OpenTofu: the tunnel, its DNS records and Access |
serve/ |
The model servers cmlops serve starts per job, outside the root compose.yaml |
tests/ |
Shell tests, run by just check |
justfile |
just check and the template's recipes; just/local.just has setup, tofu, token |
.env.example |
The settings Docker Compose and just read, to copy to .env |
.github/ |
CI (the compose workflow runs just check, the checks workflow the git hooks) and Dependabot |
The node runs the stack from a clone of this repo. Nothing else on the host needs to know it
exists beyond bin/cmlops on PATH and the tracking URI its users set.
The host needs Docker Engine with the Compose plugin 2.20 or later (for include and the
healthchecks' start_interval), just, OpenTofu (just tofu runs it on the host; only the
just infra check uses a container), flock, curl and bash 4 or later. cmlops serve also
needs the NVIDIA driver and the NVIDIA Container Toolkit.
cp .env.example .envand fill it in. Docker Compose andjustboth read it.- Set up the tunnel once (below), so
$CMLOPS_STATE/tunnel-tokenexists. just setupcreates the MLflow store and the state dir as you, and checks the token file.docker compose up -d --wait. The services come back on their own after a crash or a reboot, untildocker compose down.ln -s "$PWD/bin/cmlops" ~/.local/bin/cmlops, or into any other directory on PATH.
To run MLflow before the tunnel exists, skip step 2: just setup then creates the dirs, says
there is no token yet and exits 0, and docker compose up -d --wait mlflow starts MLflow alone.
Run just setup and the full docker compose up -d --wait once the token is there.
To take over a node that already runs MLflow and a tunnel from elsewhere:
- Stop whatever holds the MLflow port, and any connector that runs for the same tunnel.
- Point
CMLOPS_STATEat the existing state dir, or moveterraform.tfstate,terraform.tfvarsandtunnel-tokeninto it. - Run
just tofu init -reconfigure, thenjust tofu plan. The plan must show no changes before any apply: an apply against empty state creates a second tunnel and DNS records that clash with the existing ones. If the old setup published other hostnames, list them inservicesinterraform.tfvarsbefore the first plan, or the plan will delete them. Aterraform.tfvarsfrom beforeteam_nameexisted needs that line added too. - Keep the existing store path in
CMLOPS_MLFLOW_STORE. - Then
just setupanddocker compose up -d --wait.
Every service binds to 127.0.0.1 only; the tunnel with Access is the only way in from outside. The service images in the compose files are pinned by tag and digest, and Dependabot bumps them; the tool images in the justfile are pinned the same way and bumped by hand.
cmlops gate quiet|gpu|cpu [--wait SECONDS] -- CMD... runs CMD under a lock shared by all of one user's jobs on the host:
quietis a timing run: it waits until no cpu or quiet job runs, and keeps new ones out.cpuis heavy work that is not timed. It runs beside other cpu and gpu jobs and waits while a quiet job runs.gpuis one GPU job at a time, beside any quiet or cpu job.
A gate inside a gate runs at once rather than waiting for itself, except quiet inside cpu, which
exits 2, and a gpu gate inside another gate, which still waits for the GPU lock. --wait gives up
with exit 75. While a quiet gate holds, an optional host helper can pin CPU clocks and move other
work off the timed CPUs: CMLOPS_GATE_HELPER names it, and CMLOPS_GATE_CPUS sets the timed CPUs.
The comment above gate() in bin/cmlops has the details.
cmlops serve vllm|llama MODEL NAME [-- CMD...] serves a local model behind a chat-completions
API for the length of one command:
cmlops serve vllm ~/.cache/huggingface/hub/models--Qwen--Qwen3.5-9B/snapshots/<rev> qwen3.5-9b \
-- python label.py # label.py reads CMLOPS_SERVE_URL and CMLOPS_SERVE_NAME
CMLOPS_SERVE_MODELS=/srv/models CMLOPS_SERVE_ARGS='--n-cpu-moe 20' \
cmlops serve llama /srv/models/lfm2.5-24b-a2b-q4_k_m.gguf lfm -- ...MODEL is an absolute path under CMLOPS_SERVE_HF (default ~/.cache/huggingface) or
CMLOPS_SERVE_MODELS (default serve/models in this clone, which git ignores): only those two
are mounted.
It goes through cmlops gate gpu, starts the engine from serve/compose.yaml under a throwaway
tmp-serve-<pid> project, waits until /v1/models answers, and runs CMD with CMLOPS_SERVE_URL,
CMLOPS_SERVE_NAME and CMLOPS_SERVE_IMAGE (the pinned image, for a run's record) set. The
server is removed on any exit, and CMD's exit code is serve's. Without CMD the server stays up
until serve is stopped; the URL is printed on stderr. No server runs all the time: an
idle one would hold the GPU's memory outside the gate. The comment above serve() lists the
settings.
One tracking server for every project on the node, on 127.0.0.1:${CMLOPS_MLFLOW_PORT:-5000}.
A client talks HTTP and never opens the database itself, so a project's locked MLflow version
need not match the store's schema: only the server's version does.
The store is $CMLOPS_MLFLOW_STORE, mounted at /mlflow: mlflow.db and artifacts/. Back it
up from the host. Copy the database through SQLite's backup API, never with cp while the server
runs, which can copy a half-written file:
docker compose exec mlflow python -c "import sqlite3; sqlite3.connect('/mlflow/mlflow.db').backup(sqlite3.connect('/mlflow/mlflow.db.bak'))"To upgrade MLflow, follow the comment at the top of mlflow/compose.yaml.
export MLFLOW_TRACKING_URI=http://127.0.0.1:5000
curl -s http://127.0.0.1:5000/health # OKLeave the artifact location to the server: a new experiment gets mlflow-artifacts:/<id>, and
the client uploads artifacts through the server. A local artifact_location would record a path
only one checkout has. mlflow-skinny is enough on the client side.
To delete runs for good, back up mlflow.db as above, then name the runs:
docker compose exec mlflow mlflow gc --backend-store-uri sqlite:////mlflow/mlflow.db \
--artifacts-destination /mlflow/artifacts --run-ids <ids>Without --run-ids, gc purges every soft-deleted run in the store.
A Cloudflare tunnel publishes the node's web UIs behind Cloudflare Access, so a browser opens
them without ssh -L. infra/ holds the tunnel, its ingress rules (ending in a 404), the DNS
records, one Access application per hostname and one shared policy for the allowed emails. Access
sends a one-time PIN to those addresses only; a session lasts 24 hours. Each ingress rule also
makes cloudflared check the Access token for its own application, so a request without one never
reaches the service. The connector in
tunnel/compose.yaml only dials out, and binds only a metrics port on 127.0.0.1:20241 for its
healthcheck.
just tofu runs OpenTofu with everything private in $CMLOPS_STATE (mode 700): the state, its
.terraform dir and terraform.tfvars. just token writes the connector's token there.
-
In the Cloudflare dashboard (My Profile, API Tokens, Create Custom Token), make a token scoped to the account and the zone, with: Account, Cloudflare Tunnel, Edit; Account, Access: Apps and Policies, Edit; Zone, DNS, Edit.
-
Open the zone's Overview page: the API box in the right sidebar lists the Zone ID and the Account ID. The Zero Trust dashboard's settings show the team name (
team_name), the first part of<team_name>.cloudflareaccess.com. -
Fill in the variables (
set -a; . ./.env; set +aputsCMLOPS_STATEin the shell):mkdir -p -m 700 "$CMLOPS_STATE" cp infra/terraform.tfvars.example "$CMLOPS_STATE/terraform.tfvars" $EDITOR "$CMLOPS_STATE/terraform.tfvars"
-
Apply, with the API token in the environment for this shell only, then write the connector token:
read -rs CLOUDFLARE_API_TOKEN && export CLOUDFLARE_API_TOKEN just tofu init just tofu apply just token
Access sends the PIN only if the account has the One-time PIN login method (Zero Trust, Settings, Authentication).
Add subdomain = "http://127.0.0.1:<port>" to services in terraform.tfvars (the default is in
infra/variables.tf), then just tofu apply. The ingress rule, the DNS record and the Access
application all follow from that map, and the tests fail if a hostname would lack Access. The
connector picks up the new route on its own, since the tunnel is managed from Cloudflare.
-
MLflow has no login. Access is its only gate, so every hostname gets an Access application, and
tofu testchecks that. -
Keep the services bound to 127.0.0.1. Then the local network cannot reach them directly, and the only way in from outside is through Access.
servicesrefuses anything else. -
The tunnel token lets whoever holds it run a connector for this tunnel, and so serve its hostnames. It lives only in
$CMLOPS_STATE/tunnel-token(mode 600, checked byjust setup) and in the state file next to it. To rotate it, replace the tunnel and its token:just tofu apply -replace=cloudflare_zero_trust_tunnel_cloudflared.node just token docker compose up -d --force-recreate cloudflared
The new tunnel gets a new ID, and the DNS records follow it in the same apply.
just check # compose config, shell lint, secrets in history, hygiene, tofu fmt/validate/test and tests/
prek install # the git hooks: file checks and linters on commit, the message on commit, `just check` on push
just hooks # every git hook over the whole repo, as the checks workflow doesThe linters and OpenTofu run in pinned containers, so just check needs Docker, and network
access for the images and for tofu init to fetch the provider. The hooks need prek. A test
that cannot run on the machine (no Docker, not Linux) prints SKIP and does not fail, except when
CI is set, as GitHub Actions does: there a skip fails the run.
MIT. Per-file licensing is in REUSE.toml.