Hace unos meses construí una API de machine learning con FastAPI. Nada del otro mundo: un modelo que predecía precios de vivienda. Usaba scikit-learn para el modelo, pandas para procesar los datos, PostgreSQL para guardar las predicciones históricas, y Redis para no tener que estar recalculando predicciones todo el rato.

Todo funcionaba de maravilla. Push a GitHub, deploy, y a otra cosa.

Cuatro meses después volví al proyecto. Quería añadir una nueva feature. "Venga, esto lo levanto en 5 minutos", pensé. Al final no fueron 5 minutos.

$ git clone mi-proyecto
$ cd mi-proyecto
$ uv sync
error: Python 3.11 not found
$ pyenv install 3.11
$ uv sync

Error: psycopg2-binary failed to build

Ah, claro. psycopg2 necesita PostgreSQL instalado en el sistema. Busco en mi historial de brew... nada. ¿Lo instalé con Postgres.app? ¿Con brew? ¿Cuál era el comando exacto? Y ahora resulta que también necesito librerías para procesar imágenes porque el modelo usa Pillow.

Dos horas después seguía instalando cosas con brew en lugar de programar.

¿Te suena familiar?

Ese día entendí algo: el problema no es que tu código sea malo. El problema es que tu código vive en un ecosistema frágil que cambia constantemente.

Y Docker es lo que hace que ese ecosistema deje de ser frágil.

El verdadero problema que todos tenemos

Antes de meternos con Docker, hablemos claro del problema que hay. Tu yo del futuro es un extraño.

Cuando escribes código, tienes todo el contexto en la cabeza:

  • Qué versión de Python usas

  • Qué instalaste con brew hace tres semanas

  • Esas variables de entorno que configuraste en tu .zshrc y olvidaste documentar

  • Ese comando raro de PostgreSQL que corriste una vez

  • Si usaste Postgres.app o PostgreSQL de brew

  • Las librerías del sistema que instalaste para probar algo y quedaron ahí

Seis meses después, ese contexto se evapora. Vuelves al proyecto y es como si lo hubiera escrito otra persona. Una persona que no dejó instrucciones claras.

"Works on my machine" es el meme por una razón

Imagina esto:

Tu Mac:

  • macOS Sonoma

  • Python 3.11 gestionado con pyenv

  • PostgreSQL 15 via Postgres.app

  • Brew con 200 paquetes instalados a lo largo de los años

  • Redis corriendo en background (que instalaste hace meses para otro proyecto)

  • uv configurado con tus preferencias

El servidor de producción:

  • Ubuntu 22.04

  • Python 3.10

  • PostgreSQL 14

  • Sin Redis (ups, olvidaste instalarlo)

  • Sin todas esas librerías que tienes en tu Mac

El Mac de tu colega:

  • macOS Ventura (porque nunca actualiza nada)

  • Python 3.9

  • PostgreSQL instalado con brew hace dos años

  • Nunca ha usado uv, usa pip y virtualenvs

Tu código tiene que funcionar en los tres sitios. Buena suerte con eso.

Qué es Docker

Olvídate de las definiciones técnicas por un momento. Docker es esto:

Una forma de empaquetar tu aplicación con TODO lo que necesita para funcionar, de manera que funcione igual en cualquier sitio.

Cuando digo TODO, me refiero a:

  • La versión exacta de Python

  • Todas las dependencias de Python

  • Todas las dependencias del sistema (librerías, herramientas, etc.)

  • La configuración del entorno

  • Incluso otros servicios como PostgreSQL o Redis

Piensa en Docker como si fueras a empaquetar no solo tu código, sino también un mini sistema operativo configurado exactamente como necesitas. Ese paquete se llama imagen. Y cuando ejecutas esa imagen, se crea un contenedor, que es básicamente tu aplicación corriendo en su propio entorno aislado.

La magia está en que ese contenedor:

  • Funciona igual en tu Mac, en el Mac de tu colega, y en el servidor Linux

  • Está aislado del resto de tu sistema (no interfiere con otros proyectos)

  • Se puede arrancar y parar en segundos

  • Es reproducible: si funciona una vez, funcionará siempre

Tu primera vez con Docker

Vamos a dockerizar ese proyecto de FastAPI + ML del principio.

Instalando Docker

En Mac, tienes varias opciones. La oficial es Docker Desktop, pero yo uso OrbStack. Es más rápido, consume menos recursos, y es gratis para uso personal. Lo instalas con brew:

brew install orbstack

