# How to Run an MLflow Tracking Server in a Cloud Sandbox

> 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.

Source: https://sandboxer.com/guides/run-mlflow-tracking-server-in-a-sandbox. Updated 2026-10-27.

**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](https://docs.astral.sh/uv/pip/environments/). Install the full `mlflow` package, which includes the server, into its own venv ([MLflow tracking server docs](https://mlflow.org/docs/latest/self-hosting/architecture/tracking-server)):

```bash
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"
```

```text
mlflow, version 3.17.2
```

`sbx 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](/docs/hosting)). Ours was `fond-vernal-walrus-5000.box.sandboxer.com`. MLflow needs that hostname up front, for reasons shown in step 3:

```bash
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"
```

```text
active
```

- `--host 127.0.0.1` keeps MLflow off the sandbox's public IPv6 address. The hosting proxy reaches it through the `--localhost` relay 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](/docs/long-running#services-that-should-always-run)).

## 3. Host it, and why MLflow needs the two flags

```bash
sbx host $id 5000 --localhost --title MLflow
```

```text
https://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](https://mlflow.org/docs/latest/self-hosting/security/network/)). We first ran the server without `--allowed-hosts` and `--cors-allowed-origins`. With a valid cookie, this is what came back:

```text
/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 detected
```

By 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:

```text
POST experiments/create, Origin: https://fond-vernal-walrus-5000.box.sandboxer.com   403 Cross-origin request blocked
```

With both flags, as in the unit above:

```text
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 blocked
```

Name 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:

```text
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:

```python
# 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)])
```

```bash
pip install mlflow-skinny==3.17.2 requests
MLFLOW_URL="<full private URL>" python log_run.py
```

```text
… 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:

```text
~/mlflow/artifacts/3/e37271f40bd14e7698d401116ab6426b/artifacts/notes.txt
~/mlflow/mlflow.db
```

The 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:

```text
[(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

```bash
sbx stop $id --wait
sbx resume $id
```

The 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](/docs/snapshots#resume) for when each resume path happens.

## 7. Clean up

```bash
sbx delete $id --yes
```

## FAQ

### 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

- [Run JupyterLab on an HTTPS URL](/guides/run-jupyterlab-on-an-https-url)
- [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox)
- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Hosting](/docs/hosting)
