02. Local platform foundation

The whole platform stood up on a laptop: a guided tour of the demo Compose stack, the Docker concepts it needs, the run/trigger/reset workflows, and the mapping from every local service to its future Azure counterpart.

This part opens Part I: we stand up the whole platform locally with Docker Compose and use it for every feature that follows. No Azure account is needed for Part I; everything here maps one-to-one onto the Azure footprint built in Part II.

What we stand up

Chapter 01 defined the platform as four planes plus a thin dashboard. This chapter makes all five real on a laptop: a single file, projects/ml-platform/demo/docker-compose.yml, describes nine containers that together form the entire platform. No Azure account is involved anywhere in Part I.

Plane Local containers Future Azure counterpart
Execution train, batch (run once, exit), runner Azure Container Apps Jobs
Model lifecycle mlflow + minio + minio-init MLflow ACA App + Blob Storage
Operational state postgres (the results database) Azure Database for PostgreSQL
Serving serving Serving ACA App
Dashboard dashboard Dashboard ACA App

Building the proof of concept locally first buys three things: iteration speed (a full-stack restart takes minutes, not a deployment cycle), zero cloud cost while learning, and time for features to crystallize against stable interfaces before infrastructure decisions harden. The last point matters most. Chapters 03-07 build every feature against this exact stack, and chapter 08 writes down the contract that lets Part II swap each container for its Azure counterpart without touching feature code.

Docker Compose in ten minutes

Docker Compose reads one declarative YAML file describing a set of services and creates all of them with a single command: it builds or pulls the images, attaches every container to one private network, and starts everything in dependency order. Five ideas cover everything this stack relies on.