O lo descargas de su web. Una vez instalado, verás su icono en la barra de menú. Es compatible 100% con Docker, así que todos los comandos docker funcionan igual.

Preparando tu proyecto para Docker

Primero, vamos a congelar las dependencias. Con uv esto es súper fácil. Si ya tienes tu proyecto con pyproject.toml y uv.lock, simplemente exporta las dependencias a un requirements.txt:

uv export --no-dev --frozen > requirements.txt

Esto genera un requirements.txt con todas las versiones exactas que tienes en tu uv.lock, sin las dependencias de desarrollo. Es como hacer un "snapshot" de tu entorno. El flag --frozen asegura que usa exactamente lo que está en el lockfile sin intentar resolver nada nuevo.

El Dockerfile: la receta de tu entorno

El Dockerfile es un archivo de texto que contiene las instrucciones para crear tu imagen. Es literalmente la receta de cómo construir el entorno perfecto para tu aplicación.

Abre VS Code en tu proyecto y crea un archivo llamado Dockerfile (sin extensión) en la raíz. VS Code probablemente te sugerirá instalar la extensión de Docker. Hazlo, te ayudará con el autocompletado y el syntax highlighting.

Aquí está el Dockerfile para el proyecto:

# Partimos de una imagen base con Python 3.11
FROM python:3.11-slim

