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 buildAh, 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
.zshrcy olvidaste documentarEse 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 orbstackO 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.txtEsto 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_StoreEsto 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-mlBoom. 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-mlPara ver los contenedores corriendo:
docker psY 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-mlO 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-mlDocker 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 upY 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 -dPara ver los logs:
docker compose logs -f webPara parar todo:
docker compose downY si quieres borrar también los volúmenes (los datos de la base de datos):
docker compose down -vEl flujo de desarrollo real
Con Docker Compose, tu flujo de trabajo se ve así:
Abres VS Code
docker compose up -d(levantas el stack)Programas normalmente
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)
Cuando cambias solo código: no necesitas hacer nada si estás usando el compose de desarrollo con volúmenes montados
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 web2. 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-recreateCuá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:
Documentación oficial de Docker: https://docs.docker.com/ - Sorprendentemente bien escrita y con buenos tutoriales
Docker Compose docs: https://docs.docker.com/compose/ - Todo lo que necesitas saber sobre Compose
OrbStack docs: https://docs.orbstack.dev/ - Documentación de OrbStack, con tips específicos para Mac
uv con Docker: https://docs.astral.sh/uv/guides/integration/docker/ - Guía oficial de Astral sobre cómo usar uv en Docker
FastAPI con Docker: https://fastapi.tiangolo.com/deployment/docker/ - La guía oficial de FastAPI tiene una sección excelente sobre Docker
Python Speed: https://pythonspeed.com/docker/ - Blog con artículos profundos sobre optimización de imágenes de Docker para Python
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.

