Contenido

Tutorial de Python con Docker: despliega aplicaciones Flask, FastAPI y Django

En esta sección usaremos Flask como ejemplo principal y recorreremos todo el proceso: estructura del proyecto, Dockerfile, compilación, ejecución y modo de desarrollo. Después explicaremos las diferencias con FastAPI, Django y uv. Todos los ejemplos usan la imagen base python:3.14-slim.

Flask: estructura del proyecto

flask-app/
├── app.py
├── requirements.txt
├── Dockerfile
├── .dockerignore
└── compose.yaml      ← Solo se usa en modo de desarrollo

app.py:

from flask import Flask

app = Flask(__name__)


@app.get("/")
def index():
    return {"message": "Hello from Flask in Docker"}

requirements.txt:

flask==3.1.*
gunicorn==23.*

Escribir la versión como 3.1.* significa que se aceptan las revisiones de la serie 3.1, pero no se actualiza automáticamente a 3.2. En proyectos de producción, se recomienda fijar la versión completa con pip freeze o uv (consulta la sección siguiente).

Flask: Dockerfile

FROM python:3.14-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .

RUN useradd --system --no-create-home app
USER app

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]

Explicación línea por línea:

ConfiguraciónFunción
python:3.14-slimVersión ligera basada en Debian, con glibc. La mayoría de los paquetes tienen wheels precompiladas que se pueden instalar directamente.
PYTHONUNBUFFERED=1La salida no pasa por el búfer, así que puedes verla en tiempo real con docker logs.
PYTHONDONTWRITEBYTECODE=1No genera archivos .pyc.
PIP_NO_CACHE_DIR=1pip no conserva la caché de descargas, lo que reduce el tamaño de la imagen.
Primero COPY requirements.txt y luego pip installSi solo cambias el código, la capa de instalación de paquetes se puede reutilizar desde la caché. Consulta Caché de compilación.
useradd + USER appEjecuta la aplicación con un usuario distinto de root. Consulta Seguridad de Docker.
gunicorn ... app:appEn producción se usa gunicorn en lugar de flask run (el servidor de desarrollo). app:app significa «la variable app del módulo app.py».
--bind 0.0.0.0:8000Debes vincularlo a 0.0.0.0. Si lo vinculas a 127.0.0.1, no podrás conectarte desde fuera del contenedor.

CMD usa el formato exec (un arreglo JSON). gunicorn es el PID 1, así que recibe directamente la señal SIGTERM que envía docker stop y termina de forma ordenada. No espera 10 segundos hasta que se detenga a la fuerza, como en el ejemplo de Instrucciones de Dockerfile en detalle.

.dockerignore:

__pycache__
*.pyc
.venv
.env
.git

Debes excluir .venv: el entorno virtual local es para el sistema operativo de tu equipo. Copiarlo al contenedor no sirve y además aumenta el tamaño del contexto de compilación.

Flask: construir y ejecutar

$ ls -a
.
..
.dockerignore
Dockerfile
app.py
compose.yaml
requirements.txt

$ docker build -t flask-app .

