Containers Done Right
Context
A production image installs only what the running service imports, builds in layers ordered from the part that changes least to the part that changes most, compiles its Python at build time and runs as an ordinary user. Beside it, compose.yaml declares the service, its database, the network between them, the volume that keeps the rows and the order the two start in, so docker compose up replaces every docker run.
You meet this every time you change a line of app.py and rebuild, every time a new model version ships, and every time a teammate clones the project and needs the whole stack in one command. The next lesson's CI pipeline rebuilds the image on every push to main and every pull request, so the cost of a rebuild becomes the cost of every change.
Lesson 6.3.1's Dockerfile copies the whole project before installing it, so a one-line edit reinstalls all 90 packages, and it carries full MLflow, a web server and a database layer the service never imports, all run as root. Lesson 6.3.2's two containers are started by hand, the password typed into the command and the order left to memory, and a database started second, or with the wrong password, takes the service down at startup.
Goal
- Split the dependencies so the service installs
mlflow-skinnyandskopsand full MLflow sits in the dev group, and read the 92 to 56 pins and theNo module named 'skops'of leaving out skops. - Write a Dockerfile that installs the lock first with
--no-install-project, then the code, then the model, withUV_COMPILE_BYTECODEand a non-root user, and predict which steps a change to each part reruns. - Measure build time and image size with and without a uv cache mount, and trace the no-mount image's extra 355 MB to the layer that holds it.
- Retrain and re-export after the split, and diagnose the dependency-mismatch warning a model logged under the old environment prints.
- Run the service and PostgreSQL with
docker compose, the password from.env, a named volume, apg_isready -h localhosthealthcheck andservice_healthy, and distinguishdownfromdown -v.
Prerequisites
None of this is required, and the lesson stands on its own. Both are here for anyone who wants Compose end to end, or uv's own Docker advice, first.
- YouTube - "Ultimate Docker Compose Tutorial" by TechWorld with Nana. An hour on what Compose is, how a
docker runmaps onto a Compose definition,docker compose upanddownagainststartandstop, a three-service demo, and variables in a Compose file. - uv documentation - "Using uv in Docker", Optimizations. Compiling bytecode, the cache mount with
UV_LINK_MODE=copy, and intermediate layers withuv sync --no-install-project, the three techniques this lesson's Dockerfile combines.
Table of Contents
- The Service's Dependencies and the Tools'
- A Dockerfile in Layers
- The Cache Mount
- Retraining After the Split
- docker compose and the .env File
- Healthchecks, depends_on and the Named Volume
Theory
Everything runs in ~/dev/listings-service as Lesson 6.3.2 left it, with (bootcamp) deactivated as Module 6's setup page says: the registry holds listings-price versions 1 and 2 with champion on version 1, model/ holds version 1 as Lesson 6.3.1 exported it, and app.py logs every price to PostgreSQL when DATABASE_URL is set. The lesson changes pyproject.toml, uv.lock, Dockerfile, .gitignore and .dockerignore, adds compose.yaml and a .env that is never committed, and keeps every probe or variant file in ~/dev/listings-probes. Build times depend on your network and machine, and image ids, timestamps and IP addresses differ on every run.
Lesson 6.3.2 removed its containers and its network, so ports 8000 and 5432 are free for this lesson's stack. Keep that lesson's image under a tag of its own for the comparisons below:
docker tag listings-service listings-service:6.3.2It prints nothing, and listings-service:6.3.2 keeps the old image when the next build takes the name.
The Service's Dependencies and the Tools'
What. A project's dependencies are what the running service imports, and its dev group, Lesson 6.1.2's, is what you run beside it. mlflow-skinny is MLflow's lightweight release: the same mlflow package, model loading and logging, the registry client and tracking, without the 34 packages the full release adds, Flask and Gunicorn for its server and UI, SQLAlchemy and Alembic for its database store, pyarrow, matplotlib and others. The service gets mlflow-skinny and skops, and full mlflow moves to dev, for train.py, promote.py and mlflow artifacts download.
Why. uv sync --no-dev, the Dockerfile's install, puts every main dependency into the image, so with mlflow among them the image carries a tracking server and a plotting library to run one function, mlflow.pyfunc.load_model. Each package costs build time, image size and one more release that can break the build.
When. Whenever a library the service imports drags in a toolkit only the people who change the code need.
This subtitle installs two packages into the service's own list, mlflow-skinny 3.17.1 and skops 0.16.0, and moves mlflow 3.17.1 into the dev group, each at the version the lock already holds. First the count Lesson 6.2.1 met in its model's requirements.txt, now with the database driver in it. uv export prints the lock as a requirements list, the command MLflow runs when it logs a model, --no-hashes drops the checksum lines, and grep -c "==" counts the pins:
uv export --no-dev --no-hashes | grep -c "=="Resolved 100 packages in 1ms
92The first line is uv's, on standard error. 92 pins are what the image installs today. Remove MLflow and add the lightweight release alone:
uv remove mlflow
uv add mlflow-skinny==3.17.1The count line of each:
Uninstalled 62 packages in 81ms
Installed 25 packages in 14msuv remove deleted the pin and uninstalled MLflow with everything only it needed, mlflow-skinny and skops among the 62, and uv add brought back the lightweight release and what it needs. The service's one MLflow call, against the folder the image serves:
uv run python -c "import mlflow; mlflow.pyfunc.load_model('model')"The first line of what it prints, one line of the list under it, and the end of the traceback:
2026/11/03 15:23:28 WARNING mlflow.utils.requirements_utils: Detected one or more mismatches between the model's dependencies and the current Python environment:
- skops (current: uninstalled, required: skops==0.16.0)
File "/home/goku/dev/listings-service/.venv/lib/python3.12/site-packages/mlflow/sklearn/__init__.py", line 564, in _load_model_from_local_file
import skops.io
ModuleNotFoundError: No module named 'skops'model.skops is saved in skops format, Lesson 6.2.1's, and Lesson 6.2.2's uv tree --invert --package skops showed skops arriving as a dependency of mlflow. The lightweight release does not depend on it, so the loader fails at its import skops.io, the same borrowed-dependency failure Lesson 6.2.3's FastAPI would have met. The project imports skops through MLflow and never by name, and it still has to declare it:
uv add skops==0.16.0
uv add --dev mlflow==3.17.1The first command's last four lines and the second's count line:
~ listings==0.1.0 (from file:///home/goku/dev/listings-service)
+ prettytable==3.18.0
+ skops==0.16.0
+ wcwidth==0.9.2
Installed 35 packages in 32msskops arrives with the two packages it needs, and full MLflow, now in the dev group, puts back the 34 it adds on top of the lightweight release, the same 100 locked packages as before the lesson. pyproject.toml now reads, in its two lists:
dependencies = [
"fastapi==0.150.0",
"mlflow-skinny==3.17.1",
"numpy==2.5.2",
"pandas==3.0.5",
"psycopg[binary]==3.3.6",
"pydantic==2.14.1",
"scikit-learn==1.9.1",
"skops==0.16.0",
"uvicorn==0.54.0",
]
[dependency-groups]
dev = [
"httpx==0.28.1",
"mlflow==3.17.1",
"pytest==9.1.1",
]The two counts, the service's and the whole project's:
uv export --no-dev --no-hashes | grep -c "=="
uv export --no-hashes | grep -c "=="Resolved 100 packages in 1ms
56
Resolved 100 packages in 1ms
99The service now installs 56 pins instead of 92, and your machine still has all 99, so train.py, promote.py and the tests work exactly as before. The lock's diff is 8 lines, all in the project's own entry, because every package was locked already.
DON'T:
- DON'T leave out
skopsbecause no file imports it. The loader imports it, and without itload_modelstopped atNo module named 'skops'. - DON'T try the service's set with
uv sync --no-devin the project folder. It uninstalled 40 packages,mlflowamong them, and since both MLflow releases install into onemlflowfolder, the folder went too andimport mlflowfailed withModuleNotFoundErrorwhilemlflow-skinnywas still installed.uv syncput it back, and the image, built from nothing, never meets this. - DON'T move
mlflowback intodependenciesto cure a missing module. Find the one package the loader needs and declare that.
A Dockerfile in Layers
What. Every Dockerfile instruction produces a layer, and Docker reuses a cached layer while the instruction, every file it copies and every layer before it are unchanged. The first changed instruction and every one after it rerun. A layered Dockerfile orders the copies by how often they change: the lock, when a dependency does, the code, on every commit, the model, on every release.
Why. Lesson 6.3.1's COPY . . comes before uv sync, so any edit reaches the install step, which reinstalls every package. Ordered by change, a code edit reruns a sync of one package, the project, and a new model a copy of 233 kB.
When. For every image you will rebuild, which is every image a project ships.
Lesson 6.3.1's Dockerfile, built at Lesson 6.3.2's state and rebuilt after one line of the project changed, the docstring of app.py, took 57.4 seconds here, and its log shows why:
#10 [stage-0 4/5] COPY . .
#11 [stage-0 5/5] RUN uv sync --locked --no-dev
#11 27.60 Prepared 90 packages in 27.15s
#11 27.86 Installed 90 packages in 258msCOPY . . copied a changed file, so the layer after it reran, and uv downloaded and installed all 90 packages again. The new Dockerfile, in full:
FROM python:3.12.8-slim
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
COPY README.md ./
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
COPY model ./model
RUN useradd --create-home --uid 1001 app
USER app
EXPOSE 8000
CMD ["uvicorn", "listings.app:app", "--host", "0.0.0.0", "--port", "8000"]FROM, the uv copy, WORKDIR, EXPOSE and CMD are Lesson 6.3.1's. ENV sets three variables for every later step and for the container: UV_COMPILE_BYTECODE=1 makes uv sync compile every installed .py file to bytecode, the .pyc files Python otherwise writes the first time it imports a module, UV_LINK_MODE=copy is the next subtitle's, and PATH is Lesson 6.3.1's. The first copy takes only the two files the dependencies are decided by, and --no-install-project installs every locked package except the project, whose code is not in the image yet. A trailing backslash continues an instruction on the next line, and --mount is the next subtitle's. README.md comes before the code because pyproject.toml names it as the project's readme and building the project reads it, and the second uv sync adds the one thing missing, the project. The model comes last. useradd --create-home --uid 1001 app creates an ordinary user app with a home folder and the user id 1001, and USER app runs every later step and the container as that user instead of root.
Build it, under the tag Lesson 6.3.1 used:
time docker build -t listings-service .The first build took 67.5 seconds here: 47.4 in the first uv sync, 43.3 of those downloading the 55 packages and 2.4 compiling 6,491 files, and most of the rest writing the layers out. Change one line of app.py, its docstring, and rebuild:
time docker build --progress=plain -t listings-service .The steps from the code copy on, and the time:
#13 [stage-0 7/10] COPY src ./src
#13 DONE 0.1s
#14 [stage-0 8/10] RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev
#14 0.247 Resolved 100 packages in 1ms
#14 0.251 Building listings @ file:///app
#14 0.253 Built listings @ file:///app
#14 0.256 Prepared 1 package in 6ms
#14 0.258 Installed 1 package in 1ms
#14 0.469 Bytecode compiled 6491 files in 210ms
#14 0.469 + listings==0.1.0 (from file:///app)
#14 DONE 0.5s
#15 [stage-0 9/10] COPY model ./model
#15 DONE 0.1s
#16 [stage-0 10/10] RUN useradd --create-home --uid 1001 app
#16 DONE 0.4sreal 0m2.572s
user 0m0.077s
sys 0m0.068sSteps 2 to 6 print CACHED above these lines, the copy of src is the first that changed, and the sync after it rebuilt one package, so the same edit that cost 57.4 seconds under Lesson 6.3.1's file cost 2.6. Undo the docstring edit and the next build reuses the earlier layers again. Then run the image the way Lesson 6.3.1 did, and time the wait for the first answer:
docker run -d --name listings-service -p 8000:8000 listings-service
time (until curl -s http://127.0.0.1:8000/health > /dev/null; do sleep 0.2; done)
docker exec listings-service whoami
docker exec listings-service touch /app/model/xreal 0m3.824s
user 0m0.072s
sys 0m0.107s
app
touch: cannot touch '/app/model/x': Permission denieddocker run -d printed the container id above these lines. whoami answers app, and app cannot write into /app, whose files root copied in, so a process an attacker takes over through the service can read the model and cannot replace it. Three starts of this image answered in 3.3 to 5.3 seconds, from docker run to the first 200, against 7.1 to 8.4 for listings-service:6.3.2. A variant without UV_COMPILE_BYTECODE=1 built a 702 MB image against 894 MB and started in 6.8 to 7.8 seconds: every container starts from the image's files, so without .pyc files each start compiles every module it imports, and app cannot even save the result. Leave the container running for the fourth subtitle.
DON'T:
- DON'T leave
COPY . .before the install. The one-line docstring edit reran a 90-package install in 57.4 seconds instead of a one-package sync in 2.6. - DON'T drop
COPY README.md ./because the service never reads it. Without it the second sync could not build the project,failed to open fileon/app/README.md,No such file or directory. - DON'T run the service as root because it works. It works as
apptoo, and asappit cannot overwrite its own model.
The Cache Mount
What. RUN --mount=type=cache,target=/root/.cache/uv mounts a folder that BuildKit, Docker's build engine, keeps between builds, at the path where uv keeps its download cache, for that one step. Whatever uv downloads lands in the mount, not in the layer, and the next build that reruns the step finds it there. The cache sits on a different filesystem from /app, so uv cannot hardlink from it into .venv and copies instead, and UV_LINK_MODE=copy says so up front, which silences the warning uv prints otherwise: warning: Failed to hardlink files; falling back to full copy.
Why. Without the mount, uv's cache is written inside the image, next to the environment it filled, and every rerun of the step downloads every package again, because a layer that reruns starts from the layer before it, where no cache exists yet.
When. On every RUN that installs packages, and it matters most when the lock changes, the one change that reruns the first sync.
To measure it, save a copy of the Dockerfile without the mount as ~/dev/listings-probes/Dockerfile.nomount: the same file, with both uv sync instructions written as a plain RUN uv sync ... and no --mount. Build it with --no-cache, which ignores every cached layer, so it starts from nothing as the first build did:
time docker build --no-cache -f ../listings-probes/Dockerfile.nomount -t listings-service:nomount .
docker images listings-serviceIt took 111.5 seconds here, against the first build's 67.5, a gap that is mostly the network that minute, since both downloaded the same 55 packages. The sizes are no matter of timing:
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
listings-service:6.3.2 41a14dc8da60 1.15GB 258MB
listings-service:latest cc507632b629 894MB 204MB U
listings-service:nomount baaa9f6ab8a0 1.34GB 299MB The U marks the image in use, by the container still running from the last subtitle. The split dependencies brought the image from 1.15 GB to 894 MB, and the no-mount copy of the same file is 1.34 GB, larger than where it started. The layers say where the difference sits:
docker history listings-service --format "{{.Size}}" | sed -n 9p
docker history listings-service:nomount --format "{{.Size}}" | sed -n 9p503MB
858MBThe ninth layer from the top is the first sync's. The environment is 503 MB in both, and the 355 MB on top of it in the no-mount layer is uv's cache, the downloaded packages left in /root/.cache/uv, the same 355.3 MB that docker buildx du, BuildKit's disk report, showed in the cache mount after the first build. listings-service:6.3.2's install layer, full MLflow and no bytecode, is 703 MB.
The time the mount saves shows on the build it was made for. Change the description line of pyproject.toml, which leaves the lock valid and changes a file the first sync depends on, and rebuild both images without --no-cache:
time docker build -t listings-service .
time docker build -f ../listings-probes/Dockerfile.nomount -t listings-service:nomount .The first took 23.4 seconds, its sync Installed 55 packages in 675ms with no download, and the second 116.7, its sync Prepared 55 packages in 1m 23s first. Both compiled 6,491 files in 2.0 seconds. Put the description back, and the next build reuses the earlier layers.
DON'T:
- DON'T time the cache mount with
docker build --no-cache. Two such builds with the mount here took 68.3 and 70.3 seconds and both downloaded all 55 packages, whiledocker buildx dureported 355.3 MB in the mount:--no-cacheleft the mount's contents unused too. - DON'T delete the mount to make the Dockerfile shorter. The cache went into the image instead, 355 MB of it, and every lock change downloaded everything again.
Retraining After the Split
What. A model logged by MLflow carries the requirements.txt MLflow's uv export produced when it was trained, Lesson 6.2.1's, and load_model compares that list with the environment it runs in and prints a WARNING for every package missing or at another version. Version 1 was trained while mlflow was a main dependency and before psycopg arrived, so it lists 90 pins, and the image holds 56.
Why. Here every missing package is one the loader never imports, and the model still prices correctly, but a warning that lists 33 packages on every start teaches you to skip it, and the day it names the one package that matters it scrolls past with the rest.
When. After every change to the service's dependency set, before the model it serves is exported again.
The container from the second subtitle is still running. Its log, the first lines and the last:
docker logs listings-serviceINFO: Started server process [1]
INFO: Waiting for application startup.
2026/11/03 14:39:41 WARNING mlflow.utils.requirements_utils: Detected one or more mismatches between the model's dependencies and the current Python environment:
- aiohappyeyeballs (current: uninstalled, required: aiohappyeyeballs==2.7.1)
To fix the mismatches, call `mlflow.pyfunc.get_model_dependencies(model_uri)` to fetch the model's environment and install dependencies using the resulting environment file.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 172.17.0.1:56632 - "GET /health HTTP/1.1" 200 OKThe last line is the readiness loop's one answered request. The list between them has 33 lines, all current: uninstalled, Flask, SQLAlchemy, matplotlib, pyarrow and the rest of what full MLflow adds, and the times are UTC, the container's clock. The reference listing still prices at {"price":384235}. MLflow's advice, installing the model's environment, would put back the 33 packages the split removed. The fix is a model whose list matches the service, trained under the new lock, promoted and exported:
docker rm -f listings-service
uv run python -m listings.train
uv run python -m listings.promote 3 2>/dev/null
uv run mlflow artifacts download -u models:/listings-price@champion -d model 2>/dev/null
cat model/registered_model_meta
grep -c . model/requirements.txtThe two export lines and the last three of the training run, and what the rest print:
2026/11/03 15:41:23 INFO mlflow.utils.uv_utils: Exported 56 dependencies via uv
2026/11/03 15:41:23 INFO mlflow.utils.environment: Successfully exported 56 requirements from uv project. Skipping package capture based inference.
Registered model 'listings-price' already exists. Creating a new version of this model...
Created version '3' of model 'listings-price'.
version 3 56,562 0.766
listings-price version 3 is now champion
/home/goku/dev/listings-service/model
model_name: listings-price
model_version: 3
56uv run runs with the dev group, so training still has full MLflow, and the export MLflow runs leaves the dev group out, so version 3 records the 56 pins the image installs. The scores are version 1's to the digit, because the pipeline and the split are unchanged. Rebuild the image, which took 1.6 seconds here with every step before COPY model ./model cached, and start it:
docker build -t listings-service .
docker run -d --name listings-service -p 8000:8000 listings-service
until curl -s http://127.0.0.1:8000/health > /dev/null; do sleep 0.2; done
docker logs listings-serviceThe log:
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 172.17.0.1:36066 - "GET /health HTTP/1.1" 200 OKThe log is the five lines of a clean start, and the reference listing still prices at 384235. Remove the container before the next subtitle starts the stack:
docker rm -f listings-serviceDON'T:
- DON'T silence the mismatch warning. Thirty-three harmless lines are noise only until the list holds the one missing package that stops the load, as
skopsdid in the first subtitle. - DON'T install the model's recorded environment into the service to quiet it. That is the 33 packages the split removed, back in the image.
docker compose and the .env File
What. docker compose, the plugin Module 6's setup page checked, reads compose.yaml in the current folder and creates what it declares: a container per service, a shared network where each service reaches the others by name, and the named volumes. ${POSTGRES_PASSWORD:?message} is interpolation: Compose puts in the variable's value, from the shell or from a .env file beside compose.yaml, and refuses to start, printing the message, when it is missing.
Why. Lesson 6.3.2's stack is a network command and two docker run lines with a dozen flags, typed in order. Declared once, it is one command on every machine, and the password lives in one file that never enters git or the image.
When. For every service that runs beside another, a database, a cache or a second model, on your machine and in a CI job.
Save compose.yaml in the project folder:
services:
db:
image: postgres:18.6
environment:
POSTGRES_USER: listings
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: listings
volumes:
- pgdata:/var/lib/postgresql
ports:
- "5432:5432"
healthcheck:
test: ["CMD", "pg_isready", "-h", "localhost", "-U", "listings", "-d", "listings"]
interval: 2s
retries: 15
api:
build: .
environment:
DATABASE_URL: postgresql://listings:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/listings
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 5s
start_period: 30s
retries: 6
volumes:
pgdata:Each key under services is a service. db runs Lesson 6.3.2's postgres:18.6 with the same three variables and mounts the named volume pgdata at /var/lib/postgresql, Lesson 6.3.2's path for PostgreSQL 18. ports publishes a port as -p did. api has build: ., so Compose builds this folder's Dockerfile, and its DATABASE_URL names the host db, the service name, which the shared network resolves as Lesson 6.3.2's resolved listings-db. The healthchecks and depends_on are the next subtitle's, and the closing volumes block declares pgdata so Compose creates it. Start the stack before the password exists:
docker compose up -d; echo $?error while interpolating services.api.environment.DATABASE_URL: required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env
error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env
1Compose stopped before creating anything, once for each place the variable appears, with the message written after :?. Write the password into .env, and keep that file out of git and out of the build context, by adding an empty line and these two lines to the end of .gitignore and one line, .env, to the end of .dockerignore:
echo "POSTGRES_PASSWORD=listings" > .env# Secrets, never committed
.envgit check-ignore -v .env.gitignore:18:.env .envgit check-ignore -v names the rule that ignores a path, line 18 of .gitignore. The Dockerfile copies named paths only, so .env could never reach the image through it, and the .dockerignore line keeps the file out of what docker build sends to Docker at all, so no later COPY . . can put it there. Start the stack:
docker compose up -d --build
docker compose ps --format "{{.Name}} {{.Status}}"listings-service-api-1 Up Less than a second (health: starting)
listings-service-db-1 Up 2 seconds (healthy)--build builds the api image first, from cached layers, as listings-service-api. Compose names everything after the project, the folder's name: the network listings-service_default, the volume listings-service_pgdata, each container its service with a number. up printed the database Started, Waiting, Healthy, and only then api Starting, and a few seconds later both read (healthy). Then a price, and the row it left:
curl -s -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d '{"area_sqm": 85.0, "year_built": 1998, "distance_km": 3.0, "rooms": 3, "bathrooms": 1, "neighborhood": "Riverside", "property_type": "apartment", "energy_rating": "C", "condition": "good"}'; echo
docker compose exec db psql -U listings -d listings -c "SELECT id, price, listing->>'neighborhood' AS neighborhood FROM predictions"{"price":384235}
id | price | neighborhood
----+--------+--------------
1 | 384235 | Riverside
(1 row)
docker compose exec db runs a command in the db service's container, Lesson 6.3.1's docker exec by service name.
DON'T:
- DON'T commit
.envor write the password intocompose.yaml. That file is committed, and the password would live in every clone and in the history for good. - DON'T start the stack with Lesson 6.3.2's containers still up. With a container holding port 8000,
upcreatedapiand failed withBind for 0.0.0.0:8000 failed: port is already allocated, leavingdbrunning alone.
Healthchecks, depends_on and the Named Volume
What. A healthcheck is a command Docker runs inside the container every interval, and the status turns from starting to healthy at its first success, or to unhealthy after retries failures in a row, failures within start_period not counted. depends_on with condition: service_healthy starts api only after db reports healthy, where the short form, depends_on: [db], waits only for the container to start. A named volume lives outside every container: docker compose down removes the containers and the network and keeps it, down -v removes it too.
Why. The service connects once, in its lifespan, and a lifespan that cannot connect stops the service. The healthcheck tells Compose the database is ready for that connect, and the volume lets the data outlive every container you remove.
When. On every service another one waits for, and every time you decide between a restart that keeps the data and one that starts clean.
pg_isready asks a PostgreSQL server whether it accepts connections, and -h localhost is load-bearing. On a first start the postgres image's temporary server, Lesson 6.3.2's, listens only on a Unix socket, a file-based connection inside the container, and only the real server after it listens on TCP port 5432, where api connects. ~/dev/listings-probes/ready.sh runs both forms of the check against a fresh database. for i in $(seq 1 60); do ... done runs the lines up to done once for each number seq prints, 1 to 60, in $i, if C; then A; else B; fi runs A when C exits with 0 and B otherwise, and [ $tcp = ready ] exits with 0 when the two strings are equal, so && break leaves the loop at the first ready TCP check:
# Watch PostgreSQL's two readiness checks while a fresh database starts
for i in $(seq 1 60); do
if docker compose exec -T db pg_isready -U listings -d listings > /dev/null; then socket=ready; else socket=no; fi
if docker compose exec -T db pg_isready -h localhost -U listings -d listings > /dev/null; then tcp=ready; else tcp=no; fi
echo "check $i: socket $socket, tcp $tcp"
[ $tcp = ready ] && break
donedocker compose down -v
docker compose up -d db
bash ../listings-probes/ready.shcheck 1: socket no, tcp no
check 2: socket no, tcp no
check 3: socket no, tcp no
check 4: socket no, tcp no
check 5: socket no, tcp no
check 6: socket ready, tcp no
check 7: socket no, tcp no
check 8: socket no, tcp readydown -v removes the volume so the image initializes a new one, up -d db starts that service alone, and -T runs exec without a terminal, as a script needs. At check 6 the socket said ready while TCP was refused: that was the temporary server, and a healthcheck without -h localhost would have passed with nothing on the port api uses. docker compose logs db shows the same order: listening on Unix socket, CREATE DATABASE, database system is shut down, then the real server listening on IPv4 address "0.0.0.0", port 5432. api's own healthcheck calls Python's urllib.request, because the python:3.12.8-slim image has no curl and no wget, and its requests show in docker compose logs api as a GET /health from 127.0.0.1 every five seconds.
The short form of depends_on hid nothing here: three stacks started with it came up healthy, because the API's imports, model load and connect finished at 14:45:03.6 by its log, while the real server was ready at 14:45:02.5, a second of margin nothing in the file promises. A missed connect showed with db stopped and api started alone, docker compose up -d --no-deps --force-recreate api: the log ended in psycopg.OperationalError: failed to resolve host 'db' and Application startup failed. Exiting., and the container stayed Exited (3), since nothing retries. Now the volume. Bring the stack up, price a listing, take the stack down and bring it up again:
docker compose up -d --wait
curl -s -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d '{"distance_km": 3.0, "rooms": 3, "bathrooms": 1, "neighborhood": "Riverside", "property_type": "apartment", "condition": "good"}'; echo
docker compose down
docker volume ls --filter name=listings-service
docker compose up -d --wait
docker compose exec db psql -U listings -d listings -tc "SELECT count(*) FROM predictions"--wait returns only when every service is healthy, the readiness gate for the next command. The price comes back as {"price":434348}, down printed every container Stopped and Removed and the network Removed, and the volume list still holds the volume:
DRIVER VOLUME NAME
local listings-service_pgdata
1
The row survived, because the new db container mounted the same volume, and -t makes psql print the value alone. With -v:
docker compose down -v
docker compose up -d --wait
docker compose exec db psql -U listings -d listings -tc "SELECT count(*) FROM predictions" 0
down -v printed Volume listings-service_pgdata Removed after the containers and the network, and the table the service recreated at startup is empty.
DON'T:
- DON'T check PostgreSQL's readiness over the socket. At check 6 the socket said ready while TCP, the only way
apiconnects, was still refused. - DON'T trust the short
depends_onbecause the stack came up with it. It came up on about a second of margin, and anapithat missed its connect exited with code 3 and stayed down. - DON'T run
down -vas an ordinary restart. It deleted the volume and every logged prediction with it, anddownalone keeps them.
The suite still passes, without the database and with it, now on the stack's published port:
uv run pytest -q
DATABASE_URL=postgresql://listings:listings@localhost:5432/listings uv run pytest -qThe last line of each:
26 passed, 1 skipped, 2 warnings in 1.77s
27 passed, 2 warnings in 1.55sThe skip is Lesson 6.3.2's database test. The second run also logged the app tests' two prices into the stack's table, as the full suite did in Lesson 6.3.2, so SELECT count(*) FROM predictions now reads 2. Commit the six files, .env staying out:
git add .dockerignore .gitignore Dockerfile compose.yaml pyproject.toml uv.lock
git commit -m "feat: a slim layered image and the compose stack"[main aaa82c5] feat: a slim layered image and the compose stack
6 files changed, 57 insertions(+), 6 deletions(-)
create mode 100644 compose.yamlThe hash is your own. The stack keeps running, docker compose down stops it with its rows kept, and up -d brings it back.
Lab Exercises
Lab Exercise 1: Which Layers a Change Rebuilds
Make three changes one at a time, and before each build write down which steps you expect to rerun. Append Prices one listing per request, sent over HTTP. to README.md and build. Undo it with git restore README.md and build. Change the start of description in pyproject.toml from Prices a property listing, to Prices one property listing at a time,, check with uv lock --check that the lock still holds, build, restore the file with git restore pyproject.toml and build again. Build each time with time bash ../listings-probes/ran.sh, this script, which prints in order every step that ran instead of coming from the cache:
# Build the image and print, in order, the steps that ran instead of coming from the cache
docker build --progress=plain -t listings-service . 2>&1 |
awk '/^#[0-9]+ \[stage-0/ && !/ FROM / {step[$1] = $0} /^#[0-9]+ CACHED/ {cached[$1] = 1} END {for (id in step) if (!(id in cached)) print step[id]}' |
sed -E 's/^#[0-9]+ \[stage-0 +//' | sort -nawk remembers every step and every step id that printed CACHED, then prints the steps that never did, sed cuts the prefix and sort -n orders them by number. Say in one sentence per build why it ran what it did.
Lab Exercise 2: A New Password on an Old Volume
With the stack up and one listing priced, change the password in .env to riverside-85 and run docker compose up -d --wait, printing its exit code. Find which container failed and why from docker compose ps -a and both services' logs, and what the database did with the new password. Then fix the stack without losing the rows: inside the db container psql connects over the socket, which the image trusts without a password, and ALTER USER listings PASSWORD '...' changes a user's password. Bring the stack up and count the rows. Finish by putting the password back to listings, in the database and in .env, and bringing the stack up again with its volume kept, because Lesson 6.4.1 reads these rows.
Solution 1: Which Layers a Change Rebuilds
echo "Prices one listing per request, sent over HTTP." >> README.md
time bash ../listings-probes/ran.sh6/10] COPY README.md ./
7/10] COPY src ./src
8/10] RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev
9/10] COPY model ./model
10/10] RUN useradd --create-home --uid 1001 app
real 0m2.677stime's user and sys lines are left out here and below. The README copy is the first changed step, so it and every step after it ran, the copies of src and model included, although their files did not change. The second sync rebuilt the project, because the README is part of it, and the dependency layer came from the cache.
git restore README.md
time bash ../listings-probes/ran.sh
real 0m0.687sgit restore README.md put back the committed file and printed nothing, and no step ran: the layers built from the original README were still cached, because Docker keys a layer on what it was built from, never on the last build.
sed -i 's/^description = "Prices a property listing,/description = "Prices one property listing at a time,/' pyproject.toml
uv lock --check
time bash ../listings-probes/ran.sh
git restore pyproject.toml
time bash ../listings-probes/ran.shResolved 100 packages in 1ms
4/10] COPY pyproject.toml uv.lock ./
5/10] RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev --no-install-project
6/10] COPY README.md ./
7/10] COPY src ./src
8/10] RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev
9/10] COPY model ./model
10/10] RUN useradd --create-home --uid 1001 app
real 0m23.088s
real 0m0.768ssed -i edits the file in place, and uv lock --check, Lesson 6.1.1's, exits 0, because a description never changes what the lock resolves. The edit reran every step from the lock copy on, 23.1 seconds with the packages coming from the cache mount, and the restored file found its layers in the cache again.
DON'T:
- DON'T read a step that ran as a step whose files changed. The copies of
srcandmodelran after the README edit because a step before them did. - DON'T edit
pyproject.tomlfor a quick experiment before a build. Even its description reran the 503 MB install, 23.1 seconds with a warm mount.
Solution 2: A New Password on an Old Volume
docker compose up -d --wait
curl -s -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d '{"distance_km": 3.0, "rooms": 3, "bathrooms": 1, "neighborhood": "Riverside", "property_type": "apartment", "condition": "good"}'; echo
echo "POSTGRES_PASSWORD=riverside-85" > .env
docker compose up -d --wait; echo $?
docker compose ps -a --format "{{.Name}} {{.Status}}"
docker compose logs db | grep Skipping
docker compose logs api | grep -E "^api-1 \| psycopg|failed\. Exiting"The price, the last line up printed with its exit code, and what the rest print:
{"price":434348}
container listings-service-api-1 exited (3)
1
listings-service-api-1 Exited (3) Less than a second ago
listings-service-db-1 Up 6 seconds (healthy)
db-1 | PostgreSQL Database directory appears to contain a database; Skipping initialization
api-1 | psycopg.OperationalError: connection failed: connection to server at "172.18.0.2", port 5432 failed: FATAL: password authentication failed for user "listings"
api-1 | ERROR: Application startup failed. Exiting.Compose saw both services' environment change and recreated both. The new db found a database in the volume and skipped initialization, every POSTGRES_ variable with it, so the user kept the old password, and the healthcheck passed because pg_isready never logs in. api was refused with the new password and exited with code 3, so up --wait exited 1. The address in the error is db's on the network, and yours differs.
docker compose exec db psql -U listings -d listings -c "ALTER USER listings PASSWORD 'riverside-85'"
docker compose up -d --wait; echo $?
docker compose ps --format "{{.Name}} {{.Status}}"
docker compose exec db psql -U listings -d listings -tc "SELECT count(*) FROM predictions"What they print, up's progress lines left out:
ALTER ROLE
0
listings-service-api-1 Up 5 seconds (healthy)
listings-service-db-1 Up 18 seconds (healthy)
3
ALTER ROLE is PostgreSQL confirming the change, ALTER USER being its other name for the same command. The password now matches .env, the stack comes up healthy and the priced listing is still in the table, beside the two rows the test suite logged at the end of the Theory. down -v would also have fixed the login, by initializing a new database with the new password, and deleted the row with it. Put the original password back the same way, in the database first and .env second:
docker compose exec db psql -U listings -d listings -c "ALTER USER listings PASSWORD 'listings'"
echo "POSTGRES_PASSWORD=listings" > .env
docker compose up -d --wait; echo $?ALTER ROLE, the last two lines of up, and its exit code:
ALTER ROLE
Container listings-service-db-1 Healthy
Container listings-service-api-1 Healthy
0Compose recreated both containers for the changed variable, and both came up healthy on the kept volume, its three rows with it.
DON'T:
- DON'T expect a changed
POSTGRES_PASSWORDto reach an existing volume. The image reads it only when it initializes an empty data folder, and this one loggedSkipping initialization. - DON'T read a healthy
dbas one that accepts the service.pg_isreadypassed while every login was refused.
Theory Assessment
Q1: Two Packages Nobody Imports
app.py imports mlflow and never skops, yet pyproject.toml lists mlflow-skinny and skops, with mlflow in the dev group. Say what each entry is for, what the service does at startup if skops is removed and the image rebuilt, and why train.py still works on your machine.
Hint: Ask which package provides the mlflow module the service imports, which code imports skops.io, and which group uv run installs.
Q2: The Model Copied First
A teammate moves COPY model ./model to just after COPY pyproject.toml uv.lock ./, "so the model is in the image early". Say which steps the next model release reruns under each file, roughly what each costs with a warm cache mount, and whether the two images serve differently.
Hint: The first changed step reruns, and every step after it.
Q3: Healthy Too Early
The db healthcheck is pg_isready -U listings -d listings, without -h localhost. Say what that check asks, when on a fresh database it passes while nothing listens on TCP, what service_healthy then does, and why the stack can still come up healthy.
Hint: The postgres image initializes a new database with a temporary server before it starts the real one. Compare when the API reaches its connect with when the real server is ready.
Q4: "The Cache Mount Does Nothing"
A teammate builds the image twice with docker build --no-cache, sees 68.3 and 70.3 seconds with every package downloaded both times, concludes the cache mount does nothing, and deletes both --mount flags. Say what the measurement got wrong, what the deletion does to the image's size and to a rebuild after a lock change, and what the mount never changed.
Hint: docker buildx du reported 355.3 MB in the mount after the first of those builds. Compare the sizes of the two first-sync layers, and the two rebuilds after the description edit.
Answer 1:
mlflow-skinny provides the mlflow module and pyfunc.load_model, the service's one MLflow call, skops is the format of model.skops, imported by MLflow's scikit-learn loader at import skops.io, and full mlflow, in the dev group, adds the tracking store, server and UI that train.py, promote.py and mlflow artifacts download use. An image built from a copy of the project after uv remove skops exited with code 3 within seconds, its log ending in ModuleNotFoundError: No module named 'skops' and Application startup failed. Exiting.. The lock still held 100 packages, because full MLflow in the dev group depends on skops, so only the image lost it. train.py works on your machine because uv run installs the dev group with the main dependencies, all 99 pins, while the image installs --no-dev, the 56.
DON'T judge a dependency by the project's own import lines. The service imports skops through a library, and only declaring it keeps it installed.
Answer 2:
With the model copied first, a new model changes a step before both syncs, so the syncs, the copies of the README and the code, and the useradd all rerun: a variant built that way took 23.0 seconds for a new model against 1.6 for the layered file. The two images hold the same environment, code and model, so they serve the same prices, and the teammate's file pays the full install on every release for nothing, since a model in an earlier layer is not loaded any sooner.
DON'T order a Dockerfile by how important a file feels. Order it by how often each part changes, least often first.
Answer 3:
Without -h, pg_isready asks over the Unix socket inside the container. On a fresh volume the image's temporary server listens only on that socket while it creates the user and the database, so the check passed at check 6 while TCP was still refused, and service_healthy would start api with nothing on its port. The stack can still come up because the API spends its imports and model load before it connects, about a second after the real server was ready here. That margin is an accident, an API that misses its connect exits with code 3 and stays down, and -h localhost makes the check ask over TCP, the way api connects.
DON'T accept a healthcheck because the stack started. A check is correct when it passes exactly when the dependent service can do its work, here a TCP connection.
Answer 4:
--no-cache left the mount's contents unused, so both builds measured a cold mount and compare nothing. Deleting the mount puts uv's download cache in the first sync's layer, 858 MB instead of 503 MB, and the image grows from 894 MB to 1.34 GB, past Lesson 6.3.2's 1.15 GB. A rebuild after the description edit took 116.7 seconds without it, 83 of them downloading, against 23.4 with it. The mount never changed the environment itself: 503 MB in both layers, the same 6,491 files compiled in about 2 seconds.
DON'T measure a cache with a flag that turns caching off. Measure the rebuild the cache is for, a changed lock with the mount kept.
Assignment
Assignment: An Image Without uv
The image carries the uv binary, a 45.1 MB layer, and the service never calls it. A multi-stage build removes it. A Dockerfile may hold several FROM lines, each starting a stage, FROM python:3.12.8-slim AS builder names one, and COPY --from=builder /app /app in a later stage copies a folder out of it, the way the uv copy takes a file out of uv's image. Only the last stage becomes the image, so whatever stays in builder, uv included, is not in it.
Save ~/dev/listings-probes/Dockerfile.slim: a builder stage that installs the environment and the project as the lesson's Dockerfile does, and a final stage on the same base that creates the app user, copies /app out of builder, copies the model last and runs as app. Build it as listings-service:slim, compare its size with listings-service, show that uv is gone and the user is app, then start it, time the first /health and price the reference listing.
Hints:
- The environment's
pythonpoints at the base image's interpreter, and the project is installed in editable mode, pointing at/app/src, so the final stage needs the same base and the same/apppath. docker run --rm IMAGE uv --versionasks a fresh container foruvand removes it afterwards.
Solution:
~/dev/listings-probes/Dockerfile.slim:
FROM python:3.12.8-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
COPY README.md ./
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
FROM python:3.12.8-slim
ENV PATH="/app/.venv/bin:$PATH"
RUN useradd --create-home --uid 1001 app
WORKDIR /app
COPY --from=builder /app /app
COPY model ./model
USER app
EXPOSE 8000
CMD ["uvicorn", "listings.app:app", "--host", "0.0.0.0", "--port", "8000"]builder is the lesson's file up to the second sync. The final stage creates the user before the copies, so a new model reruns only COPY model, and PATH is all it needs of the three variables, because nothing installs there.
docker compose down
docker build -f ../listings-probes/Dockerfile.slim -t listings-service:slim .
docker images listings-service
docker run --rm listings-service:slim uv --version
docker run --rm listings-service:slim whoami
docker run -d --name listings-service -p 8000:8000 listings-service:slim
time (until curl -s http://127.0.0.1:8000/health > /dev/null; do sleep 0.2; done)
curl -s -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d '{"area_sqm": 85.0, "year_built": 1998, "distance_km": 3.0, "rooms": 3, "bathrooms": 1, "neighborhood": "Riverside", "property_type": "apartment", "energy_rating": "C", "condition": "good"}'; echo
docker rm -f listings-serviceThe two image lines that matter, the two answers, and the price:
listings-service:latest a0f0addb5265 894MB 204MB
listings-service:slim 99611f860fa7 828MB 185MB
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "uv": executable file not found in $PATH
app
{"price":384235}docker compose down frees port 8000 first, keeping the volume. The slim image is 66 MB smaller on disk and 19 MB smaller to download, and uv is found nowhere on PATH, so docker run exits with code 127, the shell's code for a command not found. The service runs as app, answered /health 4.1 seconds after docker run here, and prices the reference listing as the layered image does, from the same environment, code and model. docker history listings-service:slim shows one 503 MB layer, the copied /app, where the layered image has the two sync layers. With another model exported into model/, the slim image rebuilt in 1.2 seconds here, and the only step that ran was COPY model ./model.
DON'T copy /app into a final stage built from another base image. readlink -f /app/.venv/bin/python in the slim image prints /usr/local/bin/python3.12, the base image's interpreter, so the environment runs only on top of that same base.
Additional Resources
Optional, and nothing later in the module depends on them. Worth a look if you want the password out of environment variables altogether, or more than two stages in one Dockerfile.
- Docker documentation - "Manage secrets securely in Docker Compose". A secret mounted as a file at
/run/secrets/<name>, readable only by the services granted it, and why that is safer than an environment variable. - Docker documentation - "Multi-stage builds". Naming stages, copying between them, stopping a build at one stage with
--target, and an external image as a stage.
Summary
Nothing the service does changed here. It loads the same pipeline, prices the reference listing at the same 384235 and logs the same row. What changed is everything around it: which packages it is built from, in what order they arrive, who it runs as, and how it finds its database. Each of those worked before, and each was costing something that showed only when measured, a 57-second rebuild for a docstring, a tracking server in an image that only loads a model, 33 lines of warning on every start.
The measurements kept saying the same thing: the obvious reading of a number is often about something else. Two --no-cache builds said the mount did nothing, because the flag switched it off. A healthcheck that passed was answering for a server about to shut down. A depends_on that worked three times worked on a second of luck.
Compose turns those pieces into one thing a teammate can start, with what runs, what waits for what and what survives a restart written down, and the password beside it in a file that never travels. The next lesson hands that file and that Dockerfile to a machine that builds and tests them on every push to main and every pull request, where each second saved here is saved again.
Cheat Sheet
Part A - Reference Table
| Idea | Code | Notes |
|---|---|---|
| Service-only pins | uv export --no-dev --no-hashes | grep -c "==" |
92 before the split, 56 after |
| Lightweight MLflow | uv add mlflow-skinny==3.17.1 |
loads models, no server, UI or SQL store |
| Declare the loader's format | uv add skops==0.16.0 |
else No module named 'skops' |
| Tools only | uv add --dev mlflow==3.17.1 |
uv run has it, the image does not |
| Dependencies before code | uv sync --locked --no-dev --no-install-project |
then README, src, second sync |
| Layer order | lock, code, model | least often changed first |
| Bytecode at build time | UV_COMPILE_BYTECODE=1 |
faster starts, larger image |
| Ordinary user | useradd --create-home --uid 1001 app, USER app |
cannot write into /app |
| Cache mount | RUN --mount=type=cache,target=/root/.cache/uv |
cache stays out of the layer |
| Cold mount | docker build --no-cache |
the mount's contents go unused |
| Mismatch warning | current: uninstalled, required: ... |
retrain, promote, export again |
| Interpolation | ${POSTGRES_PASSWORD:?message} |
from .env, refuses if missing |
| Service name is the host | @db:5432 |
never localhost inside api |
| Readiness over TCP | pg_isready -h localhost ... |
the socket passes during init |
| Start order | condition: service_healthy |
short form waits for start only |
| Keep or delete the data | docker compose down, down -v |
-v removes the volume |
| Old volume, new password | ALTER USER listings PASSWORD '...' |
POSTGRES_PASSWORD is read only on init |
| Multi-stage | FROM ... AS builder, COPY --from=builder |
only the last stage ships |
Part B - Compact Code Block
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
COPY README.md ./
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
COPY model ./model
RUN useradd --create-home --uid 1001 app
USER appdocker compose up -d --build # build api, start both
docker compose up -d --wait # return when all healthy
docker compose down # keep the volume
docker compose down -v # delete the volume too