An image is a template; a container is a process. An image is an immutable snapshot built from a Dockerfile; a container is a running process created from an image. The relation is class to instance: one image, many possible containers. Some services use ready-made images pulled from a registry (postgres:16-alpine); others build from this repository’s own Dockerfiles (build: entries pointing at src/*/Dockerfile).

A service is also a hostname. All containers join one network in which the service name resolves as DNS. This single fact explains most of the configuration: jobs set MLFLOW_TRACKING_URI=http://mlflow:5000 because, inside the network, the tracking server literally is mlflow. Browser access from the laptop travels the other route, through published ports: the same UI is http://localhost:15000.

Concept Where this stack uses it Meaning
environment POSTGRES_PASSWORD: demo-password Configuration enters a container as environment variables; these images read no config files
named volume minio-data:/data Docker-managed disk that outlives its container; deleted only by docker compose down -v
bind mount ./minio-init.sh:/minio-init.sh:ro A host file mounted into the container, read-only (:ro); used here for init scripts
port mapping "${DEMO_MLFLOW_PORT:-15000}:5000" Publishes the container’s port 5000 on the host as 15000 (an env-overridable default). Container-to-container traffic never touches these mappings
healthcheck pg_isready -U mlplatform -d mlflow A command Docker reruns to decide whether a service is genuinely ready, not merely started
depends_on condition: service_healthy, service_completed_successfully Start ordering plus waiting: start me after Postgres passes its healthcheck, or after minio-init exited with code 0

Two waiting strategies coexist in the file, and both recur later. Long-lived services gate their dependents on service_healthy; one-shot services (the init script, the training job) use service_completed_successfully, meaning “ran to completion and exited cleanly”. That second guarantee is precisely what batch scoring needs from training.

The stack, service by service

Nine services make up the file. Six build images from this repository; their context: .. sets the build root at projects/ml-platform/, which is why those Dockerfiles can COPY src/ml_platform/... into the images. Three pull off-the-shelf images. Each subsection below states the role, the image, the configuration that matters, how readiness is signaled, and the Azure counterpart the setup rehearses.

Postgres: operational state and registry metadata

One postgres:16-alpine container hosts two databases. mlflow (created from POSTGRES_DB on first boot) backs the model registry’s metadata; results backs operational state. The results database materializes through a bind mount: demo/postgres/init/01-create-results.sql lands in /docker-entrypoint-initdb.d/, a directory the Postgres image executes once, when its data volume is empty. The schema is one table whose shape much of this course writes into:

CREATE TABLE IF NOT EXISTS results (
    id           TEXT PRIMARY KEY,
    parent_id    TEXT NULL REFERENCES results(id),
    name         TEXT NOT NULL,
    status       TEXT NOT NULL,
    output       JSONB NULL,
    triggered_by TEXT NOT NULL
);

The self-referencing parent_id is how batch fan-out gets expressed without a broker: one parent row, many child rows (chapter 04).

Readiness is the pg_isready healthcheck (every 5 s, up to 20 tries), and data persists in the postgres-data named volume. Two details matter for Part II. First, no port is published: Postgres is reachable only from inside the Compose network, exactly as a production database should be. Second, the password sits in plaintext environment variables, which is acceptable only because the network boundary is the laptop; chapter 10 replaces it with short-lived Entra tokens obtained through a managed identity.

MinIO and minio-init: artifact storage

Model artifacts need object storage with an S3-compatible API; minio/minio provides it in a container, persisting to the minio-data volume. Only the web console is published (19001:9001) for browsing buckets in a browser; the S3 API itself stays on the internal network at http://minio:9000.

The companion minio-init service introduces a recurring pattern: a container that exists to do one thing and exit. Its image is minio/mc, the MinIO CLI, with the entrypoint overridden by a bind-mounted script that retries mc alias set until MinIO answers, then creates the mlflow bucket idempotently (mc mb --ignore-existing). Notice the waiting strategy: MinIO declares no healthcheck, so service_healthy has nothing to wait on, and the script simply polls on its own. When readiness cannot be declared in the Compose file, it can still be implemented inside the container.

On Azure, Blob Storage takes over and this bootstrap disappears entirely: Terraform creates the account and container up front, and a managed identity replaces access keys.

MLflow: the model lifecycle plane

Built from demo/mlflow/Dockerfile, a thin wrapper around the upstream server: a pinned mlflow install plus an entrypoint that assembles --backend-store-uri from the PG* variables and runs mlflow server --serve-artifacts. The environment falls into three groups:

  • PGHOST, PGUSER, and friends point the metadata backend at the mlflow database inside the Postgres container.
  • MLFLOW_ARTIFACTS_DESTINATION=s3://mlflow/artifacts with MLFLOW_S3_ENDPOINT_URL=http://minio:9000 routes artifacts into the bucket created above; the AWS_* variables carry MinIO credentials.
  • MLFLOW_ALLOWED_HOSTS=* relaxes host checking for local browsing.

Ordering expresses the dependency chain precisely: depends_on requires Postgres service_healthy and minio-init service_completed_successfully, and the image’s own HEALTHCHECK curls /health so downstream services can wait on it in turn. The UI publishes at http://localhost:15000.

The Azure counterpart is the same server as an ACA App, built from src/mlflow_app/Dockerfile, pointed at the production Postgres and a Blob container. Only credential delivery changes: environment-variable passwords become managed-identity tokens fetched at startup.

train and batch: the execution plane as one-shot jobs

Both images come straight from the repository, src/train_job/Dockerfile and src/batch_job/Dockerfile; they are exactly the images Part II schedules as ACA Jobs. Each also demonstrates the most important container idea in practice: a container is just a process. These services start, run their entrypoint, exit 0, and stop; Compose records them as completed.

On a clean volume, train downloads the public wine-quality CSV (first boot needs internet access), trains, registers wine-quality version 1 in the MLflow registry, and leaves a results row behind. batch waits for train to complete successfully, loads models:/wine-quality/1, scores the same CSV, and writes its own results rows.

One YAML detail carries real weight here: the two jobs share their configuration through an anchor (&job-environment) that batch merges back in with <<:. Everything shared (tracking URI, Postgres coordinates, TRIGGERED_BY, IMAGE_DIGEST) is written once. That is also a hint about the production shape: a job definition is largely its environment block.

runner: a local stand-in for the Jobs API

In production, the dashboard starts jobs through the Azure Container Apps Jobs API. Locally something must answer those calls, and giving a web application the Docker socket would grant far more authority than it needs. runner is a small FastAPI service that embeds the real train.py, evaluate.py, and score.py entrypoints and starts them as subprocesses after validating scalar parameters. Each accepted request gets its own execution ID and background process.

Its healthcheck polls /healthz, and it publishes no host port; only the dashboard reaches http://runner:8090 on the internal network. Part II removes this adapter and sets TRIGGER_BACKEND=aca, while keeping the workload commands and request parameters.

serving and dashboard: the long-lived apps

These stay up for the life of the stack and listen on internal port 8080.

serving pins MODEL_NAME=wine-quality and an exact MODEL_VERSION, loads that registry version at startup, and exposes /healthz, /readyz, and POST /v1/predictions. It waits for bootstrap training because its initial version must exist. The host publishes it at http://localhost:18080.

dashboard reads results into a run catalog, links to the browser-reachable MLflow UI, and exposes train, eval, and batch triggers. TRIGGER_BACKEND=local sends launches to the runner; the Azure value aca sends them to ACA Jobs. The host publishes it at http://localhost:18000.

Local mode treats the laptop boundary as trusted and records a simulated caller name. Azure adds platform authentication plus an application-level operator group check; chapter 06 defines that boundary in detail.

The transfer map

Production Local POC stand-in
Azure Database for PostgreSQL postgres container (mlflow + results databases)
Blob Storage minio + minio-init containers
MLflow ACA App mlflow container
ACA train/eval/batch Jobs one-shot services and the runner commands
Serving ACA App serving container
Dashboard ACA App + Easy Auth dashboard container behind the laptop boundary
Log Analytics Compose service logs

Part II provisions the managed resources, then maps the same images and entrypoints onto them. The table is a transfer guide, not a claim that the local runner emulates Azure internals.

One image, two worlds

The demo does not build throwaway workload images. Its Compose build entries point at the same Dockerfiles that the Azure modules deploy by digest: train_job, batch_job, serving_app, and dashboard. The train image also contains the shared LLM registration/evaluation entrypoints used by the local runner and the optional cloud LLM Jobs.

Nothing in that code knows where it is. Each image asks questions of its environment (MLFLOW_TRACKING_URI: which MLflow? PGHOST and RESULTS_DB: which results store? MODEL_VERSION: which model?), and the two worlds answer differently: Compose with service names and demo credentials, Azure with app URLs and managed identities. Because every difference enters through these variables, the workload images run unmodified in both environments.

The validation mechanisms intentionally remain separate. demo/golden_path.py drives the local Compose runner; deploy/smoke-tests.sh and deploy/smoke-tests.ps1 drive ACA’s execution API. They preserve the same behavioral assertions without pretending that a local HTTP runner and the Azure control plane are the same system.

Run it

Everything happens from projects/ml-platform/demo/. The first up builds six images (a few minutes; layer caching makes later boots much faster) and pulls three more. On clean volumes the startup order plays out exactly as the depends_on graph promises: Postgres turns healthy, minio-init creates the bucket and exits, MLflow comes up, train registers wine-quality version 1, batch scores it, and finally serving and dashboard turn healthy. The training data is fetched from the public MLflow repository, so the very first boot needs internet access.

cd projects/ml-platform/demo
docker compose up --build

Logs stream to the terminal in dependency order; Ctrl-C stops the containers but keeps their volumes, so the next up resumes with accumulated model versions rather than resetting. When the stack is up:

Surface URL
Dashboard http://localhost:18000
Serving readiness http://localhost:18080/readyz
Prediction endpoint POST http://localhost:18080/v1/predictions
MLflow UI http://localhost:15000
MinIO console http://localhost:19001 (minio / minio-password)

If a host port is taken, any mapping can be overridden at start time, for example DEMO_DASHBOARD_PORT=8000 docker compose up --build.

A first prediction against the freshly registered version takes eleven physicochemical features in the schema order fixed in chapter 03; the response echoes the predicted quality values and the model_version that served them:

curl -X POST http://localhost:18080/v1/predictions \
  -H 'content-type: application/json' \
  -d '{"instances":[[7.0,0.27,0.36,20.7,0.045,45.0,170.0,1.001,3.0,0.45,8.8]]}'

Drive it

The dashboard’s Run training and Run batch scoring buttons issue exactly the requests shown below; the buttons are UI, the parameterized API is the interface. GET /api/jobs lists each job’s accepted parameters with copy-ready examples, and the trigger path is /api/runs/<job>/trigger with values nested under "parameters". Parameters are validated against a per-job allowlist before anything starts (unknown keys are rejected). Accepted requests run independently, even when another execution of the same job type is still running: each receives its own execution id and parameter set, so two training runs with different settings can proceed concurrently. In this local runner, parallelism = 1 means one replica per execution, not one execution per job type.

Because jobs execute in the background, the trigger response returns immediately with an execution id. In the local POC that id is deliberately also the results-DB row id, so polling takes one value: request GET /api/results/<execution> until the recorded status leaves RUNNING. Retraining registers the next version number; batch scoring pins whichever version its parameters name.

# Discover job names, accepted parameters, and copy-ready examples.
curl http://localhost:18000/api/jobs

# Train and register a new model version.
curl -X POST http://localhost:18000/api/runs/train/trigger \
  -H 'content-type: application/json' \
  -H 'X-MS-CLIENT-PRINCIPAL-NAME: demo-user' \
  -d '{"parameters":{"alpha":0.25,"l1_ratio":0.8,"random_state":7}}'

# Score the CSV with registered model wine-quality version 1.
curl -X POST http://localhost:18000/api/runs/batch/trigger \
  -H 'content-type: application/json' \
  -d '{"parameters":{"model_version":"1","chunk_size":500}}'

# Poll the returned execution until its status leaves RUNNING.
curl http://localhost:18000/api/results/<execution>

Reset and hygiene

Named volumes remember everything: model versions, results rows, MinIO objects. That persistence is useful mid-development and wrong for demonstrations, where the narrative expects wine-quality version 1 to be the current version. docker compose down -v stops the stack and deletes its volumes, returning every surface to empty; the next up replays first boot from scratch and registers version 1 again. Both commands run from projects/ml-platform/demo/.

docker compose down -v
docker compose up --build

One hygiene rule to keep from this chapter: the demo credentials (demo-password, minio-password) exist to satisfy images that demand passwords, not to secure anything. The security boundary of this stack is the laptop itself; production replaces these values wholesale with managed identities in Part II.

The stack is now the workshop for the rest of Part I. Chapters 03-07 build reproducible training, batch workflows, online serving, observability, and LLM packaging directly against these nine services, and chapter 08 writes down the contract that makes handing the result to Azure mechanical.

Back to top