[+] Building 6.0s (11/11) FINISHED                                                                                                     docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 364B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/python:3.14-slim                                                                              0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 74B                                                                                                                 0.0s
 => [1/6] FROM docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.8s
 => => resolve docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.0s
 => => extracting sha256:a5883d972f3b60387a858b212a0a24115f143c826e40fdef3270e8474a8f42d6                                                        0.1s
 => => extracting sha256:6aaf5b0aae75c38277901ef64362f50475b879e0ff3ed6610eb2a9f69bcf3295                                                        0.7s
 => => extracting sha256:bb8166eb34e6e8ea17f8c73a7eec8438693d91ebf29eda1d9d3c0dba36b579de                                                        0.0s
 => [internal] load build context                                                                                                                0.1s
 => => transferring context: 907B                                                                                                                0.0s
 => [2/6] WORKDIR /app                                                                                                                           0.1s
 => [3/6] COPY requirements.txt .                                                                                                                0.0s
 => [4/6] RUN pip install -r requirements.txt                                                                                                    3.9s
 => [5/6] COPY . .                                                                                                                               0.0s
 => [6/6] RUN useradd --system --no-create-home app                                                                                              0.3s
 => exporting to image                                                                                                                           0.7s
 => => exporting layers                                                                                                                          0.5s
 => => exporting manifest sha256:7189a55c270e8f9c73535044b6bd76d012e11017d9fb01e26f8da841870a0c0d                                                0.0s
 => => exporting config sha256:d34e60a2d4bdc7b72f16e2673fd83a3f058e8d73a3a12c5b368a250170caeb43                                                  0.0s
 => => exporting attestation manifest sha256:171f89e1a4fd243b116baac7aa4798fa03b80160a5dad67f360f825635782aa5                                    0.0s
 => => exporting manifest list sha256:e38ff17f0c938ac71cb8683d8b2527ece5278ac450b1b989173178502d20ee47                                           0.0s
 => => naming to docker.io/library/flask-app:latest                                                                                              0.0s
 => => unpacking to docker.io/library/flask-app:latest                                                                                           0.2s

El paso [4/6] RUN pip install tardó 3.9 segundos y fue el más largo de la compilación. Si vuelves a compilar, siempre que requirements.txt no haya cambiado, este paso mostrará CACHED.

Ejecuta la aplicación en segundo plano y pruébala:

$ docker run -d --name flask -p 8000:8000 flask-app
4d1e3914573d0ef92e9b11840475100237868cb8aaaddefc8f3765a2b5a7c8ce

$ curl -s http://localhost:8000
{"message":"Hello from Flask in Docker"}

$ docker logs flask
[2026-10-13 00:26:21 +0000] [1] [INFO] Starting gunicorn 23.0.0
[2026-10-13 00:26:21 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)
[2026-10-13 00:26:21 +0000] [1] [INFO] Using worker: sync
[2026-10-13 00:26:21 +0000] [6] [INFO] Booting worker with pid: 6
[2026-10-13 00:26:21 +0000] [7] [INFO] Booting worker with pid: 7

$ docker exec flask whoami
app
  • Que curl devuelva JSON indica que el servicio funciona. Si abres http://localhost:8000 en el navegador, verás el mismo contenido.
  • En la salida de docker logs aparecen el proceso principal de gunicorn (pid 1) y los dos workers (--workers 2).
  • whoami muestra app, lo que confirma que no se está ejecutando como root.

Por lo general, el número de workers se establece en unas 2–4 veces el número de núcleos de CPU. gunicorn también puede leer el número de workers de la variable de entorno WEB_CONCURRENCY, pero solo tiene efecto si no se especifica --workers en el comando. El CMD de este Dockerfile ya incluye --workers 2, así que establecer WEB_CONCURRENCY no tendrá efecto (en la práctica, siguen siendo 2 workers).

Modo de desarrollo: recarga automática al modificar el código

Durante el desarrollo, reconstruir la imagen cada vez que cambias el código es demasiado lento. Usa Compose para montar la carpeta del código en el contenedor y cambia al servidor de desarrollo de Flask (--debug vuelve a cargar la aplicación automáticamente cuando cambian los archivos):

compose.yaml:

services:
  web:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - .:/app
    command: ["flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "8000", "--debug"]

command reemplaza el CMD del Dockerfile, así que puedes usar gunicorn en producción y el servidor de desarrollo de Flask durante el desarrollo con el mismo Dockerfile.

$ docker compose up -d

[+] Building 0.6s (13/13) FINISHED
 => [internal] load local bake definitions                                                                                                       0.0s
 => => reading from stdin 468B                                                                                                                   0.0s
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 364B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/python:3.14-slim                                                                              0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 74B                                                                                                                 0.0s
 => [1/6] FROM docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.0s
 => => resolve docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 297B                                                                                                                0.0s
 => CACHED [2/6] WORKDIR /app                                                                                                                    0.0s
 => CACHED [3/6] COPY requirements.txt .                                                                                                         0.0s
 => CACHED [4/6] RUN pip install -r requirements.txt                                                                                             0.0s
 => CACHED [5/6] COPY . .                                                                                                                        0.0s
 => CACHED [6/6] RUN useradd --system --no-create-home app                                                                                       0.0s
 => exporting to image                                                                                                                           0.3s
 => => exporting layers                                                                                                                          0.0s
 => => exporting manifest sha256:b30dc1eb5dd7e3ae2b911558671243f8e2aeb4eb916b88ba0f4a1b6f59612147                                                0.0s
 => => exporting config sha256:5f7347cc4514246282f5be1a4e1eb3c7cfe90b0ee36c9adeaed934c91bcb2311                                                  0.0s
 => => exporting attestation manifest sha256:2498fb10cf0b98b8be07e54464eaa656528a1fa9324d15544ab7beb474766027                                    0.0s
 => => exporting manifest list sha256:34a36fcb4d656d04ad0e8f86740d02d314d5c68b67b1ff4fb1b897f053bdb39e                                           0.0s
 => => naming to docker.io/library/flask-web:latest                                                                                              0.0s
 => => unpacking to docker.io/library/flask-web:latest                                                                                           0.2s
 => resolving provenance for metadata file                                                                                                       0.0s
[+] up 3/3
 ✔ Image flask-web       Built                                                                                                                    0.7s
 ✔ Network flask_default Created                                                                                                                  0.0s
 ✔ Container flask-web-1 Started                                                                                                                  0.3s

$ curl -s http://localhost:8000
{
  "message": "Hello from Flask in Docker"
}

$ docker compose logs web
web-1  |  * Serving Flask app 'app'
web-1  |  * Debug mode: on
web-1  | WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
web-1  |  * Running on all addresses (0.0.0.0)
web-1  |  * Running on http://127.0.0.1:8000
web-1  |  * Running on http://172.19.0.2:8000
web-1  | Press CTRL+C to quit
web-1  |  * Restarting with stat
web-1  |  * Debugger is active!
web-1  |  * Debugger PIN: 127-885-458
web-1  | 172.19.0.1 - - [13/Oct/2026 00:27:50] "GET / HTTP/1.1" 200 -

El mensaje WARNING: This is a development server en los registros es normal: te recuerda que solo debes usarlo durante el desarrollo. Cuando Debug mode: on, Flask da formato automático al JSON de la respuesta en varias líneas.

Ahora cambia el mensaje de app.py a Hello again, reloaded! con tu editor y guarda el archivo. No hace falta reconstruir la imagen ni reiniciar el contenedor. Vuelve a hacer la solicitud:

$ curl -s http://localhost:8000
{
  "message": "Hello again, reloaded!"
}

$ docker compose logs --tail 6 web
web-1  | 172.19.0.1 - - [13/Oct/2026 00:26:34] "GET / HTTP/1.1" 200 -
web-1  |  * Detected change in '/app/app.py', reloading
web-1  |  * Restarting with stat
web-1  |  * Debugger is active!
web-1  |  * Debugger PIN: 202-909-220
web-1  | 172.19.0.1 - - [13/Oct/2026 00:26:37] "GET / HTTP/1.1" 200 -

Detected change in '/app/app.py', reloading en los registros indica que Flask detectó el cambio en el archivo y volvió a cargar la aplicación. Esto ocurre porque .:/app monta directamente la carpeta local en /app dentro del contenedor, así que el contenedor ve los mismos archivos que estás editando.

Cuando termines de desarrollar, ejecuta docker compose down para detener y eliminar el contenedor.

Nota para Windows y macOS: en algunos casos, el uso compartido de archivos de Docker Desktop no transmite las notificaciones de cambios. Si la recarga automática no funciona, puedes guardar el proyecto en el sistema de archivos de WSL 2 (Windows) o usar la función develop.watch de Compose.

FastAPI

FastAPI usa uvicorn como servidor ASGI. requirements.txt:

fastapi[standard]>=0.115

fastapi[standard] también instala uvicorn y otros paquetes habituales. main.py:

from fastapi import FastAPI

app = FastAPI(title="Todo API")


@app.get("/")
def index():
    return {"message": "Hello from FastAPI"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
    return {"item_id": item_id, "q": q}

El Dockerfile es casi igual al de Flask; solo cambia el comando de inicio de la última línea:

FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Construye y ejecuta (-q solo muestra el ID de la imagen):

$ docker build -q -t fastapi-app .
sha256:cd8e9d2a7b6db3a54ecab866c502794cc91f11af78efbcb862eeac962566d2ac

$ docker run -d --name fastapi -p 8000:8000 fastapi-app
b40cf8583a166c85dae4560f6356f201de8eb67c919f3fb709964cd635f0b934

$ curl -s "http://localhost:8000/items/42?q=docker"
{"item_id":42,"q":"docker"}

$ docker exec fastapi pip list 2>/dev/null | grep -iE "^(fastapi|uvicorn|pydantic) "
fastapi                                  0.143.0
pydantic                                 2.14.1
uvicorn                                  0.54.0

/items/42?q=docker devuelve {"item_id":42,"q":"docker"}; el parámetro de ruta 42 se convierte automáticamente en un entero. Puedes comprobar las versiones instaladas realmente en el contenedor con pip list.

FastAPI genera automáticamente la documentación de la API. Abre http://localhost:8000/docs:

Interfaz Swagger UI de la documentación de la API generada automáticamente por FastAPI
Documentación de la API generada automáticamente por FastAPI (/docs); el título procede de FastAPI(title="Todo API")

Durante el desarrollo, añade --reload al comando uvicorn y monta la carpeta del código para usar el mismo modo de desarrollo de Flask de arriba. Si necesitas varios workers en producción, puedes añadir --workers 4.

Django

Crea un proyecto Django con un contenedor

No necesitas instalar Python ni Django en tu equipo; puedes generar el proyecto con un contenedor. Primero, prepara requirements.txt:

django==6.*
gunicorn==23.*

Y un Dockerfile para desarrollo:

FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

Primero, construye la imagen una vez (todavía no hay ningún proyecto Django). Luego, ejecútala para usar django-admin startproject y monta la carpeta actual para escribir allí los archivos generados. Después, vuelve a construir la imagen para incluir los archivos del proyecto:

$ docker build -q -t django-app .
sha256:1ed89110a11971cd5fdec6e565d389a88ffb106c4fe0093274a4923b8d2fc954

$ docker run --rm -v "$(pwd):/app" -w /app django-app django-admin startproject mysite .

$ ls
Dockerfile
manage.py
mysite
requirements.txt

$ docker build -q -t django-app .
sha256:4f6c035057a7507b0a1a6009c6a67506e58c8c90684e4ec7a5df9300e02ff628

$ docker run -d --name django -p 8000:8000 django-app
a4b4df39bd719fb6bee85a6c4f90cc912586b4c80efce9aa43fc1b76522a9c6f

$ docker exec django python -m django --version
6.1.2

$ docker run --rm django-app python manage.py migrate | tail -n 3
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

Equivalente en Windows (haz clic en las pestañas para cambiar):

docker run --rm -v "$(pwd):/app" -w /app django-app django-admin startproject mysite .
docker run --rm -v "${PWD}:/app" -w /app django-app django-admin startproject mysite .
docker run --rm -v "%cd%:/app" -w /app django-app django-admin startproject mysite .
  • ls muestra que se han añadido manage.py y mysite/; los ha creado django-admin dentro del contenedor. En Linux, estos archivos pertenecerán a root. Puedes añadir --user "$(id -u):$(id -g)" para que te pertenezcan.
  • python -m django --version muestra 6.1.2.
  • migrate se ejecuta en un contenedor temporal con docker run --rm. Este ejemplo usa SQLite, el valor predeterminado de Django. El archivo de base de datos está en el contenedor temporal y desaparece al eliminarlo. Para un proyecto real, usa PostgreSQL; consulta más abajo.

Abre http://localhost:8000 para ver la página de bienvenida de Django:

Página de bienvenida que confirma que Django 6.1 se ha instalado correctamente
Página de inicio predeterminada de Django 6.1 (solo se muestra cuando DEBUG=True y todavía no se ha configurado ninguna URL)

Producción: collectstatic y gunicorn

En producción no se usa runserver, sino gunicorn; primero hay que recopilar los archivos estáticos (CSS, JS e imágenes del admin) en una misma carpeta con collectstatic y luego servirlos con Nginx o WhiteNoise. Crea Dockerfile.prod:

FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "mysite.wsgi:application"]

La compilación directa falla:

$ docker build -f Dockerfile.prod -t django-prod .
... (Traceback omitido)
1.787 django.core.exceptions.ImproperlyConfigured: You're using the staticfiles app without having set the STATIC_ROOT setting to a filesystem path.
------
Dockerfile.prod:7
--------------------
   5 |     RUN pip install -r requirements.txt
   6 |     COPY . .
   7 | >>> RUN python manage.py collectstatic --noinput
   8 |     EXPOSE 8000
   9 |     CMD ["gunicorn", "--bind", "0.0.0.0:8000", "mysite.wsgi:application"]
--------------------
ERROR: failed to build: failed to solve: process "/bin/sh -c python manage.py collectstatic --noinput" did not complete successfully: exit code: 1

El mensaje de error es claro: el settings.py generado por startproject no configura STATIC_ROOT, así que collectstatic no sabe dónde recopilar los archivos. Añade esto al final de mysite/settings.py:

STATIC_ROOT = BASE_DIR / "staticfiles"

Vuelve a compilar y se completará correctamente:

$ docker build -f Dockerfile.prod -t django-prod .

[+] Building 3.7s (11/11) FINISHED                                                                                                     docker:default
 => [internal] load build definition from Dockerfile.prod                                                                                        0.0s
 => => transferring dockerfile: 343B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/python:3.14-slim                                                                              0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 2B                                                                                                                  0.0s
 => [1/6] FROM docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.0s
 => => resolve docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 3.58kB                                                                                                              0.0s
 => CACHED [2/6] WORKDIR /app                                                                                                                    0.0s
 => CACHED [3/6] COPY requirements.txt .                                                                                                         0.0s
 => CACHED [4/6] RUN pip install -r requirements.txt                                                                                             0.0s
 => [5/6] COPY . .                                                                                                                               0.0s
 => [6/6] RUN python manage.py collectstatic --noinput                                                                                           1.5s
 => exporting to image                                                                                                                           2.1s
 => => exporting layers                                                                                                                          0.1s
 => => exporting manifest sha256:961f8d60c4ec63f3e3e5c41acbdef0b9df6a967dbd806f9d4d178865a5722dd4                                                0.0s
 => => exporting config sha256:8a741faf7acfcad1032b73ec67edbe2717396c8db8fe80c969a3b87903cf0c11                                                  0.0s
 => => exporting attestation manifest sha256:651df3cadbdeb850b8ca4ec2c4cd5a30b77e67f1789ba5bb2ab05c42d17fc717                                    0.0s
 => => exporting manifest list sha256:0e28a34c1ea5ce9838ea3c7204938979ab6a43e7b16d121c059946d6e7a0df01                                           0.0s
 => => naming to docker.io/library/django-prod:latest                                                                                            0.0s
 => => unpacking to docker.io/library/django-prod:latest                                                                                         1.9s

$ docker run -d --name django -p 8000:8000 django-prod
e23efa1aa481b13393faf41f4fb4ff842dee0db8053908846442824dbed97625

$ docker logs django
[2026-10-13 00:28:24 +0000] [1] [INFO] Starting gunicorn 23.0.0
[2026-10-13 00:28:24 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)
[2026-10-13 00:28:24 +0000] [1] [INFO] Using worker: sync
[2026-10-13 00:28:24 +0000] [6] [INFO] Booting worker with pid: 6

$ curl -sI http://localhost:8000/ | head -n 1
HTTP/1.1 200 OK

$ docker exec django ls staticfiles
admin

Los registros muestran que gunicorn inició y que staticfiles/ contiene los archivos estáticos recopilados de admin.

Antes de ponerlo en producción, ajusta también settings.py: DEBUG = False, configura ALLOWED_HOSTS y haz que SECRET_KEY se lea desde una variable de entorno (consulta Variables de entorno).

Migraciones de la base de datos

No incluyas migrate en CMD para que se ejecute automáticamente cada vez que se inicia (varios contenedores podrían migrar a la vez); ejecútalo una vez aparte durante el despliegue:

docker compose run --rm web python manage.py migrate

Puedes consultar Ejecutar PostgreSQL con Docker para ver una configuración de Compose con PostgreSQL.

Gestionar paquetes con uv

uv es un gestor de paquetes de Python muy rápido. Declara las dependencias en pyproject.toml y fija las versiones exactas de todos los paquetes, incluidas las dependencias indirectas, en uv.lock. Adapta el ejemplo anterior de Flask para usar uv:

pyproject.toml:

[project]
name = "flask-uv"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
    "flask>=3.1",
    "gunicorn>=23",
]