# Instalamos las dependencias del sistema que necesitamos
RUN apt-get update && apt-get install -y \
    gcc \
    postgresql-client \
    libpq-dev \
    libjpeg-dev \
    libpng-dev \
    && rm -rf /var/lib/apt/lists/*

# Instalamos uv (copiando desde su imagen oficial)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Creamos un directorio para nuestra app
WORKDIR /app

# Copiamos el requirements.txt primero (para aprovechar la caché de Docker)
COPY requirements.txt .

# Instalamos las dependencias con uv
# --system instala en el Python del sistema (no en un virtualenv)
RUN --mount=type=cache,target=/root/.cache/uv \
    uv pip install --system -r requirements.txt

# Ahora copiamos el resto del código
COPY . .

# Exponemos el puerto donde correrá FastAPI
EXPOSE 8000

# El comando que se ejecutará cuando arranque el contenedor
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Déjame explicarte cada parte:

FROM python:3.11-slim: Le decimos a Docker que queremos partir de una imagen que ya tiene Python 3.11 instalado. slim significa que es una versión ligera, sin un montón de cosas que no necesitamos.

RUN apt-get...: Aquí instalamos las dependencias del sistema. gcc para compilar cosas, libpq-dev para psycopg2, libjpeg-dev y libpng-dev para Pillow. Esto es lo que normalmente tienes que buscar en Stack Overflow cuando algo no compila. Y el && rm -rf /var/lib/apt/lists/* al final limpia archivos temporales para mantener la imagen más pequeña.

COPY --from=ghcr.io/astral-sh/uv...: Copiamos los binarios de uv (tanto uv como uvx) desde su imagen oficial a /bin/. Es la forma más eficiente de instalar uv en tu imagen.

WORKDIR /app: Creamos y nos movemos a un directorio donde vivirá nuestra aplicación.

COPY requirements.txt .: Copiamos solo el requirements.txt primero. ¿Por qué? Porque Docker usa caché por capas. Si requirements.txt no cambia, no reinstalará todas las dependencias cada vez que construyas la imagen. Esto hace que reconstruir sea súper rápido.

RUN --mount=type=cache...: Aquí instalamos las dependencias. --mount=type=cache mantiene el caché de uv entre builds, haciendo las reconstrucciones mucho más rápidas. uv pip install --system instala directamente en el Python del sistema (no crea un virtualenv). En Docker esto está bien porque el contenedor ya está aislado.

COPY . .: Copiamos el resto del código de la aplicación.

EXPOSE 8000: Le decimos a Docker que nuestra app escucha en el puerto 8000. Esto es solo documentación para quien lea el Dockerfile.

CMD: El comando que se ejecuta cuando arrancas el contenedor. Como instalamos todo en el sistema, uvicorn está disponible directamente.

El archivo .dockerignore

Antes de construir, crea un archivo .dockerignore en la raíz de tu proyecto. Es como .gitignore pero para Docker:

.git
.venv
__pycache__
*.pyc
.pytest_cache
.coverage
.env
*.log
.DS_Store

Esto evita copiar cosas innecesarias al contenedor, haciendo tus builds más rápidos y las imágenes más ligeras. Nota que NO incluimos uv.lock ni pyproject.toml en el dockerignore , aunque no los usamos directamente en el Dockerfile, es buena práctica mantenerlos en el contenedor por si acaso necesitas debuggear algo o reconstruir desde dentro del contenedor.

Construyendo la imagen

Ahora viene lo divertido. Abre la terminal integrada de VS Code (Ctrl+` o View > Terminal) y corre:

docker build -t mi-api-ml .

Verás un montón de output mientras Docker descarga la imagen base de Python, instala las dependencias del sistema, y corre uv pip install. La primera vez tarda un rato (puede ser un par de minutos). Las siguientes veces será mucho más rápido gracias al caché.

El -t mi-api-ml le pone un nombre (tag) a tu imagen. El . al final le dice "usa el Dockerfile que está en este directorio".

Corriendo tu aplicación

Una vez que la imagen está construida, córrela:

docker run -p 8000:8000 mi-api-ml

Boom. Tu FastAPI está corriendo. El -p 8000:8000 mapea el puerto 8000 del contenedor al puerto 8000 de tu Mac.

Para parar el contenedor, haz Ctrl+C en la terminal.

Si quieres correrlo en background (detached mode):

docker run -d -p 8000:8000 mi-api-ml

Para ver los contenedores corriendo:

docker ps

Y para pararlo:

docker stop <container-id>

Variables de entorno

Tu aplicación probablemente necesita variables de entorno. Puedes pasarlas al correr el contenedor:

docker run -p 8000:8000 \
  -e DATABASE_URL="postgresql://user:pass@localhost/db" \
  -e REDIS_URL="redis://localhost:6379" \
  mi-api-ml

O mejor aún, crea un archivo .env (y añádelo a tu .gitignore) y úsalo:

docker run -p 8000:8000 --env-file .env mi-api-ml

Docker Compose: cuando necesitas más que tu app

Aquí es donde Docker se pone realmente interesante. Tu API de ML probablemente necesita PostgreSQL y Redis. Podrías instalarlos en tu Mac con brew, pero entonces volvemos al problema original: dependencias del sistema, configuraciones que olvidar, puertos que gestionar.

Docker Compose te permite definir múltiples contenedores y cómo se relacionan entre sí, todo en un archivo YAML.

Crea un archivo docker-compose.yml en la raíz de tu proyecto:

services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      -DATABASE_URL=postgresql://postgres:postgres@db:5432/mlapi
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=mlapi
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Este archivo define tres servicios:

web: Tu aplicación FastAPI. Usa el Dockerfile que creamos antes (build: .).

db: PostgreSQL 15. Usamos la versión Alpine que es más ligera. Los datos se guardan en un volumen llamado postgres_data para que persistan cuando pares el contenedor. El healthcheck se asegura de que PostgreSQL esté listo antes de arrancar la app.

redis: Redis 7, también Alpine. Sencillo y efectivo.

Corriendo todo el stack

Ahora, en lugar de docker run, usas:

docker compose up

Y eso es todo. Docker levanta PostgreSQL, Redis, y tu aplicación, todo conectado y funcionando. Verás los logs de los tres servicios en tu terminal.

Si quieres correrlo en background:

docker compose up -d

Para ver los logs:

docker compose logs -f web

Para parar todo:

docker compose down

Y si quieres borrar también los volúmenes (los datos de la base de datos):

docker compose down -v

El flujo de desarrollo real

Con Docker Compose, tu flujo de trabajo se ve así:

  1. Abres VS Code

  2. docker compose up -d (levantas el stack)

  3. Programas normalmente

  4. Cuando cambias dependencias:

    • uv add nueva-libreria (añade la dependencia)

    • uv export --no-dev --frozen > requirements.txt (exporta de nuevo)

    • docker compose build web (reconstruye la imagen)

    • docker compose up -d (levanta de nuevo)

  5. Cuando cambias solo código: no necesitas hacer nada si estás usando el compose de desarrollo con volúmenes montados

  6. Cuando terminas: docker compose down

No tienes que preocuparte de si PostgreSQL está corriendo, qué versión tienes, o si Redis está configurado correctamente. Todo simplemente funciona.

Tips y mejores prácticas

Después de usar Docker durante un tiempo, aquí van algunos consejos:

1. Mantén sincronizados pyproject.toml y requirements.txt

Cada vez que añadas o actualices dependencias:

# Añades una dependencia
uv add requests

# Exportas de nuevo
uv export --no-dev --frozen > requirements.txt

# Reconstruyes la imagen
docker compose build web

2. Versiona tu requirements.txt

Añade requirements.txt a git, pero mantén uv.lock también. El lockfile es tu fuente de verdad para desarrollo local, pero requirements.txt es lo que Docker usa.

3. Usa multi-stage builds para producción

Para producción, no necesitas las herramientas de compilación en la imagen final. Un multi-stage build separa la fase de construcción de la fase de ejecución:

# Stage 1: Build
FROM python:3.11-slim AS builder

RUN apt-get update && apt-get install -y \
    gcc \
    libpq-dev \
    libjpeg-dev \
    libpng-dev \
    && rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv pip install --system -r requirements.txt

# Stage 2: Runtime
FROM python:3.11-slim

# Solo las librerías runtime necesarias, no las de compilación
RUN apt-get update && apt-get install -y \
    libpq5 \
    libjpeg62-turbo \
    libpng16-16 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copiamos solo los paquetes instalados desde el builder
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin

# Copiamos el código de la aplicación
COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Esto reduce el tamaño de la imagen final dramáticamente (a veces hasta en un 50-60%) porque no incluyes gcc ni otras herramientas de compilación. Solo copias las librerías runtime que realmente necesitas (como libpq5 en lugar de libpq-dev).

4. Versiona específicamente las imágenes

En requirements.txt ya tienes versiones específicas (gracias a uv export --frozen), pero también considera pinnear la versión de uv en el Dockerfile:

COPY --from=ghcr.io/astral-sh/uv:0.5.9 /uv /uvx /bin/

O mejor aún, usa el SHA específico de la imagen para máxima reproducibilidad:

COPY --from=ghcr.io/astral-sh/uv@sha256:... /uv /uvx /bin/

Puedes encontrar el SHA en el GitHub Container Registry de uv.

6. Aprovecha VS Code

La extensión de Docker en VS Code es espectacular. Te permite:

  • Ver todos tus contenedores corriendo

  • Ver los logs sin salir del editor

  • Ejecutar comandos dentro de contenedores

  • Hacer attach a un contenedor para debuggear

7. Comandos útiles para el día a día

# Ver contenedores corriendo
docker ps

# Ver todos los contenedores (incluso parados)
docker ps -a

# Ver imágenes
docker images

# Limpiar contenedores parados
docker container prune

# Limpiar imágenes sin usar
docker image prune

# Limpiar TODO (cuidado con esto)
docker system prune -a

# Ver logs de un servicio específico
docker compose logs web -f

# Ejecutar un comando en un contenedor corriendo
docker compose exec web python manage.py migrate

# Entrar a una shell en el contenedor
docker compose exec web bash

# Reconstruir un solo servicio
docker compose build web

# Forzar recreación de contenedores
docker compose up --force-recreate

Cuándo NO usar Docker

Docker no es la solución para todo. Hay casos donde complica más de lo que ayuda:

Scripts simples: Si es un script de una sola vez que procesa un CSV, probablemente no necesitas Docker.

Proyectos muy pequeños: Si tu proyecto es literalmente un archivo .py con dos dependencias, Docker es overkill.

Cuando estás explorando: Si estás haciendo un notebook de Jupyter explorando datos, no necesitas dockerizar eso. Docker es para cuando quieres que algo funcione de manera confiable, no para exploración rápida.

El momento "aha"

La verdadera magia de Docker no es técnica. Es psicológica.

Es ese momento cuando vuelves a un proyecto después de seis meses y funciona a la primera. Es cuando tu colega clona el repo y está productivo en cinco minutos. Es cuando despliegas a producción y no hay sorpresas porque el entorno es idéntico al de desarrollo.

Es la tranquilidad de saber que tu proyecto no va a romperse porque actualizaste algo en tu sistema. Es la libertad de poder trabajar en múltiples proyectos sin que interfieran entre sí.

Docker no hace que tu código sea mejor. Pero hace que tu código sea más resistente al tiempo y al cambio.

Recursos para seguir aprendiendo

Si quieres profundizar más:

Hasta aquí este artículo. Espero que te haya gustado. Es la forma en la que yo trabajo con Docker en el día a día y la que mejor me ha funcionado para no pelearme con el entorno. Si te sirve, misión cumplida. Gracias por leer hasta aquí, y nos vemos en el siguiente!

Docker no va de contenedores, imágenes o comandos. Va de poder centrarte en el código sin pelearte con el entorno. Cuando eso pasa, sabes que lo estás usando bien.

Keep Reading