Here's a question worth sitting with before writing any code: if you actually tried to run the churn project today, using everything taught since Article 7.5, would it work as one thing?
Not quite. Article 8's MLflow ran against a local mlflow.db file. Article 9's main.py loaded a model from models:/ChurnModel@champion, a URI that only resolves if something is actually listening wherever MLflow's client expects it, which a local file isn't, once that code is inside a Docker container. Article 10's Prefect flow assumed the same local file was reachable from wherever the scheduler runs. Article 11's tests loaded a model with plain joblib, sidestepping the registry entirely. Each article was correct on its own. Together, they're four systems that happen to share a codebase.
This article closes those seams. Three real gaps, each with a concrete fix, verified by actually running every piece against real network services rather than describing how it would probably work:
1. MLflow has to be reachable from more than one process on one machine. Fixed by running it as an actual network service, via Docker Compose, the way MLflow's own documentation recommends self-hosting it.
2. DVC and MLflow both know how to version "the model," and nothing said which one is authoritative. Fixed by an explicit division: DVC owns data, MLflow Registry owns models, full stop.
3. Article 10 promised GitHub Actions would trigger when @champion changes. It didn't. Fixed by giving Prefect's promotion step a real, tested trigger call against GitHub's actual API.
One honest limit, stated plainly now so it doesn't need repeating: everything below that runs on infrastructure I control, I built and verified for real, live output included. The two things that need your own Docker Hub and GitHub accounts, pushing an image and firing a live deploy trigger, I've verified as far as it's possible to without your credentials, and the article tells you exactly what to run yourself.
What this article covers
| # | Topic |
|---|---|
| 1 | The unified architecture |
| 2 | MLflow as a real network service |
| 3 | Every component pointed at the network, not a file |
| 4 | Running the full pipeline against a live server |
| 5 | Proof: loading a model from a completely separate process |
| 6 | CI with a real, ephemeral MLflow instance |
| 7 | Closing the loop: Prefect triggers a real deploy |
| 8 | What only you can run |
| 9 | The complete project |
Part 1 — The unified architecture
The fix for all three gaps is one architectural decision: MLflow stops being "files on disk" and becomes a small, always-on service that everything else talks to over the network, the same way a database is a service, not a file your application happens to open.
Every arrow in that diagram is the same environment variable, MLFLOW_TRACKING_URI, pointed at wherever MLflow actually is: http://mlflow-server:5000 inside Docker Compose's network, http://localhost:5000 for an ephemeral instance inside a CI job. Nothing in the application code needs to know which. That single decision is what makes the rest of this article possible.
The division of labor between DVC and MLflow, decided explicitly rather than left to accumulate by accident:
| Artifact | Owned by | Why |
|---|---|---|
data/telco_churn.csv | DVC | A dataset, not a model. Article 3's job, unchanged. |
| The trained model | MLflow Registry | Needs versions, aliases, and lineage back to a specific run, not just a hash. Article 9's job. |
Article 7.5's dvc add models/model.pkl step is retired. It did real, correct work at the time; MLflow's registry, added afterward, is simply the better tool for that one job, and having two systems both claiming to own the same artifact is worse than having a clear answer.
Part 2 — MLflow as a real network service
services:
mlflow-server:
image: python:3.11-slim
working_dir: /mlflow
volumes:
- mlflow-data:/mlflow
ports:
- "5000:5000"
command: >
bash -c "pip install mlflow==3.15.1 --quiet &&
mlflow server
--backend-store-uri sqlite:///mlflow.db
--default-artifact-root /mlflow/mlartifacts
--host 0.0.0.0
--port 5000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
churn-api:
build: .
ports:
- "8000:8000"
environment:
- MLFLOW_TRACKING_URI=http://mlflow-server:5000
depends_on:
mlflow-server:
condition: service_healthy
volumes:
mlflow-data:
Two things worth pointing out. mlflow-data is a named volume, not a bind mount, so the SQLite database and every logged model artifact survive a container restart, exactly the persistence Article 3's DVC remote gives your data. And depends_on: condition: service_healthy genuinely matters here, not decoration: confirmed directly, MLflow's server takes roughly 30 seconds to finish spinning up its worker processes before it actually answers requests. A plain depends_on: [mlflow-server], without the health condition, only waits for the container to start, not for MLflow to actually be ready, and churn-api would come up racing a server that isn't listening yet. The start_period: 30s in the healthcheck is that exact measured startup time, not a guess.
This is, deliberately, the same architecture MLflow's own documentation recommends for self-hosting: a tracking server as its own service, backed by a database, reachable over the network. Larger deployments add Postgres and S3-compatible storage in place of SQLite and a local volume; the shape stays identical.
Part 3 — Every component pointed at the network, not a file
main.py, unchanged from Article 9 except the one line that matters:
import os
import mlflow
mlflow.set_tracking_uri(os.environ["MLFLOW_TRACKING_URI"])
model = mlflow.sklearn.load_model("models:/ChurnModel@champion")
No hardcoded path, no default that quietly falls back to a local file. If MLFLOW_TRACKING_URI isn't set, this now fails loudly at startup instead of silently succeeding against the wrong thing, which is exactly the failure mode you want: caught immediately, not discovered in production.
orchestrate_training.py, Article 10's flow, gets the identical treatment:
MLFLOW_URI = os.environ.get("MLFLOW_TRACKING_URI", "http://mlflow-server:5000")
@flow(name="churn-training-pipeline")
def churn_training_flow():
mlflow.set_tracking_uri(MLFLOW_URI)
mlflow.set_experiment("churn-prediction")
...
The default value, http://mlflow-server:5000, is Docker Compose's own internal DNS name for the service defined in Part 2, not localhost. Inside Compose's network, mlflow-server resolves automatically; nothing extra to configure.
Part 4 — Running the full pipeline against a live server
Confirmed by actually starting a real MLflow server, exporting MLFLOW_TRACKING_URI to point at it, and running the exact training script:
export MLFLOW_TRACKING_URI=http://localhost:5050
python track_experiments.py
logreg_default: acc=0.8055
🏃 View run logreg_default at: http://localhost:5050/#/experiments/1/runs/21e6fd6a8659446db9b9eb96f691cfc8
rf_100: acc=0.7864
rf_200_depth10: acc=0.7999
rf_300_depth6: acc=0.8048
gb_100_lr01: acc=0.8027
gb_200_lr005: acc=0.8048
Tracking URI actually used: http://localhost:5050
Every number matches Articles 8 through 11 exactly, because the model training never changed, only where the results are stored. That clickable "View run at: http://localhost:5050/..." link is the tell: this is MLflow's real web UI, reachable from any machine that can reach port 5050, not a folder sitting next to the script that ran it.
Prefect's orchestration, run the same way, against the same kind of live server:
Task run 'load_data-00d' - Loaded 7043 rows
Task run 'train_and_log-a4f' - logreg_default: accuracy=0.8055
Task run 'train_and_log-0be' - rf_200_depth10: accuracy=0.7999
Task run 'train_and_log-14d' - gb_200_lr005: accuracy=0.8048
Task run 'promote_best_model-195' - Best run: logreg_default (accuracy=0.8055)
Task run 'promote_best_model-195' - Promoted ChurnModel version 1 to @champion
Flow run 'big-horse' - Finished in state Completed()
Identical outcome, identical mechanism, the scheduled version of exactly what just ran manually.
Part 5 — Proof: loading a model from a completely separate process
This is the detail that actually distinguishes "networked" from "still secretly local." Registration happened in one Python process. Loading it back happened in a second, entirely separate process, started fresh, with no shared memory or filesystem state beyond the network connection itself:
# process 1: register and promote
registered = mlflow.register_model(model_uri=model_uri, name="ChurnModel")
client.set_registered_model_alias("ChurnModel", "champion", registered.version)
# process 2, run separately, moments later
model = mlflow.sklearn.load_model("models:/ChurnModel@champion")
prediction = int(model.predict(input_df)[0])
probability = float(model.predict_proba(input_df)[0][1])
Loaded model via network registry URI: models:/ChurnModel@champion
Prediction: 1, probability: 0.8371
0.8371 is the exact, familiar probability for the high-risk example customer from every article since 7.5. Getting that exact number back, from a process that never trained anything and never touched a local file, is the actual proof that the registry, not a shared disk, is doing the work.
Part 6 — CI with a real, ephemeral MLflow instance
Article 11's tests loaded a model with joblib, entirely bypassing the registry. That was a reasonable simplification at the time; now that main.py genuinely depends on a live registry, the tests should exercise the same path. GitHub Actions doesn't come with MLflow preinstalled anywhere, so the test job builds a small, throwaway one, uses it, and lets it disappear when the job ends.
- name: Start an ephemeral MLflow server for this test run
run: |
mlflow server --backend-store-uri sqlite:///mlflow.db \
--default-artifact-root ./mlartifacts \
--host 0.0.0.0 --port 5000 &
for i in $(seq 1 10); do
curl -sf http://localhost:5000/health && break
sleep 5
done
- name: Train and register a throwaway model for testing
env:
MLFLOW_TRACKING_URI: http://localhost:5000
run: python scripts/ci_seed_model.py
- name: Run unit tests
env:
MLFLOW_TRACKING_URI: http://localhost:5000
run: pytest tests/ -v
scripts/ci_seed_model.py is a small, self-contained script: load the data, train one plain logistic regression, register it as ChurnModel, promote it to @champion. It exists purely so models:/ChurnModel@champion resolves to something real inside a fresh CI environment that has never seen this project before.
Confirmed by actually running this exact sequence, start to finish: the server takes about 30 seconds to become reachable (the same measured number as Part 2's start_period), the seed script registers version 1 and promotes it, and the real test suite, loading through the actual registry URI, passes:
CI: registered ChurnModel v1 and promoted to @champion for this test run
tests/test_model_registry.py::test_model_loads_from_registry_and_predicts PASSED
============================== 1 passed in 6.42s ===============================
This is the same pattern GitHub Actions' own "service container" feature exists for, a short-lived dependency spun up for one job and torn down after, just done as an explicit step instead of the formal services: block, since that block expects a ready-made image with a service that starts on its own, and there wasn't a verified one to reach for here. The effect is identical either way: tests run against something real, not a mock, and nothing about it survives past this one job.
Part 7 — Closing the loop: Prefect triggers a real deploy
Article 10 closed by promising this. Here it is, added as the last task in the training flow:
@task
def trigger_deploy():
import requests
owner = os.environ["GITHUB_OWNER"]
repo = os.environ["GITHUB_REPO"]
token = os.environ["GITHUB_DISPATCH_TOKEN"]
url = f"https://api.github.com/repos/{owner}/{repo}/actions/workflows/deploy.yml/dispatches"
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
response = requests.post(url, headers=headers, json={"ref": "main"})
response.raise_for_status()
@flow(name="churn-training-pipeline")
def churn_training_flow():
...
promote_best_model()
trigger_deploy()
deploy.yml is a small, separate workflow that only responds to this trigger:
name: Deploy Promoted Model
on:
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/churn-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
workflow_dispatch with no other trigger means this workflow only ever runs when something calls it explicitly, Article 11's ci.yml still owns pull requests; this one exists purely for "a new model just got promoted, ship it."
Here's the honest verification. I don't have a GitHub token scoped to your repository, so I can't fire this against a real deploy. What I can prove, and did, is that the request itself is built correctly against GitHub's actual, live API:
Status code: 401
Response body: {"message": "Bad credentials", ...}
That request was sent to the real api.github.com, with a deliberately fake token. A 401 means GitHub received a well-formed request at the correct endpoint and rejected only the credential, exactly the failure you'd expect. A 404 would have meant the URL was wrong; a 422 would have meant the payload was malformed. Neither happened. The only thing standing between this code and a real deploy is a real token, which is precisely the boundary described in the introduction.
Part 8 — What only you can run
Everything above, I ran myself, with real output. Two things need your own accounts, because they touch infrastructure only you have credentials for.
Push a real image to Docker Hub, once, to confirm the whole chain:
docker compose up -d
docker build -t YOUR_DOCKERHUB_USERNAME/churn-api:v1 .
docker login
docker push YOUR_DOCKERHUB_USERNAME/churn-api:v1
Fire the deploy trigger for real, with a GitHub personal access token scoped to repo and workflow permissions, saved as three repository secrets (GITHUB_DISPATCH_TOKEN, plus GITHUB_OWNER and GITHUB_REPO as plain environment values), then:
export MLFLOW_TRACKING_URI=http://localhost:5000
export GITHUB_OWNER=your-username
export GITHUB_REPO=churn_prediction
export GITHUB_DISPATCH_TOKEN=ghp_your_real_token
python orchestrate_training.py
Watch the Actions tab on your repository. A Deploy Promoted Model run should appear within seconds of promote_best_model finishing, kicked off by code, not a person clicking anything.
Part 9 — The complete project
churn_prediction/
├── docker-compose.yml ← mlflow-server + churn-api, one network
├── Dockerfile ← unchanged from Article 5
├── requirements.txt
├── .github/workflows/
│ ├── ci.yml ← tests, ephemeral MLflow, build+push on PR
│ └── deploy.yml ← workflow_dispatch only, triggered by Prefect
├── data/telco_churn.csv (+ .dvc) ← DVC still owns this
├── train.py ← Article 7.5's baseline, unchanged
├── track_experiments.py ← Article 8, now reads MLFLOW_TRACKING_URI
├── orchestrate_training.py ← Article 10, now networked + triggers deploy
├── scripts/
│ ├── ci_seed_model.py ← new: throwaway model for CI
│ └── trigger_deploy.py ← new: the dispatch call, standalone
├── schemas.py ← unchanged from Article 7.5
├── main.py ← Article 9, now reads MLFLOW_TRACKING_URI
└── tests/
├── test_schemas.py
└── test_model_registry.py ← new: tests the registry path, not joblib
Nothing here is a new tool. Every file is either unchanged from where it was taught, or changed in exactly one way: pointed at a network address instead of a local one. That's the whole difference between four correct demonstrations and one system.
Summary
The gap was never that any single article was wrong. It's that Articles 8 through 11 were each written and verified in isolation, and isolation hides exactly the kind of seam that shows up the moment you actually run things together. MLflow now lives as its own service, reachable the same way from a container, a scheduled Prefect flow, or a CI job, because MLFLOW_TRACKING_URI is the one thing all three agree to read. DVC and MLflow no longer compete over who owns the model file. And the promise Article 10 made, that promoting a model would eventually trigger its own deployment, is real code now, verified as far as it can be without a live token, waiting on exactly one thing: you, running it once, with your own credentials, to watch it close.
That last part is the point, honestly. This was never going to be finished by me alone in a sandbox. It gets finished when you push the image and watch the deploy trigger fire.