Dockerfile:

FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv

WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy

COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev --no-install-project

COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev

ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
ConfiguraciónFunción
COPY --from=ghcr.io/astral-sh/uv:0.11Copia directamente el ejecutable de uv desde la imagen oficial de uv, sin instalarlo con pip
--mount=type=cacheMontaje de caché de BuildKit. Los paquetes que descarga uv se comparten entre compilaciones, pero no quedan en la imagen
--lockedFalla si uv.lock y pyproject.toml no coinciden, para garantizar que las versiones instaladas sean exactamente las fijadas en el archivo de bloqueo
Primera ejecución de sync --no-install-projectPrimero instala solo las dependencias; esta capa se puede almacenar en caché. En la segunda ejecución se añade el proyecto
UV_COMPILE_BYTECODE=1Precompila los archivos .pyc durante la instalación para acelerar el inicio del contenedor
ENV PATH=...Hace que comandos como gunicorn usen directamente la versión del entorno .venv

Primero genera uv.lock y súbelo a Git. Si tienes uv instalado localmente, basta con ejecutar uv lock; si no, puedes generarlo con un contenedor. Ten en cuenta que la imagen ghcr.io/astral-sh/uv solo contiene el ejecutable de uv, no Python. Si ejecutas uv lock dentro de ella, aparecerá el error Failed to discover managed Python installations, así que usa una imagen de Python:

