Lesson 7.2.1 · 70 min
Docker and docker-compose: reproducible images with uv
A bot that works on your laptop and fails on the server almost always has the same cause: it is not the same Python, nor the same libraries, nor the same system. This lesson writes the Dockerfile of a bot with uv following Astral's official guide, a docker-compose file with a database and monitoring, and a reviewer in Python that checks the rules that matter: pinned digests, lockfile, multiple stages, a non-root user, health checks and no secrets in the layers.
- Module
- 7.2 · Infrastructure and deployment
- Duration
- 70 min
- Prerequisites
- 0.2.2 · Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles, 0.2.5 · Git and GitHub: commits, branches, pull requests and .gitignore, 4.4.5 · Secure credentials: environment variables, secret managers and least privilege, 4.5.1 · Live system architecture: from data to reconciliation
- Author
- Jesús Cuesta
- Updated
Level 7 lessons
7.1 · Software engineering for trading
7.2 · Infrastructure and deployment
7.3 · MLOps for trading models
7.4 · Regulation, ethics and taxation
- 7.4.1 MiFID II: clients, suitability and algorithmic trading
- 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
- 7.4.3 PRIIPs, the KID and US ETFs
- 7.4.4 MiCA after the transitional period: checking a provider in the registers
- 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
- 7.4.6 Trading taxes in Spain
- 7.4.7 Ethics: personal data, artificial intelligence and selling signals
7.5 · Quant career
7.6 · Final capstone
- 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
- 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
- 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
- 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
- 7.6.5 Capstone C4: a research platform with LLMs and alternative data
- 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control
What an image pins and what it does not
Lesson 0.2.2 pinned the libraries with uv.lock: the same code with the same versions gives the same backtest. In
production that is not enough. The bot runs on another operating system, with another Python, other system libraries
(the C library, OpenSSL, the certificates) and another configuration. A Docker image packages all of that in read-only
layers: a base system, a Python, the virtual environment and the code. A container is that image running, with a
writable layer on top that is lost when the container is removed.
Reproducibility is a chain, and it breaks at its weakest link:
Mermaid source
flowchart TB A[uv.lock] --> B[docker build] C[Base with digest] --> B D[Code in git] --> B B --> E[Image with digest] E --> F[Registry] F --> G[Server]
uv.lock pins the Python libraries with their hashes; the digest of the base image pins the system and the
interpreter; the commit pins the code; and the digest of the resulting image is what gets deployed, not a tag. An image
does not pin what comes from outside at run time: the clock, the data, the broker's responses or the secrets, which are
mounted at startup and never put inside.
The project and a naive Dockerfile
The toy bot is a uv project like the one in level 0: pyproject.toml, uv.lock, the package in src/bot/, tests, a
README, some sample data and, as in many real repositories, a .env with a test key and a secretos/ folder with what
the compose file will read. The cell creates the tree in a temporary folder:
from pathlib import Path
RAIZ = Path("proyecto")
ARCHIVOS = {
"pyproject.toml": '[project]\nname = "bot"\nversion = "1.4.0"\n'
'requires-python = ">=3.14,<3.15"\n',
"uv.lock": "version = 1\n# pandas 3.0.6 ...\n",
"src/bot/__init__.py": "",
"src/bot/__main__.py": "from bot.bucle import main\nmain()\n",
"src/bot/bucle.py": "def main():\n ...\n" + "#\n" * 3000,
"src/bot/estrategia.py": "def senal(p):\n ...\n" + "#\n" * 4500,
"src/bot/salud.py": "# see the heartbeat cell\n" + "#\n" * 900,
"tests/test_estrategia.py": "def test_senal():\n ...\n",
"README.md": "# Bot\n",
".env": "EXCHANGE_API_KEY=clave-de-juguete-123\n",
"secretos/db_password.txt": "juguete-db\n",
"secretos/exchange_api_key.txt": "clave-de-juguete-123\n",
"secretos/grafana_admin.txt": "juguete-grafana\n",
"prometheus.yml": "scrape_configs: []\n",
"data/ticks.parquet": "x" * 2_000_000,
".git/objects/pack/pack.pack": "x" * 3_000_000,
}
for ruta, texto in ARCHIVOS.items():
p = RAIZ / ruta
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(texto)
total = sum(len(t.encode()) for t in ARCHIVOS.values())
print(len(ARCHIVOS), "files,", f"{total / 1e6:.2f} MB")16 files, 5.02 MBThe Dockerfile you get by copying the first example a search engine shows is this one. It works: it builds an image and the bot starts. It has, however, almost every defect that matters in production:
DF_INGENUO = """\
FROM python:3.14
WORKDIR /app
COPY . .
RUN pip install uv && uv sync
ENV EXCHANGE_API_KEY=clave-de-juguete-123
CMD ["uv", "run", "python", "-m", "bot"]
"""The 3.14 tag points to the latest 3.14 and the latest Debian image at that moment. COPY . . puts the whole
directory into the image, including .git, the data and the .env. pip install uv installs whatever uv version is
current that day. uv sync with no options can update the lockfile if it does not match pyproject.toml. The key stays
in the image configuration, in plain sight for anyone who pulls it (docker image inspect). The process runs as root.
And nothing tells Docker whether the bot is still alive or hung.
To review a Dockerfile you have to read it the way Docker reads it: one instruction per line, with lines ending in \
joined to the next one, comments left out and the options (--mount=…, --from=…) separated from the arguments. Each
FROM opens a stage. The following reader covers what the lesson uses; it does not understand heredocs (RUN <<EOF)
or changes of the escape character, which the reviewer would flag as odd instructions:
import re
def leer_dockerfile(texto):
"""Instructions with their line, stage, options and arguments."""
instr, buf, n0, etapa = [], "", None, -1
for n, linea in enumerate(texto.splitlines(), 1):
s = linea.strip()
if not s or s.startswith("#"):
continue # blank lines and comments
n0 = n0 or n
if s.endswith("\\"):
buf += s[:-1].strip() + " "
continue
buf += s
kw, _, args = buf.partition(" ")
opciones = {}
args = args.strip()
while args.startswith("--"):
tok, _, args = args.partition(" ")
k, _, v = tok[2:].partition("=")
opciones.setdefault(k, []).append(v)
args = args.strip()
kw = kw.upper()
etapa += kw == "FROM"
instr.append({"linea": n0, "etapa": etapa, "kw": kw,
"op": opciones, "args": args})
buf, n0 = "", None
return instr
for i in leer_dockerfile(DF_INGENUO):
print(i["linea"], i["etapa"], i["kw"], i["args"])1 0 FROM python:3.14
2 0 WORKDIR /app
3 0 COPY . .
4 0 RUN pip install uv && uv sync
5 0 ENV EXCHANGE_API_KEY=clave-de-juguete-123
6 0 CMD ["uv", "run", "python", "-m", "bot"]The Dockerfile with uv
Astral's guide to using uv in Docker proposes, point by point, what the naive one lacks1Note 1 · Astral, “Using uv in Docker” (https://docs.astral.sh/uv/guides/integration/docker/), accessed on October 23,
2026: uv images, pinning the version and digest, UV_COMPILE_BYTECODE, cache with --mount=type=cache and
UV_LINK_MODE=copy, intermediate layers with --no-install-project, --no-editable in multiple stages and
UV_PYTHON_DOWNLOADS=0. The --locked and --frozen options are in the uv CLI reference.: copy the uv binary from
its official image with a pinned version (and, if you want full reproducibility, with its digest, because tags can
move), install the dependencies in a layer of their own before copying the code (--no-install-project), mount the uv
cache with --mount=type=cache and UV_LINK_MODE=copy, compile the bytecode with UV_COMPILE_BYTECODE=1 to start
faster, skip the development dependencies (UV_NO_DEV=1), use the image's Python (UV_PYTHON_DOWNLOADS=0) and
separate the build from the result in two stages.
Mermaid source
flowchart TB L[Lock and pyproject] --> B[builder stage] U[uv and its cache] --> B B --> V[.venv] V --> F[Final image] S[src/] --> F
The digests do not fit in a 72-character line, so the cell stores them in constants and inserts them into the text with
format. They are the ones the registries returned on October 23, 2026 for python:3.14.9-slim-trixie on Docker Hub
and astral-sh/uv:0.13.5 on GitHub (the latest uv release that day):
IMG = {
"PY": "python:3.14.9-slim-trixie@sha256:"
"b347e2dcbc7f0b6f9fb7182e9da6a187"
"fb10ae715e0692518c1aed0eec94a700",
"UV": "ghcr.io/astral-sh/uv:0.13.5@sha256:"
"32a7bb39b496babbd997b0a39a4d66c1"
"26bdaf5661b302bf6f06abda8deca6ff",
}
DF_BUENO = """\
# syntax=docker/dockerfile:1
# --- stage 1: dependencies (uv does not reach the final image)
FROM {PY} AS builder
COPY --from={UV} /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy \\
UV_PYTHON_DOWNLOADS=0 UV_NO_DEV=1
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \\
--mount=type=bind,source=uv.lock,target=uv.lock \\
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \\
uv sync --locked --no-install-project --no-editable
# --- stage 2: Python, the environment and the code
FROM {PY}
RUN groupadd --system --gid 10001 bot \\
&& useradd --system --uid 10001 --gid bot --no-create-home bot
WORKDIR /app
COPY --from=builder --chown=bot:bot /app/.venv /app/.venv
COPY --chown=bot:bot src/ /app/src/
ENV PATH="/app/.venv/bin:$PATH" PYTHONPATH=/app/src \\
PYTHONUNBUFFERED=1
USER bot
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s \\
--retries=3 CMD ["python", "-m", "bot.salud"]
CMD ["python", "-m", "bot"]
""".format(**IMG)
(RAIZ / "Dockerfile").write_text(DF_BUENO)
print(len(leer_dockerfile(DF_BUENO)), "instructions in",
1 + max(i["etapa"] for i in leer_dockerfile(DF_BUENO)),
"stages")14 instructions in 2 stagesOne decision deserves an explanation. Astral's guide installs the project into the environment with a second
uv sync --locked --no-editable and copies only .venv to the final stage. That way the final image does not carry the
source code, but the whole environment changes with every commit, and with it the layer the server has to download.
Here the environment carries only the dependencies, the code goes in a separate layer (src/) and Python finds it via
PYTHONPATH. For an application that is not published as a package it is the cheapest option for deployments; for a
library, the guide's.
A Dockerfile reviewer
The rules can be checked without Docker. Each one has a code, a line and a reason; the table summarizes the nine:
| Code | Rule | Why |
|---|---|---|
| DF01 | every external image with a digest | tags are mutable |
| DF02 | the final stage does not run as root | a process leak is not a system leak |
| DF03 | uv sync with --locked or --frozen; no bare pip install | the lockfile rules |
| DF04 | dependencies are installed in another stage | installer and cache out of the image |
| DF05 | HEALTHCHECK in the final stage | a live process is not a healthy bot |
| DF06 | no secrets in ENV or ARG | they stay in the image |
| DF07 | COPY . after installing dependencies | the layer cache |
| DF08 | uv sync with the uv cache mounted | do not download wheels again |
| DF09 | .env and secretos/ out of the context | COPY . would put them inside |
DIGEST = re.compile(r"@sha256:[0-9a-f]{64}$")
SECRETO = re.compile(r"KEY|SECRET|TOKEN|PASS", re.I)
INSTALA = re.compile(r"uv sync|pip install")
def revisar_dockerfile(texto):
ins = leer_dockerfile(texto)
etapas = {i["args"].split()[-1].lower() for i in ins
if i["kw"] == "FROM" and " as " in i["args"].lower()}
ultima = max(i["etapa"] for i in ins)
out = []
def mal(i, cod, msg):
out.append((i["linea"], cod, msg))
instalo = set() # stages that already installed
for i in ins:
kw, a, op = i["kw"], i["args"], i["op"]
imgs = [a.split()[0]] if kw == "FROM" else op.get("from", [])
for img in imgs:
if img.lower() not in etapas and not DIGEST.search(img):
mal(i, "DF01", f"image without digest: {img}")
if kw in ("ENV", "ARG"):
for k in re.findall(r"(\w+)=", a) or a.split()[:1]:
if SECRETO.search(k):
mal(i, "DF06", f"secret in {kw}: {k}")
if kw == "COPY" and a.split()[0] == "." \
and i["etapa"] not in instalo:
mal(i, "DF07", "COPY . before installing dependencies")
if kw == "RUN" and INSTALA.search(a):
instalo.add(i["etapa"])
if "pip install" in a:
mal(i, "DF03", "pip install without lockfile")
if "uv sync" in a and not re.search(
r"--(locked|frozen)\b", a):
mal(i, "DF03", "uv sync without --locked or --frozen")
if "uv sync" in a and not any(
"type=cache" in m and ".cache/uv" in m
for m in op.get("mount", [])):
mal(i, "DF08", "uv sync without mounted cache")
if i["etapa"] == ultima:
mal(i, "DF04", "dependencies installed in the "
"final image")
final = [i for i in ins if i["etapa"] == ultima]
users = [i["args"].split(":")[0] for i in final
if i["kw"] == "USER"]
if not users or users[-1] in ("root", "0"):
mal(final[0], "DF02", "the final image runs as root")
if not any(i["kw"] == "HEALTHCHECK" for i in final):
mal(final[0], "DF05", "no HEALTHCHECK")
return sorted(out)
for nombre, df in (("naive", DF_INGENUO), ("good", DF_BUENO)):
h = revisar_dockerfile(df)
print(f"{nombre}: {len(h)} findings")
for linea, cod, msg in h:
print(f" line {linea:>2} {cod} {msg}")naive: 9 findings
line 1 DF01 image without digest: python:3.14
line 1 DF02 the final image runs as root
line 1 DF05 no HEALTHCHECK
line 3 DF07 COPY . before installing dependencies
line 4 DF03 pip install without lockfile
line 4 DF03 uv sync without --locked or --frozen
line 4 DF04 dependencies installed in the final image
line 4 DF08 uv sync without mounted cache
line 5 DF06 secret in ENV: EXCHANGE_API_KEY
good: 0 findingsNine findings in the naive one, none in the good one. Two comments. Rule DF06 only looks at the names: it does not know
whether DB_HOST=db is a secret (it is not), nor would it find a key written in a RUN echo. And DF03 flags any
pip install without telling apart --require-hashes: in a project with uv there is no reason to use it. A reviewer
like this does not replace reading; it automates what a person forgets at eleven at night.
Rule DF09 looks at the context, not the Dockerfile: what docker build sends to the builder is the whole directory
except what .dockerignore excludes. Docker's documentation describes the syntax2Note 2 · Docker, “Build context”, section “.dockerignore files” (https://docs.docker.com/build/concepts/context/).: one pattern per line, ** for
any number of directories and ! to include again; the last matching rule wins.
def patron_a_regex(p):
p = p.strip("/")
r = re.escape(p).replace(r"\*\*/", "(?:.*/)?")
r = r.replace(r"\*\*", ".*").replace(r"\*", "[^/]*")
return re.compile(r.replace(r"\?", "[^/]") + "(?:/.*)?$")
def contexto(raiz, ignorar=""):
reglas = [(l.startswith("!"), patron_a_regex(l.lstrip("!")))
for l in ignorar.splitlines()
if l.strip() and not l.startswith("#")]
dentro = {}
for f in sorted(p for p in raiz.rglob("*") if p.is_file()):
rel = f.relative_to(raiz).as_posix()
fuera = False
for negar, rx in reglas:
if rx.match(rel):
fuera = not negar
if not fuera:
dentro[rel] = f.stat().st_size
return dentro
def revisar_contexto(dentro):
return [("DF09", f) for f in dentro
if f == ".env" or f.startswith("secretos/")]
IGNORAR = """\
.git
.venv
**/__pycache__
.env
secretos/
data/
tests/
*.md
Dockerfile
"""
(RAIZ / ".dockerignore").write_text(IGNORAR)
for nombre, ign in (("no .dockerignore", ""), ("with it", IGNORAR)):
c = contexto(RAIZ, ign)
print(f"{nombre}: {len(c)} files, "
f"{sum(c.values()) / 1e6:.2f} MB,",
revisar_contexto(c))
print(sorted(contexto(RAIZ, IGNORAR)))no .dockerignore: 18 files, 5.02 MB, [('DF09', '.env'), ('DF09', 'secretos/db_password.txt'), ('DF09', 'secretos/exchange_api_key.txt'), ('DF09', 'secretos/grafana_admin.txt')]
with it: 9 files, 0.02 MB, []
['.dockerignore', 'prometheus.yml', 'pyproject.toml', 'src/bot/__init__.py', 'src/bot/__main__.py', 'src/bot/bucle.py', 'src/bot/estrategia.py', 'src/bot/salud.py', 'uv.lock']Without .dockerignore, COPY . . would carry into the image 5 MB of git history and data, the key in .env and the
three passwords in secretos/. With it, the context is reduced to what the image needs. Astral's guide specifically
recommends excluding .venv: the local environment depends on the platform and is created from scratch inside the
image.
The layer cache, measured
Docker's documentation sums up the cache in one sentence: once a layer changes, all later layers are rebuilt, even if they would not produce anything different3Note 3 · Docker, “Docker build cache” (https://docs.docker.com/build/cache/): “Once a layer changes, then all downstream layers need to be rebuilt as well”.. A new layer does not only cost build time: it has to be pushed to the registry and the server has to download it in the next deployment. To put a number on it you need the size of the dependencies layer. The cell measures it in the course environment for what a typical crypto bot would import (numpy, pandas, pyarrow, ccxt, pydantic, pyyaml and requests) and everything they pull in:
from importlib import metadata as md
from packaging.requirements import Requirement
def cierre(raices):
"""Installed packages pulled in by `raices` (no extras)."""
vistos, pila = set(), list(raices)
while pila:
n = pila.pop().lower().replace("_", "-")
if n in vistos:
continue
try:
d = md.distribution(n)
except md.PackageNotFoundError:
continue
vistos.add(n)
for r in d.requires or []:
req = Requirement(r)
if req.marker is None or req.marker.evaluate({"extra": ""}):
pila.append(req.name)
return vistos
def bytes_instalados(n):
return sum(f.size or 0 for f in md.distribution(n).files or [])
DEPS = cierre(["numpy", "pandas", "pyarrow", "ccxt", "pydantic",
"pyyaml", "requests"])
tam = {n: bytes_instalados(n) for n in DEPS}
DEPS_MB = round(sum(tam.values()) / 1e6, 1)
print(len(DEPS), "packages,", DEPS_MB, "MB installed")
for n, b in sorted(tam.items(), key=lambda x: -x[1])[:4]:
print(f" {n:<10} {b / 1e6:6.1f} MB")32 packages, 357.6 MB installed
pyarrow 168.2 MB
numpy 56.3 MB
pandas 39.1 MB
ccxt 39.1 MBNow the simulation. Each instruction gets a cache key: the hash of the previous key, the text of the instruction and
the content of what it reads (the files it copies or mounts, or the last layer of the stage it copies from). If the key
does not change, the layer is reused. Ten realistic commits: six touch the code, one the tests, one the README, one the
sample data and one updates pandas in uv.lock. For each commit, the MB of the new layers of the final image are added
up:
import hashlib
def h(*partes):
return hashlib.sha256("|".join(partes).encode()).hexdigest()
def claves(df, repo, ignorar):
"""Key and weight (MB) of each layer of the final image."""
ctx = {f: t for f, t in repo.items()
if contexto_virtual(f, ignorar)}
ins = leer_dockerfile(df)
ultima = max(i["etapa"] for i in ins)
nombre, fin, previa, capas = {}, {}, {}, []
for i in ins:
kw, a, op, e = i["kw"], i["args"], i["op"], i["etapa"]
lee = []
if kw == "FROM":
if " as " in a.lower():
nombre[e] = a.split()[-1]
clave = h(a)
else:
if kw == "COPY" and "from" in op:
lee = [fin.get(op["from"][0], op["from"][0])]
elif kw == "COPY":
src = a.split()[-2].rstrip("/")
lee = [h(f, t) for f, t in sorted(ctx.items())
if src == "." or f.startswith(src + "/")]
for m in op.get("mount", []):
if "type=bind" in m:
s = re.search(r"source=([^,]+)", m).group(1)
lee.append(h(s, ctx.get(s, "")))
clave = h(previa[e], kw, a, str(op), *lee)
previa[e] = clave
if e < ultima: # intermediate stage
fin[nombre.get(e, str(e))] = clave
continue
peso = 0.0 # MB of the layer
if kw == "RUN" and INSTALA.search(a):
peso = DEPS_MB
elif kw == "COPY" and "/app/.venv" in a:
peso = DEPS_MB
elif kw == "COPY":
peso = sum(len(t) for f, t in ctx.items()
if src == "." or
f.startswith(src + "/")) / 1e6
capas.append((clave, peso))
return capas
def contexto_virtual(f, ignorar):
reglas = [(l.startswith("!"), patron_a_regex(l.lstrip("!")))
for l in ignorar.splitlines() if l.strip()]
fuera = False
for negar, rx in reglas:
if rx.match(f):
fuera = not negar
return not fuera
COMMITS = [
("strategy: volume filter", "src/bot/estrategia.py"),
("filter tests", "tests/test_estrategia.py"),
("strategy: threshold", "src/bot/estrategia.py"),
("README", "README.md"),
("loop: reconnection", "src/bot/bucle.py"),
("sample data", "data/ticks.parquet"),
("pandas 3.0.6 to 3.0.7", "uv.lock"),
("strategy: fix sign", "src/bot/estrategia.py"),
("health: heartbeat at 90 s", "src/bot/salud.py"),
("strategy: stop", "src/bot/estrategia.py"),
]
repo = dict(ARCHIVOS)
antes = {"ingenuo": claves(DF_INGENUO, repo, ""),
"bueno": claves(DF_BUENO, repo, IGNORAR)}
FILAS = []
for n, (msg, f) in enumerate(COMMITS, 1):
repo[f] = repo[f] + f"# change {n}\n"
fila = [n, msg]
for nombre, df, ign in (("ingenuo", DF_INGENUO, ""),
("bueno", DF_BUENO, IGNORAR)):
ahora = claves(df, repo, ign)
viejas = {c for c, _ in antes[nombre]}
fila.append(round(sum(p for c, p in ahora
if c not in viejas), 3))
antes[nombre] = ahora
FILAS.append(fila)
print(f"{n:>2} {msg:<29} {fila[2]:>7.1f} {fila[3]:>7.3f}")
tot_i = sum(f[2] for f in FILAS)
tot_b = sum(f[3] for f in FILAS)
print(f"total new MB: naive {tot_i:.1f}, good {tot_b:.1f}") 1 strategy: volume filter 362.6 0.017
2 filter tests 362.6 0.000
3 strategy: threshold 362.6 0.017
4 README 362.6 0.000
5 loop: reconnection 362.6 0.017
6 sample data 362.6 0.000
7 pandas 3.0.6 to 3.0.7 362.6 357.617
8 strategy: fix sign 362.6 0.017
9 health: heartbeat at 90 s 362.6 0.017
10 strategy: stop 362.6 0.017
total new MB: naive 3626.2, good 357.7With the naive Dockerfile, every commit, whatever it is, invalidates COPY . . and with it the dependencies layer:
357.6 MB of environment plus 5 MB of context, ten times. With the good one, code changes generate 17 KB, changes to
tests, README and data generate nothing (they are outside the context) and only the commit that changes uv.lock
rebuilds the environment. The figure at the top shows the ten commits. The difference is not only time: on a VPS with
a small disk, ten versions of the naive bot leave 3.6 GB of layers that have to be cleaned up with
docker image prune.
The heartbeat: a live bot is not a healthy bot
HEALTHCHECK tells Docker how to know whether the container is working. A process can be alive and do nothing: an
await that never returns, a half-closed connection, a loop waiting for data that never arrives. The Dockerfile
reference explains it with a web server stuck in an infinite loop4Note 4 · Docker, “Dockerfile reference”, HEALTHCHECK (https://docs.docker.com/reference/dockerfile/): defaults
--interval=30s, --timeout=30s, --start-period=0s, --retries=3.. For a bot, the useful signal is a heartbeat: the
main loop writes the time to a file on every iteration, and bot.salud checks that it is not too old. With the clock
injected, as in lesson 4.5.1, it can be tested without waiting:
import os
LATIDO = Path("tmp/latido")
MAX_EDAD = 90 # seconds
def comprobar(ruta, ahora, max_edad=MAX_EDAD):
"""0 = healthy, 1 = unhealthy (the code HEALTHCHECK expects)."""
try:
edad = ahora - ruta.stat().st_mtime
except FileNotFoundError:
return 1
return 0 if edad <= max_edad else 1
LATIDO.parent.mkdir(exist_ok=True)
LATIDO.touch()
T0 = 1_760_000_000 # injected clock (epoch)
os.utime(LATIDO, (T0, T0))
for t in (T0 + 30, T0 + 90, T0 + 91):
print(f"heartbeat {t - T0:>3} s ago -> {comprobar(LATIDO, t)}")
print("no file ->", comprobar(Path("tmp/no-existe"), T0))
def peor_deteccion(max_edad, intervalo, reintentos, timeout):
"""From the last heartbeat to `unhealthy`, in the worst case."""
return max_edad + intervalo * reintentos + timeout
print("worst case:", peor_deteccion(MAX_EDAD, 30, 3, 5), "s")heartbeat 30 s ago -> 0
heartbeat 90 s ago -> 0
heartbeat 91 s ago -> 1
no file -> 1
worst case: 185 sWith a 30-second interval and three retries (Docker's defaults are 30 s, a 30 s timeout and 3 retries), a hung bot
takes about three minutes in the worst case to be marked unhealthy. It is a trade-off: faster means more false alarms
during a long garbage collection or a market with no data. Two practical details. The slim image does not ship
curl, so the check is a Python module. And the documentation of restart policies describes restarting when the
container exits5Note 5 · Docker, “Start containers automatically” (https://docs.docker.com/engine/containers/start-containers-automatically/).; an unhealthy container does not exit on its own. Someone has to see it (the alerts of lesson
7.2.3), or the bot itself needs a watchdog that makes it exit with an error when its loop stops, so that
restart: unless-stopped brings it back up.
docker-compose: bot, database and monitoring
A production bot does not run alone: it needs somewhere to store orders and fills (PostgreSQL), something that collects its metrics (Prometheus) and somewhere to look at them (Grafana). Compose describes the four services, their networks, volumes and secrets in one file, and starts them in order. The design:
Mermaid source
flowchart TB G[grafana] --> P[prometheus] P --> B[bot] B --> D[(postgres)] B --> X[Exchange]
The database and Prometheus live on an internal network with no way out to the Internet; only the bot also has a
network with outbound access to the exchange. Grafana is published on 127.0.0.1 and reached through an SSH tunnel.
The passwords and the exchange key go under secrets:: Compose mounts each one as a file in /run/secrets/<name> only
in the services that request it, and Docker's documentation explains why not in environment variables: any process can
see them and they end up in the logs by accident6Note 6 · Docker, “Manage secrets securely in Docker Compose” (https://docs.docker.com/compose/how-tos/use-secrets/). The
PostgreSQL _FILE variables are in the official image's documentation; Grafana's __FILE, in “Configure a Grafana
Docker image”.. The PostgreSQL image reads the password from a file with
POSTGRES_PASSWORD_FILE, and Grafana with GF_SECURITY_ADMIN_PASSWORD__FILE. First, the naive compose file and the
good one:
import yaml
IMG |= {
"PG": "postgres:18.6-trixie@sha256:"
"edb0c08dc33befd0262ab3b5cf1c4f3f"
"8cfd8b09e88c05b45adee29d6761ddb6",
"PROM": "prom/prometheus:v3.15.1@sha256:"
"fa3302279710be2f21a8301ec17a8b4f"
"2301fd829bd113839b3b6b02b6f191a2",
"GRAF": "grafana/grafana:13.2.4@sha256:"
"dbbc38ea21103cefb50396340cd88afe"
"c779c7e070b0a7bf5a75b4ce46651d3f",
}
COMPOSE_INGENUO = """\
services:
db:
image: postgres:latest
environment:
POSTGRES_PASSWORD: juguete-db
ports: ["5432:5432"]
bot:
build: .
environment:
EXCHANGE_API_KEY: clave-de-juguete-123
depends_on: [db]
grafana:
image: grafana/grafana
ports: ["3000:3000"]
"""
COMPOSE_BUENO = """\
name: bot-trading
services:
db:
image: {PG}
environment:
POSTGRES_USER: bot
POSTGRES_DB: trading
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes: ["pgdata:/var/lib/postgresql"]
networks: [interna]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bot -d trading"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
bot:
build: .
image: bot-trading:1.4.0
environment:
BOT_MODO: paper
DB_HOST: db
secrets: [db_password, exchange_api_key]
depends_on:
db: {{condition: service_healthy}}
networks: [interna, salida]
read_only: true
tmpfs: [/tmp]
stop_grace_period: 30s
restart: unless-stopped
prometheus:
image: {PROM}
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- promdata:/prometheus
networks: [interna]
restart: unless-stopped
grafana:
image: {GRAF}
environment:
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin
secrets: [grafana_admin]
ports: ["127.0.0.1:3000:3000"]
networks: [interna]
depends_on:
prometheus: {{condition: service_started}}
restart: unless-stopped
networks:
interna: {{internal: true}}
salida: {{}}
volumes:
pgdata: {{}}
promdata: {{}}
secrets:
db_password: {{file: ./secretos/db_password.txt}}
exchange_api_key: {{file: ./secretos/exchange_api_key.txt}}
grafana_admin: {{file: ./secretos/grafana_admin.txt}}
""".format(**IMG)
(RAIZ / "compose.yaml").write_text(COMPOSE_BUENO)
c = yaml.safe_load(COMPOSE_BUENO)
print(list(c["services"]), list(c["secrets"]))['db', 'bot', 'prometheus', 'grafana'] ['db_password', 'exchange_api_key', 'grafana_admin']The compose reviewer follows the same idea as the Dockerfile one. The rules come from the documentation: depends_on
in its short form only waits for the container to start, while condition: service_healthy waits for the dependency's
health check to pass7Note 7 · Docker, “Compose file reference: services”, depends_on (https://docs.docker.com/reference/compose-file/services/).; and Docker diverts the traffic of published ports before it reaches the chains ufw uses, so a
"5432:5432" on a server with a firewall stays open to the Internet even though ufw says otherwise8Note 8 · Docker, “Packet filtering and firewalls”, section “Docker and ufw”
(https://docs.docker.com/engine/network/packet-filtering-firewalls/)..
def revisar_compose(texto, raiz):
c = yaml.safe_load(texto)
svc, out = c["services"], []
declarados = set(c.get("secrets", {}))
for n, s in svc.items():
img = s.get("image", "")
if "build" not in s and not DIGEST.search(img):
out.append((n, "DC01", f"image without digest: {img}"))
env = s.get("environment", {})
if isinstance(env, list):
env = dict(e.split("=", 1) for e in env)
for k in env:
if SECRETO.search(k) and not k.endswith("FILE"):
out.append((n, "DC02", f"secret in environment: {k}"))
for p in s.get("ports", []):
if not str(p).startswith("127.0.0.1:"):
out.append((n, "DC03", f"port on all "
f"interfaces: {p}"))
dep = s.get("depends_on", {})
if isinstance(dep, list):
dep = {d: {"condition": "service_started"} for d in dep}
for d, cond in dep.items():
sano = "healthcheck" in svc.get(d, {})
if sano and cond.get("condition") != "service_healthy":
out.append((n, "DC04", f"does not wait for {d} "
f"to be healthy"))
if not sano and d == "db":
out.append((d, "DC05", "no healthcheck and another "
"service depends on it"))
if "restart" not in s:
out.append((n, "DC06", "no restart policy"))
for sec in s.get("secrets", []):
f = c.get("secrets", {}).get(sec, {}).get("file")
if sec not in declarados or not (raiz / f).is_file():
out.append((n, "DC07", f"secret without file: {sec}"))
return sorted(set(out))
for nombre, txt in (("naive", COMPOSE_INGENUO),
("good", COMPOSE_BUENO)):
h = revisar_compose(txt, RAIZ)
print(f"{nombre}: {len(h)} findings")
for servicio, cod, msg in h:
print(f" {servicio:<8} {cod} {msg}")naive: 10 findings
bot DC02 secret in environment: EXCHANGE_API_KEY
bot DC06 no restart policy
db DC01 image without digest: postgres:latest
db DC02 secret in environment: POSTGRES_PASSWORD
db DC03 port on all interfaces: 5432:5432
db DC05 no healthcheck and another service depends on it
db DC06 no restart policy
grafana DC01 image without digest: grafana/grafana
grafana DC03 port on all interfaces: 3000:3000
grafana DC06 no restart policy
good: 0 findingsTen findings in the naive one and none in the good one. The one with the most consequences is the most discreet:
depends_on: [db] starts the bot as soon as the PostgreSQL container exists, and PostgreSQL takes a few seconds to
accept connections; the bot fails to connect at startup and, with no restart policy, stays stopped. Rule DC05 is written
only for db: generalizing it to any service another one depends on is one of the exercises.
Compose computes the startup order from depends_on, and undoes it in reverse when stopping. It is a topological sort,
the same one graphlib from the standard library does:
from graphlib import TopologicalSorter
svc = yaml.safe_load(COMPOSE_BUENO)["services"]
grafo = {n: set(s.get("depends_on", {})) for n, s in svc.items()}
ts = TopologicalSorter(grafo)
ts.prepare()
tandas = []
while ts.is_active():
listos = sorted(ts.get_ready())
tandas.append(listos)
ts.done(*listos)
print("start:", " -> ".join("+".join(t) for t in tandas))
print("stop: ", " -> ".join("+".join(t) for t in tandas[::-1]))start: db+prometheus -> bot+grafana
stop: bot+grafana -> db+prometheusShutdown matters as much as startup. stop_grace_period: 30s gives the bot 30 seconds from the stop signal to cancel
open orders, save state and close connections before Docker kills it; the bot has to catch the signal and do it
(lesson 4.5.5). And since the database stops after the bot, its last writes get through.
Build, start and check
On a machine with Docker, the real steps are these. docker compose config --quiet validates the file with Compose
itself, and the lesson's reviewer and Docker's should agree; --wait makes up wait until the services with a health
check are healthy:
docker buildx build -t bot-trading:1.4.0 proyecto/
docker compose -f proyecto/compose.yaml config --quiet
docker compose -f proyecto/compose.yaml up -d --wait
docker compose -f proyecto/compose.yaml ps
docker image inspect bot-trading:1.4.0 --format '{{.Id}}'From Python, for a deployment script that stops if something fails:
import subprocess
def docker(*args):
r = subprocess.run(["docker", *args], cwd=RAIZ,
capture_output=True, text=True)
if r.returncode:
raise RuntimeError(r.stderr.strip())
return r.stdout.strip()
docker("buildx", "build", "-t", "bot-trading:1.4.0", ".")
docker("compose", "config", "--quiet")
docker("compose", "up", "-d", "--wait")
print(docker("compose", "ps", "--format", "json"))The digest of the built image is what goes into the deployment log: “on October 23, sha256:… was deployed, built from
commit …”. With that, rolling back means deploying the previous digest, not rebuilding and hoping the same thing comes
out. How the image is built and published on every commit, and how it reaches the server, is lesson 7.2.2.
Exercises
- Generalize rule DC05 so that it flags any service without
healthcheckthat another one depends on withcondition: service_healthy, and add a rule DC08 that flags services withbuildbut noimage(without a tag, the deployment log cannot say what was deployed).
Solution
def revisar_compose_2(texto, raiz):
c = yaml.safe_load(texto)
svc = c["services"]
out = [h for h in revisar_compose(texto, raiz)
if h[1] != "DC05"]
for n, s in svc.items():
dep = s.get("depends_on", {})
if isinstance(dep, list):
continue
for d, cond in dep.items():
if (cond.get("condition") == "service_healthy"
and "healthcheck" not in svc.get(d, {})
and "build" not in svc.get(d, {})):
out.append((d, "DC05", f"{n} waits for it to be "
f"healthy and it has no "
f"healthcheck"))
if "build" in s and "image" not in s:
out.append((n, "DC08", "build without image"))
return sorted(set(out))
malo = COMPOSE_BUENO.replace(" image: bot-trading:1.4.0\n", "")
malo = malo.replace("prometheus: {condition: service_started}",
"prometheus: {condition: service_healthy}")
for h in revisar_compose_2(malo, RAIZ):
print(h)('bot', 'DC08', 'build without image')
('prometheus', 'DC05', 'grafana waits for it to be healthy and it has no healthcheck')Prometheus has no healthcheck in the good compose file because nobody waited for it to be healthy; as soon as Grafana
requires it, it needs one. Services with build are excluded because their check is in the Dockerfile
(HEALTHCHECK), which the compose reviewer does not read: a more complete reviewer would read both.
- Add to the Dockerfile reviewer a rule DF10 that flags
ADDwith a URL and no--checksum, and test it withADD https://astral.sh/uv/install.sh /uv-installer.sh(the line in Astral's guide to install uv with its installer) and with the same line with--checksum=sha256:followed by 64 zeros.
Solution
def revisar_dockerfile_2(texto):
out = revisar_dockerfile(texto)
for i in leer_dockerfile(texto):
if (i["kw"] == "ADD" and re.match(r"https?://", i["args"])
and "checksum" not in i["op"]):
out.append((i["linea"], "DF10", "remote ADD without "
"--checksum"))
return sorted(out)
base = DF_BUENO.replace(
"WORKDIR /app\nRUN --mount", "WORKDIR /app\n{ADD}\nRUN --mount", 1)
sin = "ADD https://astral.sh/uv/install.sh /uv-installer.sh"
con = "ADD --checksum=sha256:" + "0" * 64 + sin[3:]
for linea in (sin, con):
h = revisar_dockerfile_2(base.replace("{ADD}", linea))
print([x[1:] for x in h])[('DF10', 'remote ADD without --checksum')]
[]The Dockerfile reference accepts --checksum in ADD with HTTP sources: if the downloaded file does not have that
hash, the build fails. Without it, the installer can change between two builds and nobody notices. Copying uv from its
image with a digest, as the lesson does, avoids the problem at the root.
- A colleague says that pinning digests is overkill for a personal bot: “
python:3.14-slimis enough for me, and that way I always have the patches”. What would you answer?
Solution
That they are right about the risk they point out and wrong about the solution. With a tag, two builds of the same
commit can give different images without anyone deciding it: Docker Hub shows the python:3.14.8-slim-trixie tag
updated on October 21, 2026, and 3.14.9 appeared the next day; Docker's documentation reminds you that tags are mutable.
If the bot fails after a deployment, they will not know whether it was their code or the image. With a digest, every
base change is a commit (reviewable, with its CI, reversible), and the patches keep arriving if a tool such as
Dependabot proposes the new digest every week. For a personal bot, the difference is being able to answer “what
changed?” with one line of the history.
Footnotes
-
Astral, “Using uv in Docker” (https://docs.astral.sh/uv/guides/integration/docker/), accessed on October 23, 2026: uv images, pinning the version and digest,
UV_COMPILE_BYTECODE, cache with--mount=type=cacheandUV_LINK_MODE=copy, intermediate layers with--no-install-project,--no-editablein multiple stages andUV_PYTHON_DOWNLOADS=0. The--lockedand--frozenoptions are in the uv CLI reference. ↩ -
Docker, “Build context”, section “.dockerignore files” (https://docs.docker.com/build/concepts/context/). ↩
-
Docker, “Docker build cache” (https://docs.docker.com/build/cache/): “Once a layer changes, then all downstream layers need to be rebuilt as well”. ↩
-
Docker, “Dockerfile reference”,
HEALTHCHECK(https://docs.docker.com/reference/dockerfile/): defaults--interval=30s,--timeout=30s,--start-period=0s,--retries=3. ↩ -
Docker, “Start containers automatically” (https://docs.docker.com/engine/containers/start-containers-automatically/). ↩
-
Docker, “Manage secrets securely in Docker Compose” (https://docs.docker.com/compose/how-tos/use-secrets/). The PostgreSQL
_FILEvariables are in the official image's documentation; Grafana's__FILE, in “Configure a Grafana Docker image”. ↩ -
Docker, “Compose file reference: services”,
depends_on(https://docs.docker.com/reference/compose-file/services/). ↩ -
Docker, “Packet filtering and firewalls”, section “Docker and ufw” (https://docs.docker.com/engine/network/packet-filtering-firewalls/). ↩
Each answer is folded under its question
Self-assessment
The Dockerfile starts with
FROM python:3.14.9-slim-trixie. You build it today and again in a month, without changing anything. Do you get the same base image?- AYes: the tag includes the exact Python version
- BNot necessarily: tags are mutable and the publisher republishes them (Docker Hub shows the 3.14.8 one updated on October 21, 2026); only a digest
@sha256:…pins the content - CYes, because Docker caches the image forever
- DNo, because
slimchanges distribution every month
Show answer
Answer B. Not necessarily: tags are mutable and the publisher republishes them (Docker Hub shows the 3.14.8 one updated on October 21, 2026); only a digest
@sha256:…pins the contentDocker's documentation puts it this way: tags are mutable, and pinning the digest guarantees the same image even if the publisher replaces the tag. The price is that security fixes no longer arrive on their own: you have to update the digest on purpose (Dependabot with the
dockerecosystem opens those PRs).What is the difference between
uv sync --lockedanduv sync --frozen?- ANone: they are synonyms
- B
--frozendownloads faster because it does not check hashes - CBoth install what
uv.locksays without changing it;--lockedalso fails if the lockfile is not up to date withpyproject.toml, and--frozendoes not check it - D
--lockedlocks the environment so that nobody can modify it
Show answer
Answer C. Both install what
uv.locksays without changing it;--lockedalso fails if the lockfile is not up to date withpyproject.toml, and--frozendoes not check itAccording to the uv reference,
--lockedrequires the lockfile to be up to date and exits with an error if it would need updating;--frozenuses the lockfile as the source of truth without checking it. Astral's guide uses--frozenin the first sync, when onlyuv.lockis mounted; in the lesson both files are mounted and--lockedis used.In the naive Dockerfile,
COPY . .comes beforeuv sync. What happens when you change one line ofestrategia.py?- AOnly the code layer is rebuilt
- BNothing: Docker detects that the dependencies have not changed
- CThe build fails
- DThe cache key of
COPY . .changes and all later layers are rebuilt, including the dependencies layer: 362.6 new MB for a one-line change
Show answer
Answer D. The cache key of
COPY . .changes and all later layers are rebuilt, including the dependencies layer: 362.6 new MB for a one-line changeDocker rebuilds a layer when its input changes and, after it, every layer that follows. That is why the dependencies go in a layer that depends only on
uv.lockandpyproject.toml, and the code is copied afterwards. In the lesson's simulation, ten commits add up to 3,626.2 new MB with the naive Dockerfile and 357.7 MB with the good one.The database password goes in
environment: POSTGRES_PASSWORD: …in the compose file. What does Docker's documentation propose?- ADeclare it under
secrets:and passPOSTGRES_PASSWORD_FILE=/run/secrets/db_passwordto the image: Compose mounts the file only in the services that request it and it does not stay in the process environment - BEncode it with base64 in the compose file itself
- CPut it in an
ARGof the Dockerfile - DWrite it in
.envand runCOPY . .
Show answer
Answer A. Declare it under
secrets:and passPOSTGRES_PASSWORD_FILE=/run/secrets/db_passwordto the image: Compose mounts the file only in the services that request it and it does not stay in the process environmentEnvironment variables are visible to every process in the container and end up in logs by accident; build arguments stay in the image. Compose mounts each secret as a file in
/run/secrets/<name>, and the PostgreSQL and Grafana images read the password from a file with_FILEand__FILE.- ADeclare it under
The compose file publishes Grafana as
"3000:3000"on a server with ufw that only lets port 22 through. Is Grafana safe from the Internet?- AYes: ufw blocks everything other than 22
- BYes, if Grafana has a password
- CNo: Docker diverts the traffic of published ports before it reaches the chains ufw uses; you have to publish on
127.0.0.1:3000:3000and come in through an SSH tunnel - DNo, because Grafana opens the port on its own
Show answer
Answer C. No: Docker diverts the traffic of published ports before it reaches the chains ufw uses; you have to publish on
127.0.0.1:3000:3000and come in through an SSH tunnelDocker's documentation warns about it: traffic to published ports is routed in the nat table before it reaches the INPUT and OUTPUT chains that ufw uses, so its rules do not apply. Publishing only on the local interface closes that gap.
Key points
- An image pins the system, Python and the libraries; a tag does not pin the image. Base, uv and service images go with a digest
@sha256:…, and updating them is a reviewed change. - With uv:
uv.lockin the repository,uv sync --locked(or--frozenifpyproject.tomlis not there yet), cache mounted with--mount=type=cacheandUV_LINK_MODE=copy, as in Astral's guide. - The order of the instructions is the order of the cache: dependencies before code. Over ten simulated commits, the naive Dockerfile generates 3,626.2 MB of new layers and the good one 357.7 MB, almost all of it in the only commit that changes
uv.lock. - Multiple stages: uv and the cache stay in the build stage; the final one carries Python, the environment, the code, a non-root user and a health check.
- No secrets in
ENV,ARGor the build context:.dockerignoreleaves out.envandsecretos/, and Compose mounts secrets as files in/run/secrets/. - In compose: digests,
depends_onwithcondition: service_healthy, an internal network with no way out for the database and ports only on127.0.0.1. A reviewer in Python checks all of that before building.
Resources
Research
Documentation
- uv: Using uv in Docker (Astral's official guide)
- Docker: Building best practices (pinning the base image by digest, USER, multiple stages)
- Docker: Build secrets
- Docker Compose: services reference (depends_on, healthcheck)
- Docker Compose: Manage secrets in Compose
- Docker: Packet filtering and firewalls (Docker and ufw)