Cómo ejecutar cualquier app Python en Docker con Docker Compose
Aprende a ejecutar cualquier app Python en Docker con Docker Compose. Actualizado para 2026 con mejores prácticas modernas, health checks, usuarios no-root y Cloudflare…
Actualizado Publicado 25 min de lectura

Ejecutar apps Python en Docker con Docker Compose es la forma más rápida de pasar de main.py a un despliegue fiable en cualquier VPS. Esta guía cubre el pipeline completo: .dockerignore, Dockerfile, compose.yml, arranque, verificación, además de SSL con Cloudflare Tunnel para HTTPS gratis. Actualizada para 2026 con Docker Compose V2 (adiós al guion), disponibilidad de Python 3.13/3.14, uv como alternativa más rápida a pip, y Compose Watch para recarga en vivo.
Tanto si estás contenedorizando un dashboard NiceGUI, una app Streamlit o cualquier proyecto Python, el patrón es el mismo. Yo uso esta configuración en VPS de Hetzner corriendo Dokploy para varias apps Python pequeñas, y funciona de forma fiable.
Requisitos previos
- Un VPS corriendo Linux (Ubuntu o Debian). Un Hetzner CX23 (4 EUR/mes) puede correr 3-5 apps Python pequeñas. Hetzner Cloud VPS es mi opción por defecto. Hostinger VPS funciona como alternativa económica.
- Docker y Docker Compose V2 instalados. Si necesitas una guía: Cómo instalar Docker y Docker Compose en sistemas Ubuntu ARM
- Familiaridad básica con el terminal y un editor de texto.
- Recomendado: Dockge para gestionar Docker Compose desde una GUI. Hace que arrancar, parar y editar archivos compose sea mucho más fácil.
- Recomendado: Una cuenta de Cloudflare con un dominio (para SSL gratis vía Tunnels).
Los precios de los VPS subieron en general en 2026 — si estás reconsiderando un servidor alquilado, mira qué cambió y cuándo gana un mini PC.
Resumen de la estructura del proyecto
Antes de crear archivos, así queda la estructura final del directorio:
my-python-app/
├── .dockerignore
├── Dockerfile
├── compose.yml
└── my-app/
├── requirements.txt
└── main.pyTodo vive en un solo directorio. La subcarpeta my-app/ contiene tu código Python real y sus dependencias. Los archivos Docker van en la raíz del proyecto.
Crea un archivo .dockerignore
Sin un .dockerignore, Docker envía todo tu directorio de proyecto (incluyendo .git, __pycache__, .venv, .env) al daemon de Docker como contexto de build. Esto ralentiza los builds y puede filtrar secretos a la imagen.
Crea .dockerignore en la raíz del proyecto:
**/__pycache__
**/.venv
**/.git
**/.env
**/.DS_Store
Dockerfile
compose.yml
README.mdY ya está. Estas líneas evitan la mayoría de problemas comunes de contexto inflado.
Crea un Dockerfile
Aquí está el Dockerfile actualizado y listo para producción:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY ./my-app/requirements.txt /app
RUN pip install --no-cache-dir -r requirements.txt
COPY ./my-app /app
RUN addgroup --system app && adduser --system --group app
USER app
CMD ["python", "main.py"]Entendiendo las instrucciones del Dockerfile
FROM python:3.12-slim: Usa la variante slim (~130MB) en lugar de la imagen completa (~1GB). Para la mayoría de apps Python, slim tiene todo lo que necesitas. La imagen completa solo es necesaria si necesitas herramientas de compilación como gcc para extensiones nativas.
No uses Alpine para Python
Puede que veas python:3.12-alpine en otros tutoriales. Sáltalo. Alpine usa musl libc en lugar de glibc, lo que provoca fallos de compilación oscuros con paquetes Python que tienen extensiones en C (numpy, pandas, Pillow, cryptography). Los tiempos de build también son mayores porque muchos paquetes tienen que compilarse desde el código fuente. Los ~80MB extra de slim ahorran horas de depuración.
WORKDIR /app: Establece el directorio de trabajo dentro del contenedor. Todos los comandos siguientes se ejecutan desde aquí.
ENV PYTHONDONTWRITEBYTECODE=1: Evita que Python escriba archivos .pyc en disco. No hay razón para cachear bytecode en una imagen de contenedor.
ENV PYTHONUNBUFFERED=1: Fuerza que stdout y stderr no usen buffer. Sin esto, docker compose logs puede mostrar salida retrasada o incompleta de tu app. Para más sobre variables de entorno en Docker, mira Cómo usar las variables de entorno ARG y ENV en Docker.
COPY ./my-app/requirements.txt /app y luego RUN pip install --no-cache-dir -r requirements.txt: Copia primero los requirements y después instala las dependencias. Este orden es intencional: Docker cachea capas, así que si solo cambia el código de tu aplicación (y no las dependencias), la capa de pip install se reutiliza de la caché y los rebuilds son rápidos. El flag --no-cache-dir mantiene la caché de descargas de pip fuera de la imagen.
COPY ./my-app /app: Copia el resto del código de tu aplicación. Va después del pip install para que los cambios de código no disparen una reinstalación completa de dependencias.
RUN addgroup --system app && adduser --system --group app y luego USER app: Crea un usuario no-root y cambia a él. Correr contenedores como root es un antipatrón de seguridad. Si un atacante escapa del proceso, tiene root en el contenedor. El usuario app tiene privilegios mínimos.
CMD ["python", "main.py"]: El comando por defecto. Sobrescribible en compose.yml si necesitas un entrypoint distinto para servicios distintos.
¿Quieres builds más rápidos?
uv es un reemplazo directo de pip que es 10-100x más rápido en resolución e instalación de dependencias. Para usarlo en tu Dockerfile, reemplaza la línea de pip install con:
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system --no-cache -r requirements.txtEl mount de caché hace que los builds repetidos se salten la descarga de paquetes ya instalados. Para un recorrido completo de uv, mira Primeros pasos con uv: configura tu proyecto Python en 2026.
Crea un directorio my-app con tus scripts Python
Ahora crea el código de la aplicación. Aquí hay dos ejemplos comunes.
Ejemplo con NiceGUI
Crea my-app/requirements.txt:
niceguiCrea my-app/main.py:
from nicegui import ui
ui.label('Hello NiceGUI!')
ui.run()NiceGUI usa el puerto 8080 por defecto. Puedes cambiarlo con ui.run(port=9000) si lo necesitas. Solo asegúrate de que el mapeo de puertos en compose.yml coincida.
Para más sobre NiceGUI:
- Tutorial de NiceGUI: construye una UI para tu app Python en 5 minutos
- Cómo añadir múltiples páginas a NiceGUI
Ejemplo con Streamlit
Crea my-app/requirements.txt:
streamlitCrea my-app/main.py:
import streamlit as st
st.title('Hello Streamlit!')
st.write('This is a minimal Streamlit app running in Docker.')Streamlit usa el puerto 8501 por defecto. Nota: streamlit hello es un comando demo integrado; las apps reales usan streamlit run main.py.
Para un despliegue completo de Streamlit + Cloudflare Tunnel, mira Desplegar Streamlit en un VPS con proxy a Cloudflare Tunnels.
¿NiceGUI o Streamlit?
Ambos son sólidos para construir UIs web Python rápidamente. NiceGUI te da más control sobre el layout y los eventos; Streamlit es más rápido para dashboards de datos. Compáralos en Streamlit vs. NiceGUI: elige el mejor framework web Python.
Crea un archivo Docker Compose (compose.yml)
NiceGUI
services:
web:
container_name: python-server
command: python main.py
build:
context: .
dockerfile: Dockerfile
volumes:
- ./my-app:/app
ports:
- "5021:8080"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10sStreamlit
services:
web:
container_name: python-server
command: streamlit run main.py --server.headless true
build:
context: .
dockerfile: Dockerfile
volumes:
- ./my-app:/app
ports:
- "5021:8501"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8501')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30sDirectivas clave de Compose explicadas
services:: Empieza directamente. No hace faltaversion: "3"(esa clave es obsoleta y se ignora en Compose V2).container_name: python-server: Le da al contenedor un nombre fijo en lugar de uno autogenerado. Más fácil de referenciar en comandos.command:: Sobrescribe elCMDdel Dockerfile. Para NiceGUI espython main.py; para Streamlit,streamlit run main.py --server.headless true.build:: Le dice a Compose que construya la imagen desde el Dockerfile local.volumes: ./my-app:/app: Monta tu código fuente dentro del contenedor como bind mount. Los cambios de código en el host se reflejan inmediatamente sin rebuild.ports: "5021:8080": Mapea el puerto 5021 del host al puerto del contenedor (8080 para NiceGUI, 8501 para Streamlit). Accede a la app enhttp://localhost:5021.restart: unless-stopped: Reinicia el contenedor si falla, pero no si lo paras manualmente. Un buen valor por defecto para apps alojadas en un VPS.healthcheck:: Le dice a Docker que compruebe periódicamente si la app responde de verdad. Usaurllibde Python en lugar decurlporquecurlno está instalado enpython:3.12-slim. Una vez que pasa el healthcheck,docker compose psmuestra el contenedor como «healthy».
Compose Watch para desarrollo
En lugar de bind mounts, puedes usar Docker Compose Watch para una sincronización de archivos más granular. Añade esto a tu compose.yml:
develop:
watch:
- action: sync
path: ./my-app
target: /app
ignore:
- __pycache__/
- "*.pyc"
- action: rebuild
path: ./requirements.txtLuego ejecuta docker compose watch. Los cambios de código se sincronizan al instante; los cambios de dependencias disparan un rebuild automático. Es opcional — los bind mounts siguen funcionando bien — pero Compose Watch te da patrones de ignore y acciones distintas por ruta.
Arranca el stack de Docker Compose
docker compose up -d --builddocker compose(con espacio): El comando V2. El viejodocker-compose(con guion) se eliminó en abril de 2025.up -d: Arranca los contenedores en modo detached (segundo plano).--build: Reconstruye la imagen si el Dockerfile cambió.
Para parar el stack: docker compose down.
Para una referencia completa sobre gestionar contenedores tras la configuración inicial, mira Cómo actualizar un contenedor con Docker Compose.
Verifica que funciona
No asumas que está corriendo. Verifícalo:
1. Comprueba el estado del contenedor:
docker compose psDeberías ver Up en la columna STATUS. Tras 10-30 segundos (cuando pase el healthcheck), también debería mostrar healthy.
NAME STATUS PORTS
python-server Up (healthy) 0.0.0.0:5021->8080/tcp2. Revisa los logs:
docker compose logs -f webPara NiceGUI, busca algo como NiceGUI is on http://0.0.0.0:8080. Para Streamlit, You can now view your Streamlit app in your browser. Pulsa Ctrl+C para dejar de seguir los logs.
3. Prueba el acceso HTTP:
curl -s -o /dev/null -w "%{http_code}" http://localhost:5021Espera 200. Si obtienes 000 o un connection refused, la app no está escuchando en el puerto esperado.
4. Ábrelo en el navegador: navega a http://<tu-ip-vps>:5021.
¡Healthy!
Una vez que pasa el healthcheck, Docker marca el contenedor como «healthy». Esto importa porque la política de reinicio de Docker y herramientas como Dokploy usan el estado de salud para decidir si un contenedor funciona de verdad o solo está corriendo. Un contenedor unhealthy puede reiniciarse automáticamente.
Para más comandos Docker que usarás a diario, mira Top 50+ comandos Docker que DEBES conocer.
Añade nuevos paquetes PIP
Cuando tu app necesite una nueva dependencia:
-
Añade el paquete a
my-app/requirements.txt. -
Reconstruye:
bash docker compose up -d --build
El flag --build reconstruye la imagen con los requirements actualizados. Si usas bind mounts y solo cambiaron los requirements, la caché de capas de Docker hace que solo se repita el paso de pip install.
Para profundizar en la actualización de contenedores en ejecución, mira Cómo actualizar un contenedor con Docker Compose.
Añade un dominio con SSL usando Cloudflare Tunnels
Cloudflare Tunnels te da SSL gratis sin abrir puertos en tu firewall ni comprar un certificado. Tu app queda detrás del túnel; solo el edge de Cloudflare queda expuesto.
¿Ya tienes un Cloudflare Tunnel?
Si ya tienes cloudflared corriendo en tu VPS, solo necesitas añadir un nuevo hostname en el dashboard de Cloudflare (Access → Tunnels → tu túnel → Configure) que apunte a http://localhost:5021. Salta al paso 4.
Paso 1: Instala cloudflared en tu VPS (o ejecútalo como contenedor Docker).
Paso 2: Crea un túnel:
cloudflared tunnel create my-python-appEsto genera un ID de túnel y un archivo de credenciales.
Paso 3: Configura la regla de ingress. Crea o edita ~/.cloudflared/config.yml:
tunnel: my-python-app
credentials-file: /root/.cloudflared/<tunnel-id>.json
ingress:
- hostname: app.example.com
service: http://localhost:5021
- service: http_status:404Reemplaza <tunnel-id> con el ID real del paso 2. Reemplaza app.example.com con tu dominio.
Paso 4: Añade el CNAME DNS. En el dashboard de Cloudflare, añade un registro CNAME para app.example.com apuntando a <tunnel-id>.cfargotunnel.com.
Paso 5: Ejecuta el túnel:
cloudflared tunnel run my-python-appTu app Python ya es accesible en https://app.example.com con un certificado SSL válido gestionado por Cloudflare.
Para un enfoque alternativo con reverse proxy, mira Configurar CloudPanel como reverse proxy con Docker y Dockge. Si usas Streamlit específicamente, Desplegar Streamlit en un VPS con proxy a Cloudflare Tunnels tiene un recorrido más detallado.
Solución de problemas comunes
Puerto ya en uso
Error: bind: address already in use o port is already allocated
Causa: Otro proceso está usando el puerto 5021 en el host.
Solución:
ss -tlnp | grep 5021Esto muestra qué está usando el puerto. O paras ese proceso o cambias el puerto del host en compose.yml (p. ej., "5022:8080").
Módulo no encontrado
Error: ModuleNotFoundError: No module named 'xyz'
Causa: El paquete no está en requirements.txt, o la imagen no se reconstruyó después de añadirlo.
Solución:
docker compose exec web bash
pip list | grep xyzSi falta el paquete, añádelo a requirements.txt y reconstruye con docker compose up -d --build.
Permiso denegado en los montajes de volúmenes
Error: PermissionError: [Errno 13] Permission denied
Causa: Desajuste de UID/GID entre el usuario del host y el usuario app del contenedor.
Solución: Comprueba la propiedad en el host:
ls -ln my-app/Si los archivos pertenecen a un UID distinto de 1000 (el UID del usuario app en el Dockerfile), o cambias la propiedad en el host (chown -R 1000:1000 my-app/) o ajustas el Dockerfile para que coincida con el UID de tu host.
El contenedor se cierra inmediatamente
Error: El contenedor muestra Exited (1) en docker compose ps
Causa: Traceback de Python al arrancar — normalmente un error de sintaxis en main.py o una dependencia que falta.
Solución:
docker compose logs webLee el traceback. Culpables habituales: errores de importación, archivos que faltan (revisa las rutas del volume mount), command equivocado en compose.yml.
El healthcheck siempre está unhealthy
Error: El contenedor se queda en «starting» y luego pasa a «unhealthy»
Causa: La URL o el puerto del healthcheck no coinciden con el puerto en el que escucha la app.
Solución:
- Entra en el contenedor:
docker compose exec web bash - Prueba el healthcheck manualmente:
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080')" - Si falla, comprueba en qué puerto escucha realmente la app:
python -c "import socket; print(socket.gethostname())" - Asegúrate de que el puerto del healthcheck en
compose.ymlcoincide con el puerto de la app.
Para comandos generales de limpieza cuando algo va mal, mira Cómo limpiar todas las cosas de Docker.
Consejos de endurecimiento para producción
Cuando tu app funcione, unos pasos extra la dejan lista para producción:
Límites de recursos — evita que un contenedor deje sin recursos a los demás en un VPS compartido:
deploy:
resources:
limits:
cpus: '1'
memory: 512MAñade esto bajo el servicio web en compose.yml.
¿Ejecutas varios contenedores?
Sin límites de recursos, una app Python con un memory leak puede consumir toda la RAM del VPS y tumbar otros contenedores (o el propio host). Define siempre deploy.resources.limits en VPS de producción. Un Hetzner CX22 con 4GB de RAM corre cómodamente 3-5 apps Python pequeñas con límites de 512MB cada una.
Usuario no-root — ya cubierto en la sección del Dockerfile. No te lo saltes.
Healthcheck — ya cubierto. Esencial para que la política de reinicio de Docker funcione correctamente. Sin él, Docker solo sabe si el proceso está corriendo, no si la app responde realmente a peticiones.
Backups — si tu app usa una base de datos o escribe datos persistentes, monta un volumen dedicado para ellos y haz backup en almacenamiento compatible con S3. El bind mount (./my-app:/app) es para el código fuente; no guardes datos ahí.
Builds multi-etapa — si tu app necesita gcc u otras herramientas de compilación para extensiones nativas (numpy, cryptography, etc.), usa un build multi-etapa para compilar en una etapa y copiar solo los artefactos de runtime a la imagen slim final. Mantiene la imagen de producción ligera.
Conclusiones
Ejecutar apps Python en Docker con Docker Compose es sencillo cuando tienes los patrones correctos. Esto es lo que te llevas:
- Usa
python:3.12-slim(ni la completa, ni Alpine) como imagen base. - Incluye siempre
.dockerignore, usuario no-root,PYTHONDONTWRITEBYTECODEyPYTHONUNBUFFERED. - Usa
docker compose(con espacio, no guion) — el viejodocker-composeV1 ya no existe. - Quita el
version: "3"de tus archivos compose — es obsoleto. - Añade healthchecks para que Docker sepa si tu app funciona de verdad.
- Cloudflare Tunnels te da SSL gratis sin abrir puertos del firewall.
- Define límites de recursos cuando corras varios contenedores en un mismo VPS.
Empieza con el ejemplo sencillo de NiceGUI de arriba, verifica que funciona y luego itera. El coste total de un stack VPS + Docker + Cloudflare Tunnels es menos de 5 EUR/mes — difícil de superar para una app Python autoalojada con HTTPS.
Para más proyectos Docker que autoalojar, mira Contenedores Docker para tu home server.
Explora más tutoriales de Docker