$ docker run --rm -v "$PWD:/app" -w /app python:3.14-slim sh -c "pip install -q --root-user-action=ignore uv && uv lock"
Using CPython 3.14.8 interpreter at: /usr/local/bin/python3
Resolved 9 packages in 96ms

$ ls -a
.
..
.dockerignore
Dockerfile
app.py
pyproject.toml
uv.lock

$ docker build -t flask-uv .

[+] Building 8.3s (14/14) FINISHED                                                                                                     docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 497B                                                                                                             0.0s
 => [internal] load metadata for ghcr.io/astral-sh/uv:0.11                                                                                       0.0s
 => [internal] load metadata for docker.io/library/python:3.14-slim                                                                              0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 63B                                                                                                                 0.0s
 => FROM ghcr.io/astral-sh/uv:0.11@sha256:77280f2f771df71f90786c314fe1bbc1e023feac652969bbf139c280babf2eb7                                       0.7s
 => => resolve ghcr.io/astral-sh/uv:0.11@sha256:77280f2f771df71f90786c314fe1bbc1e023feac652969bbf139c280babf2eb7                                 0.0s
 => => extracting sha256:42b502055136e736d5e1d3f5be28fa847276dafc7a55e3fc6bbee14532b41021                                                        0.6s
 => => extracting sha256:95d48f9d48ecf28dfaf61ee94d6692294fd1c4c0527f9d926d6906744b6e3a1d                                                        0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 28.88kB                                                                                                             0.0s
 => CACHED [stage-0 1/7] FROM docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170         0.0s
 => => resolve docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.0s
 => [stage-0 2/7] COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv                                                                    0.1s
 => [stage-0 3/7] WORKDIR /app                                                                                                                   0.0s
 => [stage-0 4/7] COPY pyproject.toml uv.lock ./                                                                                                 0.0s
 => [stage-0 5/7] RUN --mount=type=cache,target=/root/.cache/uv     uv sync --locked --no-dev --no-install-project                               2.3s
 => [stage-0 6/7] COPY . .                                                                                                                       0.1s
 => [stage-0 7/7] RUN --mount=type=cache,target=/root/.cache/uv     uv sync --locked --no-dev                                                    0.6s
 => exporting to image                                                                                                                           4.2s
 => => exporting layers                                                                                                                          3.3s
 => => exporting manifest sha256:08d48ef913a933c9b86dfde0768976f21c10d0a39e77e87f14221c2379fa7231                                                0.0s
 => => exporting config sha256:b7444ccd0f3b14cf614f33f8e0e50b47798a97685e3d8fc8afa400731bde86ae                                                  0.0s
 => => exporting attestation manifest sha256:752398dc502698e4bf24a28a5fbfb7fff9940084c5ae815f7687822c96d0cbc5                                    0.0s
 => => exporting manifest list sha256:08accafba065f4996215355e6b25a29c024354ea5853bd31cd1eadc0cb15efb7                                           0.0s
 => => naming to docker.io/library/flask-uv:latest                                                                                               0.0s
 => => unpacking to docker.io/library/flask-uv:latest                                                                                            0.8s

