How to Run an MLflow Tracking Server in a Cloud Sandbox
Updated October 27, 2026 .md
Short answer: Install MLflow with uv, run mlflow server as a systemd service on 127.0.0.1:5000 with SQLite and local artifacts, and host it with sbx host --localhost. Put the hosted hostname in --allowed-hosts and --cors-allowed-origins or MLflow answers 403. Your laptop logs runs by sending the URL's __sbx_auth cookie.
What we ran#
Everything below ran on production on October 27, 2026, in one small sandbox (2 vCPU, 4 GB) with MLflow 3.17.2 and Python 3.12. The laptop client was mlflow-skinny 3.17.2. Timings are single runs.
| Step | Result |
|---|---|
Create a venv and install mlflow with uv |
10.7 s |
| Hosted URL without its token | 401 on every path, including /health |
| With the cookie, default MLflow settings | /health 200, but the UI and API 403 Invalid Host header |
With --allowed-hosts only |
UI 200, but a POST with the page's own Origin 403 Cross-origin request blocked |
With --allowed-hosts and --cors-allowed-origins |
UI and API 200; a foreign Origin still 403 |
| Log params, 5 metric steps and a file from the laptop | 1.99 s |
sbx stop --wait, then sbx resume |
2.2 s and 1.3 s. Memory restored, same server PID, run intact |
| Resume that boots from disk | systemd restarted MLflow; run, metrics and artifact still there |
A small sandbox costs $0.018/hour while it runs and nothing while it is stopped.
1. Install MLflow#
The base image has Python 3.12 and uv. Install the full mlflow package, which includes the server, into its own venv (MLflow tracking server docs):
sbx new --name mlflow --type small
id=sb_rotvpjxf8y # the id that sbx new printed; note its "subdomain" too
sbx exec $id --timeout 600 "uv venv -q ~/mlflow-venv --python 3.12 && \
VIRTUAL_ENV=~/mlflow-venv uv pip install -q mlflow==3.17.2 && ~/mlflow-venv/bin/mlflow --version"mlflow, version 3.17.2sbx exec stops a command after 30 seconds by default. --timeout 600 gives the install room.
2. Run it as a systemd service#
Your hosted URL will be https://<subdomain>-5000.box.sandboxer.com (Hosting). Ours was fond-vernal-walrus-5000.box.sandboxer.com. MLflow needs that hostname up front, for reasons shown in step 3:
sbx exec $id "mkdir -p ~/mlflow && sudo tee /etc/systemd/system/mlflow.service >/dev/null <<EOF
[Unit]
Description=MLflow tracking server
After=network-online.target
[Service]
User=user
WorkingDirectory=/home/user/mlflow
ExecStart=/home/user/mlflow-venv/bin/mlflow server --host 127.0.0.1 --port 5000 \
--backend-store-uri sqlite:////home/user/mlflow/mlflow.db \
--artifacts-destination /home/user/mlflow/artifacts \
--allowed-hosts fond-vernal-walrus-5000.box.sandboxer.com \
--cors-allowed-origins https://fond-vernal-walrus-5000.box.sandboxer.com
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now mlflow && \
until curl -sf localhost:5000/health >/dev/null; do sleep 0.2; done; systemctl is-active mlflow"active--host 127.0.0.1keeps MLflow off the sandbox's public IPv6 address. The hosting proxy reaches it through the--localhostrelay in the next step.- Run metadata goes to SQLite at
~/mlflow/mlflow.db. Files you log go to~/mlflow/artifacts, and the server proxies uploads and downloads, so clients never need direct disk or bucket access. - The enabled unit starts MLflow again after a resume that boots from disk (long-running docs).
3. Host it, and why MLflow needs the two flags#
sbx host $id 5000 --localhost --title MLflowhttps://fond-vernal-walrus-5000.box.sandboxer.com?_token=<token>The port is private by default. Without the token, the proxy answers 401 for every path and MLflow never sees the request. Opening the full URL once answers 302 and sets the __sbx_auth cookie, as on any private hosted port.
MLflow 3.5 and later adds its own security middleware (MLflow security docs). We first ran the server without --allowed-hosts and --cors-allowed-origins. With a valid cookie, this is what came back:
/health 200 OK
/ 403 Invalid Host header - possible DNS rebinding attack detected
/ajax-api/2.0/mlflow/experiments/search 403 Invalid Host header - possible DNS rebinding attack detectedBy default MLflow accepts only localhost and private-IP Host headers, and the proxy passes your *.box.sandboxer.com hostname through. Adding --allowed-hosts fixed the UI. But a POST that carried the page's own Origin header was still refused. Browsers send that header on the UI's own POST requests, such as creating an experiment:
POST experiments/create, Origin: https://fond-vernal-walrus-5000.box.sandboxer.com 403 Cross-origin request blockedWith both flags, as in the unit above:
POST experiments/create, Origin: https://fond-vernal-walrus-5000.box.sandboxer.com 200 {"experiment_id": "2"}
POST experiments/create, Origin: https://evil.example 403 Cross-origin request blockedName the exact hostname rather than *. MLflow also has --disable-security-middleware, which turns off host validation, the CORS check and its security headers together. We didn't use it.
4. Log a run from your laptop#
The MLflow client takes a tracking URI and sends plain HTTP requests. It can't carry ?_token=, and it doesn't keep cookies. Without help, every call gets the proxy's 401 page:
MlflowException API request to endpoint /api/2.0/mlflow/experiments/search failed with error code 401 != 200. Response body: '<!doctype html>…MLflow lets you add headers to every request with a request header provider. Registering one in your script is enough, no plugin package needed. This is log_run.py; it reads the full private URL from an environment variable, trades the token for the cookie once and sends the cookie on every call:
# MLFLOW_URL="https://…-5000.box.sandboxer.com?_token=…" python log_run.py
import os, time
from urllib.parse import urlsplit
import requests, mlflow
from mlflow.tracking.request_header.abstract_request_header_provider import RequestHeaderProvider
from mlflow.tracking.request_header.registry import _request_header_provider_registry
full = os.environ["MLFLOW_URL"]
r = requests.get(full, allow_redirects=False) # trade ?_token= for the __sbx_auth cookie
cookie = r.headers["set-cookie"].split(";")[0]
class SandboxerCookie(RequestHeaderProvider):
def in_context(self):
return True
def request_headers(self):
return {"Cookie": cookie}
_request_header_provider_registry.register(SandboxerCookie)
u = urlsplit(full)
mlflow.set_tracking_uri(f"{u.scheme}://{u.netloc}")
mlflow.set_experiment("sandbox-demo")
t0 = time.perf_counter()
with mlflow.start_run(run_name="laptop-run") as run:
mlflow.log_params({"lr": 0.01, "epochs": 5})
for step in range(5):
mlflow.log_metric("loss", round(1 / (step + 1), 3), step=step)
with open("notes.txt", "w") as f:
f.write("trained from a laptop\n")
mlflow.log_artifact("notes.txt")
print(f"run {run.info.run_id} logged in {time.perf_counter() - t0:.2f} s")
got = mlflow.get_run(run.info.run_id)
print("params:", got.data.params)
print("metrics:", got.data.metrics)
print("artifacts:", [a.path for a in mlflow.MlflowClient().list_artifacts(run.info.run_id)])pip install mlflow-skinny==3.17.2 requests
MLFLOW_URL="<full private URL>" python log_run.py… INFO mlflow.tracking.fluent: Experiment with name 'sandbox-demo' does not exist. Creating a new experiment.
…
run e37271f40bd14e7698d401116ab6426b logged in 1.99 s
params: {'lr': '0.01', 'epochs': '5'}
metrics: {'loss': 0.2}
artifacts: ['notes.txt']The 1.99 s covers creating the experiment and the run, logging the params, five separate metric calls, uploading the file and ending the run, each a request through the proxy. Each log_metric is a separate round trip, so in a training loop use mlflow.log_metrics or MlflowClient().log_batch to send several values at once. _request_header_provider_registry is a private name in MLflow; pin the version you tested. The cookie is the route's access token, so keep it out of logs.
The run landed on the sandbox disk:
~/mlflow/artifacts/3/e37271f40bd14e7698d401116ab6426b/artifacts/notes.txt
~/mlflow/mlflow.dbThe same client code works from a training job in another sandbox, or from CI.
5. Open the UI#
Open the full private URL in a browser. The browser stores the cookie, then loads the MLflow UI: experiments, runs, the loss chart, and notes.txt under Artifacts. We checked the pieces with curl and the cookie: the page (<title>MLflow) and its 4.4 MB JavaScript bundle returned 200, and the metric history API returned all five steps:
[(0, 1.0), (1, 0.5), (2, 0.333), (3, 0.25), (4, 0.2)]MLflow also sends X-Frame-Options: SAMEORIGIN, so the UI won't load inside an iframe on another site.
6. What survives stop and resume#
sbx stop $id --wait
sbx resume $idThe stop took 2.2 s and the resume 1.3 s, with memoryRestored: true. MLflow's PID was 1290 before and after, and the old cookie still worked: runs/get returned laptop-run, FINISHED, with both params.
We then forced a resume that boots from disk with sbx resume $id --type default (memoryRestored: false, resumeReason: "resized"). systemd started MLflow again under a new PID. The hosted URL answered 502 for about 6 seconds while the server started, then 200. The laptop script's get_run returned the same params, metrics and notes.txt. Everything lives in ~/mlflow on the sandbox disk, which also makes backups simple: copy that folder. Moving to a smaller size isn't supported yet; to shrink, create a new sandbox and copy ~/mlflow across. See snapshots for when each resume path happens.
7. Clean up#
sbx delete $id --yesFAQ#
Why does the MLflow UI say "Invalid Host header - possible DNS rebinding attack detected"?#
MLflow 3.5 and later rejects Host headers that aren't localhost or a private IP. Behind the hosting proxy, the header is your <subdomain>-5000.box.sandboxer.com name. Add it with --allowed-hosts, or set MLFLOW_SERVER_ALLOWED_HOSTS.
The UI loads but creating or deleting an experiment fails with 403. Why?#
That's MLflow's CORS check. The browser sends the page's own Origin on those POST requests, and MLflow allows only localhost origins by default. Add --cors-allowed-origins https://<subdomain>-5000.box.sandboxer.com.
How does the MLflow client authenticate to a private hosted URL?#
Fetch the full URL once with redirects off, keep the __sbx_auth cookie from Set-Cookie, and send it as a Cookie header on every request through a registered request header provider, as in step 4. MLFLOW_TRACKING_TOKEN sends a bearer token, which the proxy doesn't use.
Do my runs survive when the sandbox auto-stops?#
Yes. Runs are in SQLite and artifacts are files, both on the sandbox disk, which is kept across stop and resume. After a resume that boots from disk, the systemd unit starts MLflow again.
Should I use Postgres instead of SQLite?#
For one server and a few clients, SQLite was enough in our test. MLflow also accepts a postgresql:// backend store if several servers share the data; we didn't test that here.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.