---
title: "7.2.1 Docker and docker-compose with uv"
description: "Reproducible images with uv following Astral's guide: digests, lockfile, cache, multi-stage and a non-root user, plus a compose file validated in Python."
url: "https://pythonparatrading.com/en/python-trading-course/level-7/docker-compose-reproducible-images-uv/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-7/docker-compose-reproducible-images-uv.png"
---

# 7.2.1 · 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.

- Course: [Level 7: Production, MLOps, regulation, career and capstone](https://pythonparatrading.com/en/python-trading-course/level-7/)
- 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](https://pythonparatrading.com/en/python-trading-course/level-0/virtual-environments-dependencies-with-uv/), [0.2.5 · Git and GitHub: commits, branches, pull requests and .gitignore](https://pythonparatrading.com/en/python-trading-course/level-0/git-and-github-for-trading/), [4.4.5 · Secure credentials: environment variables, secret managers and least privilege](https://pythonparatrading.com/en/python-trading-course/level-4/secure-api-credentials-and-least-privilege/), [4.5.1 · Live system architecture: from data to reconciliation](https://pythonparatrading.com/en/python-trading-course/level-4/live-trading-system-architecture/)

## By the end of this lesson

- Explain what a Docker image pins and what it does not, and why a tag such as `python:3.14` is not reproducible while a digest is.
- Write a multi-stage Dockerfile with uv: lockfile with `--locked`, mounted uv cache, dependencies in their own layer and a non-root user.
- Order the instructions so that a code change does not rebuild or re-download the dependencies, and measure the difference.
- Write a docker-compose file for a bot, a database and monitoring, with secrets in files, health checks and startup order.
- Validate Dockerfiles and compose files with a reviewer written in Python before building anything.

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

<aside class="callout" data-kind="nota">

The environment where the course cells run has no Docker. The Dockerfiles and compose files in this lesson are written
as text, parsed and validated with Python in cells that really run, and the real commands (`docker build`,
`docker compose up`) appear at the end in a cell that does not run. The image digests were looked up in the registries
on October 23, 2026.

</aside>

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

```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")
```
```output
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:

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

```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"])
```
```output
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 lacks[^1]: 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
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):

```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")
```
```output
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:

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

```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}")
```
```output
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 syntax[^2]: one pattern per line, `**` for
any number of directories and `!` to include again; the last matching rule wins.

```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)))
```
```output
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 different[^3]. 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:

```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")
```
```output
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:

```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}")
```
```output
 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 loop[^4]. 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:

```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")
```
```output
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 **exits**[^5]; 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
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 accident[^6]. 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:

```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"]))
```
```output
['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 pass[^7]; 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 otherwise[^8].

```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}")
```
```output
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:

```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]))
```
```output
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:

```bash
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:

```python skip
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.

<aside class="callout" data-kind="riesgo">

Pinning digests has a cost: security fixes for the base image stop arriving on their own. Docker's documentation
proposes keeping them up to date with Dependabot (`package-ecosystem: "docker"`), which opens a PR when there is a new
digest. A digest pinned two years ago is not reproducibility: it is an unpatched image.

</aside>

## 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.**

```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)
```
```output
('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.

2. 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.**

```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])
```
```output
[('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.

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

[^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/).

## 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?

   - A. Yes: the tag includes the exact Python version
   - 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
   - C. Yes, because Docker caches the image forever
   - D. No, because `slim` changes distribution every month

   *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`?

   - A. None: they are synonyms
   - B. `--frozen` downloads faster because it does not check hashes
   - 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
   - D. `--locked` locks the environment so that nobody can modify it

   *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`?

   - A. Only the code layer is rebuilt
   - B. Nothing: Docker detects that the dependencies have not changed
   - C. The build fails
   - 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

   *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?

   - 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
   - B. Encode it with base64 in the compose file itself
   - C. Put it in an `ARG` of the Dockerfile
   - D. Write it in `.env` and run `COPY . .`

   *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?

   - A. Yes: ufw blocks everything other than 22
   - B. Yes, if Grafana has a password
   - 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
   - D. No, because Grafana opens the port on its own

   *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

### Research

- [Docker for trading](https://pythonparatrading.com/en/docker-for-trading/) · Research-0013 · 4 min

### Documentation

- [uv: Using uv in Docker (Astral's official guide)](https://docs.astral.sh/uv/guides/integration/docker/) · docs.astral.sh
- [Docker: Building best practices (pinning the base image by digest, USER, multiple stages)](https://docs.docker.com/build/building/best-practices/) · docs.docker.com
- [Docker: Build secrets](https://docs.docker.com/build/building/secrets/) · docs.docker.com
- [Docker Compose: services reference (depends_on, healthcheck)](https://docs.docker.com/reference/compose-file/services/) · docs.docker.com
- [Docker Compose: Manage secrets in Compose](https://docs.docker.com/compose/how-tos/use-secrets/) · docs.docker.com
- [Docker: Packet filtering and firewalls (Docker and ufw)](https://docs.docker.com/engine/network/packet-filtering-firewalls/) · docs.docker.com

Previous lesson: [7.1.3 · Patterns: events, message queues and hexagonal architecture](https://pythonparatrading.com/en/python-trading-course/level-7/event-driven-queues-hexagonal-architecture/)

Next lesson: [7.2.2 · VPS, cloud and latency; CI/CD with GitHub Actions](https://pythonparatrading.com/en/python-trading-course/level-7/vps-cloud-latency-ci-cd/)
