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.

Fig. — New final-image layers over ten simulated commits, with the naive Dockerfile and with the two-stage uv one: 3,626.2 MB versus 357.7 MB, almost all of it in the single commit that changes uv.lock
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 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:

uv.lockdocker buildBase with digestCode in gitImage with digestRegistryServer
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:

IN [01]python
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")
OUT [01]The bot's repository
16 files, 5.02 MB

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

IN [02]python
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:

IN [03]python
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"])
OUT [03]A Dockerfile reader
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.

Lock and pyprojectbuilder stageuv and its cache.venvFinal imagesrc/
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):

IN [04]python
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")
OUT [04]The production Dockerfile
14 instructions in 2 stages

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

CodeRuleWhy
DF01every external image with a digesttags are mutable
DF02the final stage does not run as roota process leak is not a system leak
DF03uv sync with --locked or --frozen; no bare pip installthe lockfile rules
DF04dependencies are installed in another stageinstaller and cache out of the image
DF05HEALTHCHECK in the final stagea live process is not a healthy bot
DF06no secrets in ENV or ARGthey stay in the image
DF07COPY . after installing dependenciesthe layer cache
DF08uv sync with the uv cache mounteddo not download wheels again
DF09.env and secretos/ out of the contextCOPY . would put them inside
IN [05]python
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}")
OUT [05]Rules DF01 to DF08
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 findings

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

IN [06]python
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)))
OUT [06]The build context and .dockerignore
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:

IN [07]python
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")
OUT [07]How much the bot's dependencies weigh
32 packages, 357.6 MB installed
  pyarrow     168.2 MB
  numpy        56.3 MB
  pandas       39.1 MB
  ccxt         39.1 MB

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

IN [08]python
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}")
OUT [08]Ten commits: new layers with each Dockerfile
 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.7

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

IN [09]python
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")
OUT [09]bot.salud: the heartbeat and how long a hang takes to be detected
heartbeat  30 s ago -> 0
heartbeat  90 s ago -> 0
heartbeat  91 s ago -> 1
no file -> 1
worst case: 185 s

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

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

IN [10]python
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"]))
OUT [10]Two compose files
['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/)..

IN [11]python
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}")
OUT [11]Rules DC01 to DC07
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 findings

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

IN [12]python
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]))
OUT [12]Startup and shutdown order
start: db+prometheus -> bot+grafana
stop:  bot+grafana -> db+prometheus

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

IN [13]python
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

  1. Generalize rule DC05 so that it flags any service without healthcheck that another one depends on with condition: service_healthy, and add a rule DC08 that flags services with build but no image (without a tag, the deployment log cannot say what was deployed).
Solution
IN [14]python
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)
OUT [14]General DC05 and DC08
('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.

  1. Add to the Dockerfile reviewer a rule DF10 that flags ADD with a URL and no --checksum, and test it with ADD 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
IN [15]python
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])
OUT [15]DF10: downloads without a checksum
[('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.

  1. A colleague says that pinning digests is overkill for a personal bot: “python:3.14-slim is 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

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

  2. Docker, “Build context”, section “.dockerignore files” (https://docs.docker.com/build/concepts/context/). ↩

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

  4. Docker, “Dockerfile reference”, HEALTHCHECK (https://docs.docker.com/reference/dockerfile/): defaults --interval=30s, --timeout=30s, --start-period=0s, --retries=3. ↩

  5. Docker, “Start containers automatically” (https://docs.docker.com/engine/containers/start-containers-automatically/). ↩

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

  7. Docker, “Compose file reference: services”, depends_on (https://docs.docker.com/reference/compose-file/services/). ↩

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

  1. 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 slim changes 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 content

    Docker'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 docker ecosystem opens those PRs).

  2. What is the difference between uv sync --locked and uv sync --frozen?

    • ANone: they are synonyms
    • B--frozen downloads faster because it does not check hashes
    • CBoth install what uv.lock says without changing it; --locked also fails if the lockfile is not up to date with pyproject.toml, and --frozen does not check it
    • D--locked locks the environment so that nobody can modify it
    Show answer

    Answer C. Both install what uv.lock says without changing it; --locked also fails if the lockfile is not up to date with pyproject.toml, and --frozen does not check it

    According to the uv reference, --locked requires the lockfile to be up to date and exits with an error if it would need updating; --frozen uses the lockfile as the source of truth without checking it. Astral's guide uses --frozen in the first sync, when only uv.lock is mounted; in the lesson both files are mounted and --locked is used.

  3. In the naive Dockerfile, COPY . . comes before uv sync. What happens when you change one line of estrategia.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 change

    Docker 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.lock and pyproject.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.

  4. The database password goes in environment: POSTGRES_PASSWORD: … in the compose file. What does Docker's documentation propose?

    • ADeclare it under secrets: and pass POSTGRES_PASSWORD_FILE=/run/secrets/db_password to 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 ARG of the Dockerfile
    • DWrite it in .env and run COPY . .
    Show answer

    Answer A. Declare it under secrets: and pass POSTGRES_PASSWORD_FILE=/run/secrets/db_password to the image: Compose mounts the file only in the services that request it and it does not stay in the process environment

    Environment 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 _FILE and __FILE.

  5. 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:3000 and 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:3000 and come in through an SSH tunnel

    Docker'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.lock in the repository, uv sync --locked (or --frozen if pyproject.toml is not there yet), cache mounted with --mount=type=cache and UV_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, ARG or the build context: .dockerignore leaves out .env and secretos/, and Compose mounts secrets as files in /run/secrets/.
  • In compose: digests, depends_on with condition: service_healthy, an internal network with no way out for the database and ports only on 127.0.0.1. A reviewer in Python checks all of that before building.

Resources