$ docker run -d --name flaskuv -p 8000:8000 flask-uv
6a6fcaf04c1005467f010e1016981c1309fc59f6ed363c29dda8c4f5bd657eed

$ curl -s http://localhost:8000
{"message":"Hello from Flask in Docker"}

$ docker exec flaskuv which gunicorn
/app/.venv/bin/gunicorn

$ docker images flask-uv
IMAGE             ID             DISK USAGE   CONTENT SIZE   EXTRA
flask-uv:latest   08accafba065        266MB         67.8MB   U

Equivalente para Windows (selecciona una pestaña para cambiar):

docker run --rm -v "$PWD:/app" -w /app python:3.14-slim sh -c "pip install -q --root-user-action=ignore uv && uv lock"
docker run --rm -v "${PWD}:/app" -w /app python:3.14-slim sh -c "pip install -q --root-user-action=ignore uv && uv lock"
docker run --rm -v "%cd%:/app" -w /app python:3.14-slim sh -c "pip install -q --root-user-action=ignore uv && uv lock"

which gunicorn muestra /app/.venv/bin/gunicorn, lo que indica que se está usando el entorno virtual creado por uv.

Comparación del tamaño de las imágenes

$ docker images --filter reference=flask-app --filter reference=fastapi-app --filter reference=django-app --filter reference=python:3.14-slim
IMAGE                ID             DISK USAGE   CONTENT SIZE   EXTRA
django-app:latest    4f6c035057a7        243MB         52.7MB
fastapi-app:latest   cd8e9d2a7b6d        300MB         68.9MB
flask-app:latest     e38ff17f0c93        189MB         45.8MB
python:3.14-slim     a2b82f3c4855        180MB         45.7MB
ImagenTamaño adicional respecto a la imagen baseMotivo principal
flask-appAproximadamente 9 MBFlask y gunicorn son pequeños
django-appAproximadamente 63 MBDjango ocupa más espacio
fastapi-appAproximadamente 120 MBfastapi[standard] incluye varios paquetes, como uvicorn, pydantic-core, httptools y uvloop

La imagen flask-uv, compilada con uv, ocupa 266 MB, más que flask-app, porque incluye el ejecutable de uv y los archivos .pyc generados por UV_COMPILE_BYTECODE. Si el tamaño te preocupa, puedes usar compilaciones en varias etapas: instala uv en la primera etapa y copia solo .venv en la segunda. Así, la imagen final no incluirá uv.

Paquetes que requieren compilación

La imagen slim no incluye compilador. Si pip install muestra errores como gcc: not found o error: command 'gcc' failed, significa que algún paquete no tiene un wheel precompilado compatible y debe compilarse desde el código fuente. Hay dos opciones:

  1. Instalar las herramientas de compilación (la imagen ocupará más espacio):

    RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
        && rm -rf /var/lib/apt/lists/*
  2. Usar compilaciones en varias etapas: instala las herramientas de compilación y genera un wheel en la primera etapa; en la segunda, instala solo el wheel. La imagen final no incluirá el compilador.

La mayoría de los paquetes populares (numpy, pandas, psycopg, entre otros) ofrecen wheels precompilados para Linux que se pueden instalar directamente en slim. alpine usa musl libc y necesita otro tipo de wheel (musllinux). Si algunos paquetes no lo ofrecen, tendrás que compilarlos, lo que aumenta el tiempo de compilación y la probabilidad de errores. Por eso los proyectos de Python suelen recomendar slim en lugar de alpine.

Resumen

  • En producción, usa gunicorn (WSGI) o uvicorn (ASGI), no el servidor de desarrollo.
  • Copia primero requirements.txt y luego instala las dependencias para aprovechar la caché de compilación.
  • Durante el desarrollo, monta la carpeta del código para que el framework vuelva a cargarlo automáticamente.
  • En Django, debes configurar STATIC_ROOT antes de ejecutar collectstatic.

Ejercicios

Ejercicio 1: Cambia a 4 el número de workers de gunicorn del ejemplo de Flask sin modificar el Dockerfile.

Respuesta

Escribe el comando completo después del nombre de la imagen para sobrescribir CMD:

docker run -d -p 8000:8000 flask-app gunicorn --bind 0.0.0.0:8000 --workers 4 app:app

Usa docker logs para confirmar que aparecen 4 líneas de Booting worker. Configurar solo -e WEB_CONCURRENCY=4 no funciona porque CMD ya especifica --workers 2.

Ejercicio 2: ¿En qué URL están los documentos de la API del ejemplo de FastAPI?

Respuesta

http://localhost:8000/docs, generada automáticamente por FastAPI.

Preguntas frecuentes

¿Qué imagen de Python conviene elegir: slim o alpine?

Se recomienda slim. alpine usa musl libc, y algunos paquetes no tienen wheels precompiladas, así que hay que compilarlos manualmente. La compilación es lenta y puede fallar fácilmente.

¿La salida de print aparece con retraso en docker logs?

Configura PYTHONUNBUFFERED=1 para que la salida no pase por el búfer.

¿Conviene usar pip o uv?

Puedes usar cualquiera de los dos. uv es rápido y usa uv.lock para fijar las versiones de todos los paquetes; en este capítulo probamos ambas opciones.

Actualizado 14 de octubre de 2026