Práctica 3.4: Despliegue de una aplicación Flask
1. Objetivos
Al finalizar la práctica, seremos capaces de:
- Comprender la arquitectura de tres capas de una aplicación web Python: servidor web (Nginx), servidor de aplicaciones WSGI (Gunicorn) y framework de aplicación (Flask).
- Explicar qué es un framework web, qué es Flask como microframework y en qué consiste el patrón MVC.
- Explicar qué es WSGI, para qué sirve y por qué existe un estándar de comunicación entre servidores web y aplicaciones Python.
- Gestionar las dependencias de un proyecto Python con
uv, sustituyendo a las soluciones clásicas basadas enpipenv. - Crear entornos virtuales reproducibles con
uv venve instalar dependencias conuv pip install -r requirements.txt. - Desplegar una aplicación Flask mínima y, posteriormente, una aplicación real completa desde un repositorio de GitHub.
- Desplegar dos aplicaciones simultáneas en el mismo servidor, cada una con su propio servicio, socket y virtual host.
- Ejecutar Gunicorn en modo manual, tanto escuchando en un puerto TCP como en un socket Unix.
- Crear una unidad de servicio de
systemdrobusta, con reinicio automático, recarga sin downtime y directivas de hardening. - Configurar Nginx como proxy inverso hacia Gunicorn utilizando un socket Unix.
- Diagnosticar los errores más habituales del proceso de despliegue.
- Comprobar que una aplicación que depende de un servicio externo sigue funcionando cuando ese servicio no responde.
2. Requisitos previos
- Un servidor (físico, virtual o en la nube) con Debian 12 (Bookworm) o Ubuntu 22.04/24.04 LTS y acceso por SSH.
- Usuario con privilegios de
sudoen dicho servidor. - Conexión SSH funcional desde tu máquina local al servidor.
- Acceso a GitHub y al repositorio
raul-profesor/atletismo-records, que es la aplicación que se despliega en la segunda parte.
3. Conocimientos previos
Antes de comenzar conviene tener claros algunos conceptos, porque aparecen a lo largo de toda la práctica.
3.1. ¿Qué es un framework web?
En el desarrollo moderno de aplicaciones web se utilizan distintos frameworks, que son herramientas que nos dan un esquema de trabajo y una serie de utilidades y funciones que nos facilita y nos abstrae de la construcción de páginas web dinámicas.
En general, los frameworks están asociados a lenguajes de programación (Ruby on Rails para Ruby, Symfony para PHP, Spring Boot para Java). En el mundo de Python el más conocido es Django, pero Flask es una opción que quizá no tenga una curva de aprendizaje tan elevada y, aun así, nos posibilita la creación de aplicaciones web igual de complejas que las que se pueden crear en Django.
3.2. Flask
Flask es un «micro» framework escrito en Python y concebido para facilitar el desarrollo de aplicaciones web bajo el patrón MVC.
La palabra «micro» no designa a que sea un proyecto pequeño ni a que nos permita hacer páginas web pequeñas. Al instalar Flask tenemos las herramientas necesarias para crear una aplicación web funcional, pero si se necesita en algún momento una nueva funcionalidad hay un conjunto muy grande de extensiones (plugins) que se pueden instalar y que dotan a la aplicación de nueva funcionalidad.

De principio en la instalación no se tienen todas las funcionalidades que se pueden necesitar, pero de una manera muy sencilla se pueden extender el proyecto con nuevas funcionalidades por medio de plugins.
3.3. El patrón MVC
El patrón MVC es una forma de trabajar que permite diferenciar y separar lo que es:
| Capa | Responsabilidad | En Flask |
|---|---|---|
| Modelo | Los datos de la aplicación, normalmente almacenados en una base de datos | Objetos Python, ORM (SQLAlchemy), ficheros |
| Vista | La presentación en HTML | Plantillas Jinja2 en templates/ |
| Controlador | La gestión de las peticiones: qué ruta llama a qué lógica | Las funciones Python decoradas con @app.route() |
3.4. Gunicorn
Cuando se implementa una aplicación web basada en Python, normalmente se tienen estas tres piezas:
- Servidor web (Nginx, Apache): procesa y distribuye las solicitudes de los navegadores y otros clientes.
- Servidor de aplicaciones WSGI (Gunicorn, uWSGI, mod_wsgi, Waitress): intermedia entre el servidor web y la aplicación.
- Aplicación web (Django, Flask, Pyramid, FastAPI).
WSGI (Web Server Gateway Interface) proporciona un conjunto de reglas para estandarizar el comportamiento y la comunicación entre servidores web y aplicaciones web. Mediante el uso de servidores y aplicaciones compatibles con WSGI, los desarrolladores pueden concentrar su tiempo y energía en el desarrollo de aplicaciones web en lugar de administrar la comunicación entre la aplicación y el servidor web.

Gunicorn, que es la abreviatura de Green Unicorn, es un servidor de aplicaciones WSGI que se encuentra entre el servidor web y su aplicación web, gestionando la comunicación entre los dos. Acepta solicitudes del servidor y las traduce (a través de WSGI) en algo que la aplicación web puede entender antes de pasarla a la aplicación real. Envía respuestas desde la aplicación web al servidor. También se encarga de ejecutar varias instancias de la aplicación web, reiniciándolas según sea necesario y distribuyendo solicitudes a instancias saludables.
El modelo de Gunicorn: prefork
Gunicorn sigue un modelo prefork: un proceso master lanza varios procesos workers, cada uno con su propia copia de la aplicación Python. El master se encarga de aceptar conexiones y distribuirlas entre los workers; si un worker muere, el master lo reemplaza automáticamente. Esto es exactamente lo que hace un servidor como Apache con mod_php o PHP-FPM, y es lo que lo diferencia del modelo threaded de Node.js.
3.5. pip: el gestor de paquetes de Python
pip es el comando para instalar paquetes de Python integrados en las fuentes desde la versión 3.4.
Este comando automatiza la conexión al sitio https://pypi.org/, la descarga, la instalación e incluso la compilación del módulo solicitado. Además, se ocupa de las dependencias de cada paquete.
3.6. Entornos virtuales de Python
Un entorno virtual es una forma de tener múltiples instancias paralelas del intérprete de Python, cada una con diferentes conjuntos de paquetes y diferentes configuraciones. Cada entorno virtual contiene una copia independiente del intérprete de Python, incluyendo copias de sus utilidades de soporte.
Los paquetes instalados en cada entorno virtual sólo se ven en ese entorno virtual y en ningún otro. Incluso los paquetes grandes y complejos con binarios dependientes de la plataforma pueden quedar aislados entre sí.
De esta forma tendremos entornos independientes entre sí, parecido a lo que ocurre con los directorios de los proyectos de Node.js. Los entornos virtuales de Python nos permiten instalar un paquete en una ubicación aislada en lugar de instalarlo de manera global.
4. uv: el gestor de dependencias moderno de Python
En el enunciado original de esta práctica se utilizaba Pipenv. Hoy en día la opción estándar en la industria es uv, escrito en Rust por el equipo de Astral (los mismos creadores de ruff). Esta sección explica qué es y por qué lo vamos a utilizar.
4.1. ¿Qué es uv?
uv es un gestor de proyectos y paquetes Python que unifica en una sola herramienta
varias piezas que antes estaban separadas:
| Función | Herramienta clásica | Con uv |
|---|---|---|
| Descargar e instalar paquetes | pip |
uv pip install |
| Resolver dependencias | pip (con pip-tools) |
uv pip compile / uv pip sync |
| Crear entornos virtuales | python -m venv / virtualenv |
uv venv |
| Fijar versiones exactas | pip freeze |
uv pip freeze / uv.lock |
| Instalar Python si no lo tienes | pyenv |
uv python install |
| Ejecutar dentro del entorno | source .venv/bin/activate |
uv run |
En resumen: uv es un reemplazo directo de pip + venv + pipenv, y además es entre 10 y 100 veces más rápido porque está escrito en Rust y resuelve dependencias de forma asíncrona y en paralelo.
4.2. Instalación
uv se distribuye como un binario autónomo, de forma similar a como se instalaba pipenv. La instalación oficial es un script que descarga el binario appropriate para tu sistema:
Recargamos la shell para que ~/.local/bin esté en el PATH:
También disponible en los repositorios de Debian/Ubuntu
En Debian 13 (Trixie) y Ubuntu 25.04 o superiores, uv está disponible como paquete nativo: sudo apt install uv. En versiones anteriores, el script de instalación es la vía recomendada porque instala una versión mucho más reciente.
uv no necesita Python instalado
A diferencia de pipenv, uv es un binario autónomo: no depende de Python para ejecutarse. Sí necesita un intérprete de Python disponible para poder crear entornos virtuales e instalar código con extensiones compiladas, pero puede descargarse él mismo (uv python install 3.12).
4.3. Uso básico
# Crear el entorno virtual (crea el directorio .venv/)
uv venv
# Crear el entorno con una versión concreta de Python
uv venv --python 3.12
# Instalar las dependencias declaradas en requirements.txt
uv pip install -r requirements.txt
# Instalar un paquete suelto
uv pip install gunicorn
# Ver lo que hay instalado
uv pip list
# Congelar las dependencias a versions exactas
uv pip freeze > requirements.txt
# Ejecutar un comando dentro del entorno, sin activarlo
uv run gunicorn --workers 4 wsgi:app
uv pip encuentra el entorno automáticamente
No hace falta activar el entorno virtual. uv pip busca, en este orden: un entorno activado ($VIRTUAL_ENV), un .venv en el directorio actual o en el directorio padre más cercano. Si no lo encuentra, te lo dirá y te propondrá crearlo.
4.4. Pipenv, pip + venv o uv?
Esta es la pregunta natural que surge al modernizar la práctica. Las tres opciones son válidas, pero con diferencias importantes:
| Característica | pipenv |
pip + venv |
uv |
|---|---|---|---|
| Aislamiento del entorno | Automático (pipenv shell) |
Manual (python -m venv) |
Automático (uv venv) |
| Fichero de declaración de dependencias | Pipfile (+ Pipfile.lock) |
requirements.txt |
requirements.txt, pyproject.toml o uv.lock |
| Velocidad | Lenta | Lenta | Muy rápida (10–100×) |
| Formato de bloqueo | JSON en Pipfile.lock |
pip freeze a mano |
uv.lock (TOML, universal) |
| Estado del proyecto | Prácticamente abandoned (última release estable en 2023) | Estándar de facto, siempre disponible | Estándar de facto actual |
| Instala Python si falta | No | No | Sí (uv python install) |
| Disponible en los repos de Debian | sudo apt install pipenv |
Incluido en Python | sudo apt install uv (recientes) |
Por qué pipenv dejó de ser la recomendación
Pipenv ha quedado obsoleto: su formato Pipfile era una idea interesante, pero en la práctica obligaba a mantener dos sistemas de declaración de dependencias (Pipfile para desarrollo y requirements.txt para despliegue), lo que generaba duplicidad y desincronización. Además su resolución de dependencias era muy lenta. La comunidad pythonista se ha ido hacia pip + venv con requirements.txt, y más recientemente hacia uv, que resuelve el mismo problema mucho mejor.
Alternativa sin uv
Si en tu servidor no puedes instalar uv (políticas restrictivas, por ejemplo), el equivalente exacto con herramientas estándar es:
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
Todo lo demás de la práctica (ruta del ejecutable, unidad de systemd, configuración de Nginx) es exactamente igual, porque lo importante es la ruta al binario gunicorn dentro del entorno virtual.
5. Arquitectura que vamos a montar
La aplicación que serviremos es una aplicación Flask clásica, con la arquitectura de tres capas típica:
graph LR
C[Cliente<br/>navegador] -->|HTTP :80| N[Nginx<br/>proxy inverso]
N -->|socket Unix| G[Gunicorn<br/>master + 4 workers]
G -->|WSGI| A[Flask<br/>wsgi:app]
A --> T[Plantillas Jinja2<br/>y ficheros estáticos]
El recorrido de una petición es el siguiente:
- El navegador del cliente abre una conexión TCP contra el puerto 80 y envía una petición HTTP.
- Nginx recibe la petición. Como la URI no corresponde a ningún fichero estático, la envía al proxy inverso.
- Gunicorn tiene un proceso master que acepta la conexión y la reparte entre sus procesos worker.
- El worker traduce la petición HTTP a una llamada WSGI y se la pasa a la aplicación Flask.
- Flask ejecuta la función de la ruta, renderiza la plantilla Jinja2 y devuelve una respuesta WSGI.
- La respuesta recorre el camino inverso: Flask → Gunicorn → Nginx → cliente.
Por qué el socket Unix y no un puerto TCP
Entre Nginx y Gunicorn, que están en la misma máquina, no tiene sentido abrir un puerto TCP: el socket Unix es un fichero especial del sistema de ficheros que permite la comunicación local entre procesos. Es más rápido (no pasa por la pila de red), no es alcanzable desde el exterior y hereda directamente los permisos del usuario del servicio. Lo veremos en detalle en el Paso 9.
Gunicorn vs el servidor de desarrollo de Flask
El servidor de desarrollo de Flask (flask run o app.run()) es un servidor de desarrollo: no es un servidor de producción. Lo explicaremos con más detalle en el Paso 7.
6. Aplicación a desplegar
En esta práctica trabajaremos con dos aplicaciones: una mínima, para entender los conceptos sin distracciones, y una real, para ver un despliegue completo.
6.1. Aplicación PoC (proof of concept)
La primera aplicación es la más simple posible, a modo de prueba de concepto. Consta de dos ficheros:
application.py: contiene la aplicación Flask propiamente dicha.wsgi.py: se encarga únicamente de importarla y exponerla como objeto WSGI, que es lo que Gunicorn espera.
El contenido de application.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "<h1>Mi aplicacion Flask</h1><p>Desplegada con Nginx + Gunicorn</p>"
if __name__ == "__main__":
app.run()
Y el de wsgi.py:
from application import app
# Gunicorn.expecta encontrar un objeto WSGI llamado 'app' en este módulo
__all__ = ["app"]
¿Por qué un wsgi.py aparte?
Porque separa la aplicación (lo que el desarrollador escribe y despliega) del punto de entrada que consume el servidor. Cuando la aplicación crece, el wsgi.py es el sitio natural donde configurar cosas como el objeto de contexto, los blueprints o el middleware. Además, Gunicorn necesita una ruta de importación con dos componentes: archivo:objeto. En este caso, wsgi:app.
Para qué sirve la PoC
La desplegaremos por completo (entorno virtual, servicio systemd, virtual host de Nginx, resolución de nombres) para que veas cada pieza por separado, sin ruido.
Después repetiremos el mismo procedimiento con la aplicación real, donde todo será más realista: la estructura del proyecto, un requirements.txt, un mapa que consume una API externa y varios servicios conviviendo en el mismo servidor.
6.2. Aplicación real: records de atletismo
La segunda aplicación la hemos escrito para esta práctica y está publicada en:
https://github.com/raul-profesor/atletismo-records
La clonaremos en el Paso 13.
Qué hace
La aplicación muestra los records mundiales vigentes de medio fondo y fondo de atletismo, indicando dónde y cuándo se consequieron, mediante un mapa interactivo.
Incluye:
- Un mapa interactivo (Leaflet + OpenStreetMap) con un marcador por recinto, cuyo tamaño es proporcional a la altitud del terreno.
- Una tabla filtrable por categoría, sexo, modalidad y texto libre.
- Una API JSON (
/api/records,/api/recintos,/api/altitud). - Una sonda de salud en
/health, pensada para engancharla asystemdo a un monitor de despliegue.
Por qué esta aplicación
Elegida por un motivo muy concreto: los records de atletismo dependen del terreno.
Una marca no es sólo un tiempo: es un tiempo en un lugar. El maratón de Londres (16 m sobre el nivel del mar) y el de Chicago (181 m) no son comparables entre sí, y los records de altura de los años 80 y 90 se batieron en circuitos con desnivel y en altitud. La altitud del recinto es, por tanto, una variable de primer orden para entender el rendimiento, y eso hace que un mapa que muestre la altitud de cada recinto sea algo informativo y no un adorno.
Además, la aplicación tiene una estructura realista: separa los datos (app/datos.py), el acceso a servicios externos (app/elevacion.py), las rutas (app/__init__.py) y las plantillas, e incorpora degradación elegante (si la API de altitud no responde, la aplicación sigue funcionando).
Estructura del proyecto
aplicacion/
├── app/
│ ├── __init__.py # Rutas Flask
│ ├── datos.py # Carga y filtrado de los datos
│ ├── elevacion.py # Consulta a la API de Open-Meteo
│ ├── templates/
│ │ └── index.html
│ └── static/
│ ├── css/estilo.css
│ └── js/mapa.js
├── data/
│ └── records.json # Records y recintos
├── requirements.txt
└── wsgi.py # Punto de entrada para Gunicorn
Los datos
El fichero data/records.json contiene 19 records en 12 recintos, con las coordenadas y la altitud estimada de cada uno. Los datos proceden de World Athletics y están fechados, por lo que conviene revisarlos antes de cada curso.
La altitud exacta de cada recinto no está en el JSON: se consulta en tiempo de ejecución a la API de elevación de Open-Meteo, que usa el modelo digital de elevación Copernicus GLO-90. El JSON sólo lleva una estimación de referencia.
7. Preparación del servidor
Todos los comandos de esta sección deben ejecutarse desde una terminal con permisos de administración.
Paso 1: Actualizar el sistema e instalar el software necesario
Lo primero que hay que hacer siempre antes de instalar nada es actualizar los índices de paquetes y los propios paquetes del sistema. Esto garantiza que se instalan las últimas versiones disponibles y con sus parches de seguridad.
Comprobamos las versiones del sistema y de las herramientas que vamos a necesitar:
Si no están instalados Python, Git o Nginx, los instalamos:
Qué aporta cada paquete
python3: el intérprete de Python.python3-venv: el módulovenv, necesario para crear entornos virtuales conpython -m venv(y usado internamente poruv venv).python3-pip: el gestor de paquetes clásico, por si necesitas instalarlo en el sistema.git: para clonar el repositorio de la aplicación real.
Paso 2: Crear un usuario de servicio dedicado
Nunca ejecutes la aplicación como root
Tampoco como tu usuario personal. El enunciado original de esta práctica usaba chown -R $USER:www-data /var/www/mi_aplicacion, es decir, la aplicación se ejecutaba con los permisos de tu usuario de trabajo. Eso significa que si un alumno de tu clase encuentra una vulnerabilidad de ejecución remota en tu aplicación, consigue un shell en el servidor con tus privilegios, incluyendo tu clave SSH y tu acceso a sudo.
La práctica correcta es crear un usuario de sistema, sin shell y sin directorio home utilizable, que sólo pueda leer y ejecutar lo mínimo necesario.
Creamos el usuario:
Verificamos su creación:
Qué hace cada flag
| Flag | Significado |
|---|---|
--system |
Crea una cuenta de sistema (UID bajo 1000), sin shell interactivo ni contraseña asignada |
--home /opt/flask |
Su directorio home (no se crea si no existe, pero lo usaremos como raíz de las apps) |
--shell /usr/sbin/nologin |
Impide que nadie pueda abrir una sesión interactiva con este usuario |
--user-group |
Crea además un grupo flask con el mismo nombre |
Por qué NO /var/www y SÍ /opt/flask
/var/www es la raíz de documentos por defecto de Apache, y muchos servidores web sirven su contenido estático directamente desde ahí. /opt es la convención para software de terceros instalado y gestionado por el administrador del sistema. Como nuestra aplicación no se sirve de forma estática sino a través de un proxy inverso, /opt/flask es el sitio más limpio. Además, el directorio www-data del grupo www-data deja de ser relevante: Nginx no necesita leer nada de nuestra aplicación, solo mantener una conexión con Gunicorn.
Paso 3: Crear la estructura de directorios
Creamos el árbol de directorios y lo ponemos en manos del usuario del servicio:
Comprobamos los permisos:
Este paso no es opcional
Los dos directorios de proyecto tienen que existir y tener como propietario a flask antes de crear el entorno virtual.
Si el directorio no existe, el cd del Paso 5 fallará. Y si existe pero es de root (por ejemplo, si lo creaste con sudo mkdir sin luego hacer el chown), el uv venv fallará al intentar crear el .venv dentro:
error: Failed to create virtual environment
cause: failed to create directory `.venv`: Permission denied (os error 13)
El error engaña un poco
Dice "Permission denied" sobre .venv, pero el directorio .venv no existe todavía: lo que falla es crear el directorio dentro de mi_app, que pertenece a root y en el que flask no puede escribir.
La solución no es dar permisos al .venv (que no existe), sino arreglar el propietario del directorio padre:
La estructura final será:
/opt/flask/
└── apps/
├── mi_app/ <- Aplicación PoC
│ ├── .venv/ <- Entorno virtual con uv
│ ├── application.py
│ ├── wsgi.py
│ └── requirements.txt
└── atletismo/ <- Aplicación real (clonada del repositorio)
├── .venv/
├── app/
│ ├── __init__.py
│ ├── datos.py
│ ├── elevacion.py
│ ├── templates/
│ └── static/
├── data/
│ └── records.json
├── wsgi.py
└── requirements.txt
El directorio .venv va dentro del proyecto
Es una convención que uv sigue automáticamente: uv pip install busca un .venv en el directorio actual o en el padre más cercano. Si el entorno virtual vive en otro sitio, hay que tirar de --python /ruta/al/entorno/bin/python en cada comando. Dentro del proyecto, todo funciona sin configuración previa.
8. Gestión de dependencias con uv
Paso 4: Instalar uv
Comprobamos si uv ya está disponible en el servidor:
Si el comando muestra una ruta y una versión, ya lo tienes instalado. Aun así, te recomendamos reinstalarlo en /usr/local/bin (siguiente apartado), porque uv instalado en tu home no funciona con sudo -u flask. Si prefieres no reinstalarlo, el apartado Instalar uv sin permisos de administrador te explica cómo resolverlo.
Por qué uv debe instalarse en /usr/local/bin
Hay dos obstáculos entre tú y un uv instalado en tu home, y los dos dan error:
1. El PATH de sudo. Desde la versión 1.9.16, sudo tiene activa la opción secure_path por defecto:
Esta opción sustituye tu PATH por una lista de directorios del sistema. El script de instalación deja uv en ~/.local/bin, que no está en la lista, así que:
2. Los permisos de tu directorio home. Aunque indiques la ruta completa, el usuario flask tiene que atravesar tu home para llegar al binario. Y el home de un usuario creado por cloud-init (como el de una máquina virtual en la nube) suele tener permisos 700:
Para un usuario sin permiso sobre ese directorio, el resultado es:
sudo -u flask /home/ubuntu/.local/bin/uv venv
# sudo: unable to execute /home/ubuntu/.local/bin/uv: Permission denied
El error dice "Permission denied", no "No such file"
Esto confunde a menudo: el fichero sí existe y sí tiene permisos de ejecución (755). Lo que falta es permiso para recorrer los directorios intermedios. En Unix, para ejecutar un binario necesitas permiso x (recorrido) en todos los directorios de la ruta, y r-x en el propio binario.
Instalamos uv en /usr/local/bin, que es un directorio del sistema, con permisos 755 y sin el problema del home:
Verificamos la instalación:
Ahora sí funciona con sudo
Como /usr/local/bin está en el secure_path de sudo y es legible por todos los usuarios, estos comandos ya funcionan sin más complicaciones:
Y no necesitas ninguna variable de shell.
La variable de entorno UV_INSTALL_DIR
El script de instalación de uv permite elegir el directorio destino con esta variable. Sin ella, instala en ~/.local/bin, que es lo que causa los problemas que acabamos de ver.
Alternativa: el paquete de los repositorios
En Debian 13 (Trixie) y Ubuntu 25.04 o superiores, uv está disponible como paquete nativo y se instala directamente en /usr/bin:
En versiones anteriores, el script de instalación instala una versión bastante más reciente, que es la opción preferible.
uv se instala en el home, no en el sistema
El binario queda en ~/.local/bin/uv, que pertenece a tu usuario, no al usuario de servicio flask. Esto es intencional y no es un problema:
- El ejecutable
gunicornque usará el servicio vive dentro del entorno virtual (.venv/bin/gunicorn), y es ese binario el quesystemdinvocará con su ruta absoluta. uvsólo se usa en la fase de preparación, que ejecutas tú.
Alternativa: instalarlo en el sistema
Si prefieres que uv esté disponible para todos los usuarios del servidor, incluido root, puedes instalarlo en /usr/local/bin, que sí está en el secure_path de sudo:
En ese caso, sudo -u flask uv ... funciona directamente en los pasos siguientes, sin depender del PATH de tu usuario. Es la opción más cómoda si vas a desplegar varias aplicaciones.
Instalar uv sin permisos de administrador
Si no puedes escribir en /usr/local/bin (porque no tienes sudo, o por una política de la organización), hay una tercera vía: copiar el binario al directorio del usuario del servicio, que sí controlas:
sudo -u flask mkdir -p /opt/flask/bin
sudo -u flask cp "$(command -v uv)" /opt/flask/bin/uv
sudo -u flask chmod 755 /opt/flask/bin/uv
Y a partir de ahí, los comandos serían:
¿Por qué esta vía funciona?
/opt/flask es el directorio que creamos en el Paso 3 y que pertenece al usuario flask. Al estar en su home efectivo, el usuario puede recorrerlo y ejecutar el binario sin problemas de permisos.
Y al ser una ruta absoluta dentro de /opt, tampoco tiene nada que ver con el secure_path de sudo: funciona siempre.
El inconveniente de esta vía
El binario queda copiado, no instalado. Si más adelante actualizas uv en tu usuario, esta copia se queda atrás. Acuérdate de volver a copiarla.
Paso 5: Crear el entorno virtual e instalar dependencias
Nos situamos en el directorio de la aplicación y creamos el entorno:
¿Por qué sudo -u flask?
Para que el entorno virtual y los ficheros que se instalen en él pertenezcan al usuario del servicio, y no a tu usuario personal. Así, el servicio systemd podrá leerlos sin necesidad de permisos adicionales, y no habrá ficheros con permisos restringidos en /opt/flask.
Lo que NO hay que hacer
No uses sudo uv ... sin -u flask. El comando se ejecutaría como root y el entorno virtual quedaría propiedad de root, no del usuario del servicio. El servicio systemd no podría escribir en él y tendrías que corregir permisos a mano.
¿Por qué uv y no uv con la ruta completa?
Como lo hemos instalado en /usr/local/bin (Paso 4), el nombre uv a secas ya funciona con sudo. Es más legible que repetir /usr/local/bin/uv en cada comando.
--python 3.12
Si el servidor tiene esa versión de Python instalada, uv la usa. Si no, uv la descarga automáticamente (gestionada por uv), lo cual es muy cómodo en servidores donde no quieres tocar los repositorios del sistema.
Instalamos Flask y Gunicorn. Son los dos ejecutables que usaremos en el resto de la práctica:
flask es el servidor de desarrollo, gunicorn el de producción
El paquete flask trae dos cosas: la biblioteca (el código de la aplicación) y el ejecutable flask, que es su servidor de desarrollo. El paquete gunicorn trae el ejecutable gunicorn, que es el servidor de producción que usaremos detrás de Nginx.
Instalamos los dos aquí para no tener que volver a hacerlo más adelante.
Verificamos qué se ha instalado y dónde:
Los ejecutables viven en .venv/bin/
Fíjate en que aparecen flask, gunicorn y python. Esta es la ruta que usaremos en la unidad de systemd: /opt/flask/apps/mi_app/.venv/bin/gunicorn. Nunca usaremos simplemente gunicorn, porque el PATH del servicio systemd es distinto del de tu shell interactiva.
Un requirements.txt para poder recrear el entorno
Un entorno virtual es descartable: se borra y se recrea. Lo que no se puede perder es la lista de dependencias. La guardamos así:
Y para reconstruir el entorno en cualquier momento (o en otra máquina):
Paso 6: Crear los ficheros de la aplicación
Dentro del directorio de la aplicación PoC:
cd /opt/flask/apps/mi_app
sudo -u flask touch application.py wsgi.py
sudo chown flask:flask application.py wsgi.py
Editamos application.py y wsgi.py con el contenido del apartado 6.1.
touch + chown en dos comandos
touch ejecutado con sudo crearía el fichero con root como propietario. El chown posterior corrige la propiedad. Alternativamente: sudo -u flask touch application.py wsgi.py.
9. Probar la aplicación en local
Antes de montar nada, conviene comprobar que la aplicación funciona. Veremos dos formas de ejecutarla y compararemos.
Paso 7: Probar con el servidor de desarrollo de Flask
Usamos el ejecutable flask que instalamos en el Paso 5. Lo arrancamos desde wsgi.py:
¿Por qué .venv/bin/flask y no simplemente flask?
Dos razones:
- El entorno virtual está aislado. El
flaskque tienes instalado en el sistema (si lo tienes) es otro distinto, y no tendría las dependencias de nuestra aplicación. Con la ruta completa, ejecutas el del venv. - La ruta es relativa al directorio actual, así que el comando anterior sólo funciona si estás en
/opt/flask/apps/mi_app. Por eso elcdva siempre antes.
El atajo uv run
uv run hace lo mismo eligiendo el venv por ti:
cd /opt/flask/apps/mi_app
sudo -u flask env PATH="$PATH" uv run flask --app wsgi run --host 0.0.0.0 --port 5000
Es más corto, pero más opaco: si algo falla, no sabes con qué Python se está ejecutando. Para una práctica, la ruta completa es más fácil de depurar.
Qué significa --host 0.0.0.0
Le indicamos al servidor que escuche en todas sus interfaces de red. Si lo dejamos en el valor por defecto (127.0.0.1), sólo aceptaría conexiones desde la propia máquina y no podríamos acceder desde el portátil.
Si preferimos usar el if __name__ == "__main__": app.run() que lleva application.py, el comando sería:
Este servidor NO es apto para producción
El servidor de desarrollo de Flask:
- Usa un único proceso y un único hilo: no atiende peticiones en paralelo.
- Dice explícitamente que no es estable ni pensado para producción (su banner lo avisa).
- No reinicia los workers si uno falla.
- No tiene ningún mecanismo de limitación de peticiones, conexiones o recursos.
- No se integra con
systemdni con la rotación de logs del sistema.
Sirve para tu desarrollo local, punto. Lo que usaremos en producción es Gunicorn.
Comprobamos desde el navegador de nuestra máquina anfitriona: http://IP-DEL-SERVIDOR:5000
En la terminal del servidor, la salida del servidor de desarrollo debe parecerse a esto:
* Serving Flask app 'wsgi'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://192.168.X.X:5000
Press CTRL+C to quit
Fíjate en el aviso
El propio servidor de desarrollo te avisa de que no es apto para producción. No es un detalle: es la primera de las razones por las que en producción usaremos Gunicorn.
Verás que responde a cada petición con una línea de log:
Recordatorio
Si no se conecta, comprueba el grupo de seguridad de la máquina virtual (en AWS, EC2 → Security Groups → Inbound Rules) y que el cortafuegos del sistema (ufw/firewalld) permite el puerto.
Detenemos el servidor con CTRL+C.
Paso 8: Probar con Gunicorn en un puerto TCP
Ahora sí, con el servidor de producción:
Donde:
-
--workers 4establece el número de procesos worker. La regla habitual de Gunicorn es aproximadamente el doble de núcleos + 1 (2 × nproc + 1), porque los workers son de tipo prefork (bloqueantes) y no aprovechan la espera de E/S de la misma forma que un modelo asíncrono. Puedes ver los núcleos del servidor connproc.No pongas demasiados workers
Cada worker es un proceso Python independiente con su propia copia de la aplicación. Si la aplicación es grande (por ejemplo, un ORM con modelos pesados), 20 workers pueden consumir cientos de megabytes. Empieza con
2 × nproc + 1y mide. -
--bind 0.0.0.0:5000hace que el servidor escuche peticiones por todas sus interfaces de red y en el puerto 5000. -
wsgi:appes el nombre del archivo (sin extensión.py) yappes la instancia de la aplicación Flask dentro del archivo.
La sintaxis módulo:objeto
Gunicorn resuelve wsgi:app así: importa el módulo wsgi y busca en él un atributo llamado app. Como wsgi.py hace from application import app, ese atributo es exactamente el objeto Flask, que es un callable WSGI válido.
Comprobamos de nuevo desde el navegador: http://IP-DEL-SERVIDOR:5000
Ahora la salida del servidor es la de Gunicorn, que sí es un servidor de producción:
[2026-11-08 18:30:12 +0100] [12345] [INFO] Starting gunicorn 26.2.0
[2026-11-08 18:30:12 +0100] [12345] [INFO] Listening at: http://0.0.0.0:5000 (12345)
[2026-11-08 18:30:12 +0100] [12345] [INFO] Using worker: sync
[2026-11-08 18:30:12 +0100] [12346] [INFO] Booting worker with pid: 12346
[2026-11-08 18:30:12 +0100] [12347] [INFO] Booting worker with pid: 12347
[2026-11-08 18:30:12 +0100] [12348] [INFO] Booting worker with pid: 12348
[2026-11-08 18:30:12 +0100] [12349] [INFO] Booting worker with pid: 12349
Lo que nos dice esta salida
Listening at: http://0.0.0.0:5000: el proceso master ha abierto el puerto.Using worker: sync: cada worker atiende las peticiones de forma bloqueante (es el tipo por defecto).- Cuatro líneas
Booting worker: una por cada worker, con su PID. Estos son los que se van a reiniciar solos si uno falla.
Detenemos Gunicorn con CTRL+C.
Paso 9: Convertir Gunicorn en un servicio de systemd
Hasta aquí todo era manual. En producción queremos que la aplicación:
- Arranque sola cuando el servidor se enciende.
- Se reinicie sola si se cuelga o si alguien la mata.
- Se pueda consultar y depurar con herramientas estándar del sistema.
Para eso creamos una unidad de servicio de systemd.
Decidir el directorio del socket Unix
Antes de escribir la unidad, tenemos que decidir dónde vivirá el socket Unix que usará Nginx para hablar con Gunicorn. Hay dos opciones habituales:
| Opción | Ruta | Ventaja | Inconveniente |
|---|---|---|---|
| Dentro del proyecto | /opt/flask/apps/mi_app/mi_app.sock |
Path obvious | Hay que crearlo y borrarlo a mano; permisos heredados del directorio |
| Directorio de runtime | /run/flask/mi_app.sock |
systemd lo crea y lo limpia solo, con el usuario correcto | Hay que declararlo en la unidad |
Vamos a usar la segunda opción, que es la que se usa en producción. systemd tiene una directiva RuntimeDirectory que:
- Crea el directorio
/run/flaskal arrancar el servicio. - Le asigna como propietario al usuario del servicio (
flask). - Lo elimina al parar el servicio.
Además, las rutas de socket Unix están limitadas a unos 100 bytes (el campo sun_path del protocolo AF_UNIX), así que conviene que sean cortas.
Escribir la unidad de servicio
Creamos el fichero:
Y escribimos:
[Unit]
Description=Mi aplicacion Flask servida por Gunicorn
After=network.target
[Service]
Type=simple
User=flask
Group=flask
WorkingDirectory=/opt/flask/apps/mi_app
# El PATH empieza por el venv, para que 'gunicorn' sea el del entorno virtual
Environment="PATH=/opt/flask/apps/mi_app/.venv/bin"
# Variables de entorno de la aplicacion (el '-' evita que falle si no existe)
EnvironmentFile=-/opt/flask/apps/mi_app/.env
ExecStart=/opt/flask/apps/mi_app/.venv/bin/gunicorn \
--workers 4 \
--bind unix:/run/flask/mi_app.sock \
--access-logfile - \
--error-logfile - \
--timeout 30 \
--graceful-timeout 30 \
wsgi:app
# Recarga sin downtime: HUP hace que Gunicorn reinicie los workers
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=5
KillSignal=SIGQUIT
TimeoutStopSec=30
# Directorio de runtime: /run/flask, con el usuario del servicio
RuntimeDirectory=flask
RuntimeDirectoryMode=0750
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
Qué hace cada directiva
| Directiva | Función |
|---|---|
After=network.target |
Arranca después de que la red esté lista |
Type=simple |
El proceso principal es el propio Gunicorn (no un script de arranque) |
User / Group |
Identidad con la que se ejecuta la aplicación: nunca root |
WorkingDirectory |
Directorio desde el que se ejecuta Gunicorn (donde están las plantillas y static/) |
Environment="PATH=..." |
Pone el venv al principio del PATH |
EnvironmentFile=-... |
Carga las variables del .env. El prefijo - indica que el fichero es opcional |
ExecStart |
El comando de arranque, con la ruta absoluta al ejecutable |
--bind unix:... |
Crea el socket Unix que Nginx consumirá. Este es el detalle que faltaba en el enunciado original |
--access-logfile - |
Los logs de acceso van a stdout, que systemd captura en el journal |
--error-logfile - |
Ídem para los errores |
--timeout 30 |
Si un worker tarda más de 30 s en responder, se le mata y se reinicia |
ExecReload + HUP |
Recarga los workers sin tumbar el servicio |
Restart=always |
Si el proceso muere, systemd lo vuelve a arrancar |
RestartSec=5 |
Espera 5 s entre reinicios, para no entrar en un bucle |
KillSignal=SIGQUIT |
Al parar, Gunicorn termina los workers de forma ordenada |
TimeoutStopSec=30 |
Margen para que los workers terminen las peticiones en curso |
RuntimeDirectory |
systemd crea /run/flask con el usuario correcto y lo limpia al parar |
El bloque de hardening no es decorativo
ProtectSystem=strict hace que todo el sistema de ficheros sea de sólo lectura para el servicio, salvo las rutas declaradas en ReadWritePaths. ProtectHome=true impide que el servicio lea tu /home. PrivateTmp=true le da su propio /tmp aislado. NoNewPrivileges=true impide que el proceso gane privilegios mediante setuid.
Todo esto limita mucho el daño si un alumno (o un atacante) consigue ejecutar código dentro de la aplicación. Es la diferencia entre "la aplicación corría como root" y "la aplicación corre como un usuario sin acceso a nada".
El .env de la aplicación
Creamos el fichero de variables de entorno:
Contenido típico:
Habilitar y arrancar el servicio
Como cada vez que se crea un servicio nuevo de systemd, se habilita y se inicia:
Comprobamos el estado:
Deberías ver algo como:
● flask-app.service - Mi aplicacion Flask servida por Gunicorn
Loaded: loaded (/etc/systemd/system/flask-app.service; enabled)
Active: active (running) since ...
Main PID: 12345 (gunicorn)
Tasks: 5 (limit: 38284)
Memory: 62.4M
Comprobaciones útiles
systemctl is-enabled flask-app→enabledsystemctl is-active flask-app→activesystemctl show -p MainPID flask-app→ el PID del proceso masterps -ef | grep gunicorn→ verás el master y sus 4 workers
Verificar que el socket se ha creado
Este es el punto que conviene comprobar antes de configurar Nginx:
Deberías ver el socket:
Y podemos probar que Gunicorn responde a través del socket, sin pasar por Nginx:
Este paso es la clave de la práctica
En el enunciado original, la configuración de Nginx apuntaba a un socket unix: que nadie creaba nunca. El alumno se encontraba con un 502 Bad Gateway sin ninguna pista de por qué. Si haces esta comprobación, el error aparece en el minuto 20 y no en el minuto 45.
10. Nginx como proxy inverso
Paso 10: Configurar el virtual host de Nginx
Creamos el fichero de configuración del sitio web:
Y escribimos:
server {
listen 80;
listen [::]:80;
server_name flask-app.local www.flask-app.local;
server_tokens off;
client_max_body_size 10m;
access_log /var/log/nginx/flask-app.access.log;
error_log /var/log/nginx/flask-app.error.log;
# Los ficheros estaticos los sirve Nginx directamente, sin pasar por Gunicorn
location /static/ {
alias /opt/flask/apps/mi_app/static/;
expires 30d;
access_log off;
}
location / {
include proxy_params;
# Proxy inverso hacia el socket Unix de Gunicorn
proxy_pass http://unix:/run/flask/mi_app.sock;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
Comentarios sobre la configuración
server_name: los nombres de dominio que responderá este virtual host. Más adelante lo resolveremos con/etc/hosts(ver el Paso 12).access_log/error_log: los logs de acceso y de errores, en ficheros separados para poder analizarlos por separado.server_tokens off: no revela la versión de Nginx en las cabeceras de respuesta. Es una medida básica de hardening.location /static/: Nginx sirve directamente los ficheros estáticos de la aplicación, sin molestar a Gunicorn. Es una optimización importante: las CSS y las imágenes son las peticiones más frecuentes, y no requieren ejecutar Python.proxy_pass http://unix:/ruta/al/socket: el proxy inverso hacia Gunicorn.
El detalle de la barra final en proxy_pass
Si escribimos proxy_pass http://unix:/run/flask/mi_app.sock; sin barra final, Nginx reenvía la URI original sin modificarla. Si le añadimos una barra final (.../socket/), Nginx elimina el prefijo de la URI antes de reenviarla, y las rutas de la aplicación dejarán de funcionar. Es un clásico.
Variante con TCP en lugar de socket Unix
Si prefieres la versión con puerto (más fácil de depurar con curl http://127.0.0.1:8000), el ExecStart del servicio sería:
ExecStart=/opt/flask/apps/mi_app/.venv/bin/gunicorn \
--workers 4 \
--bind 127.0.0.1:8000 \
wsgi:app
y en Nginx:
El socket Unix es preferible porque no abre un puerto (aunque sea en localhost) y se comporta mejor con muchos workers.
Paso 11: Habilitar el sitio y recargar Nginx
Creamos el enlace simbólico del sitio disponible al de sitios activos:
Y nos aseguramos de que se ha creado dicho enlace simbólico:
El directorio sites-available frente a sites-enabled
Es una convención de Debian y Ubuntu. sites-available contiene todas las configuraciones posibles; sites-enabled contiene los enlaces simbólicos a las que Nginx realmente dará servicio. Así se activa o desactiva un sitio con un simple ln -s o rm, sin tocar el fichero de configuración.
Comprobamos que la configuración de Nginx no contiene errores:
Debería mostrar:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Comprueba siempre con nginx -t antes de recargar
Si hay un error de sintaxis y recargas igualmente, te quedas sin servidor web: Nginx se queda con la configuración anterior, pero si el error impide la carga, el servicio no arranca. Con nginx -t lo sabrás antes de tocar nada.
Recargamos Nginx y comprobamos su estado:
reload frente a restart
reload pide a Nginx que recargue la configuración sin tumbar las conexiones existentes. Es la forma correcta de aplicar cambios: los usuarios conectados no notan nada.
Paso 12: Resolver el nombre y verificación final
En este momento, la aplicación ya es accesible por IP:
Pero queremos acceder por nombre de dominio, que es como se hará en producción.
Recordemos que aún no hemos tratado con el DNS
El tema 4 del curso está dedicado al DNS. Por ahora, la forma más sencilla de resolver el nombre es editar el fichero hosts de nuestra máquina anfitriona (la del portátil, no el servidor).
En Linux y macOS, el fichero está en:
En Windows:
Añadimos una línea:
donde 192.168.X.X es la IP de tu máquina virtual.
No edites /etc/hosts del servidor para esto
Si editas el /etc/hosts del servidor, la resolución funciona sólo dentro del servidor, no desde tu portátil. Necesitas editar el fichero de tu máquina local. Puedes editar el del servidor con sudo nano /etc/hosts y necesitas permisos de administrador en Windows (hay que abrir el Bloc de notas como administrador o usar PowerShell con permisos elevados).
Comprobamos que la resolución funciona:
Y por fin, la prueba final desde el navegador de la máquina anfitriona:
Deberíamos ver la misma página que en el Paso 8.
Podemos verificarlo también desde el terminal, que es más cómodo para documentar:
HTTP/1.1 200 OK
Server: nginx
Date: Sun, 08 Nov 2026 18:32:07 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 69
Connection: keep-alive
<h1>Mi aplicacion Flask</h1><p>Desplegada con Nginx + Gunicorn</p>
Las cabeceras de la respuesta
Fíjate en Server: nginx: la respuesta la ha compuesto Nginx, no Gunicorn. Y en que no aparece ninguna cabecera Server: gunicorn, porque Nginx la sustituye. Por eso el server_tokens off que pusimos en la configuración es una medida útil: sin él, la respuesta habría revelado la versión exacta de Nginx.
¡Despliegue completado!
Ya tenemos una aplicación Flask servida por Gunicorn, gestionada por systemd y publicada a través de Nginx. Y todo ello con un usuario sin privilegios, un socket Unix y un despliegue reproducible con uv.
11. Desplegar la aplicación real de atletismo
Ya hemos desplegado la PoC. Ahora repetimos el procedimiento con la aplicación real, que es donde el despliegue se pone interesante.
Paso 13: Clonar el repositorio
cd /opt/flask/apps
sudo -u flask git clone https://github.com/raul-profesor/atletismo-records atletismo
cd atletismo
ls -la
La propiedad de los ficheros
Como clonamos con sudo -u flask, todo el contenido pertenece al usuario flask, que es el que ejecutará el servicio.
Paso 14: Crear el entorno e instalar las dependencias
Esta aplicación sí trae un requirements.txt correcto y actualizado, así que la instalación es distinta de la de la PoC:
cd /opt/flask/apps/atletismo
sudo -u flask uv venv --python 3.12
sudo -u flask uv pip install -r requirements.txt
El requirements.txt incluye Flask y Gunicorn
Su contenido es:
Es decir, pip install -r requirements.txt instala los dos ejecutables que vamos a usar (flask y gunicorn), igual que los instalamos a mano en la PoC. La diferencia es que aquí las versiones están fijadas y declaradas en un fichero, que es lo que hace un despliegue reproducible.
Comprobamos qué se ha instalado:
Un detalle de uv que puede confundir
uv pip busca hacia arriba un pyproject.toml y, si encuentra uno, instala en el entorno virtual de ese proyecto en lugar del tuyo. Si tu servidor tiene un pyproject.toml en un directorio padre, las dependencias se irían allí.
Para forzar el entorno correcto, indica la variable VIRTUAL_ENV:
cd /opt/flask/apps/atletismo
sudo -u flask env VIRTUAL_ENV="$PWD/.venv" uv pip install -r requirements.txt
Comprobamos que el entorno es el correcto:
Paso 15: Probar la aplicación en local
Primero con el servidor de desarrollo, para verificar que todo funciona:
cd /opt/flask/apps/atletismo
sudo -u flask .venv/bin/flask --app wsgi run --host 0.0.0.0 --port 5000
Comprobamos en el navegador que:
- Se ve el mapa con los marcadores de los recintos.
- La tabla muestra los 19 records.
- Los filtros funcionan (por ejemplo, elegir «Fondo» o buscar «Monaco»).
- El formulario de búsqueda devuelve resultados.
Comprueba también la sonda de salud
En otra terminal:
Debe devolver algo como:
Un detalle importante sobre la API de altitud
La aplicación consulta la altitud del terreno a la API de Open-Meteo en tiempo de ejecución. Si el servidor no tiene salida a Internet, la aplicación no se rompe: registra un aviso en el log y muestra la altitud estimada que viene en el JSON.
Detenemos el servidor con CTRL+C y probamos con Gunicorn:
cd /opt/flask/apps/atletismo
sudo -u flask .venv/bin/gunicorn --workers 4 --bind 0.0.0.0:5000 wsgi:app
El punto de entrada vuelve a ser wsgi:app
El comando es idéntico al de la PoC: la estructura del proyecto no cambia el punto de entrada, siempre que respetemos la convención archivo:objeto.
Paso 16: Crear el servicio systemd de la aplicación real
Como esta aplicación tiene su propio directorio, su propio entorno virtual y su propio socket, necesita su propio servicio y su propio virtual host.
[Unit]
Description=Records de atletismo (Flask + Gunicorn)
After=network.target
[Service]
Type=simple
User=flask
Group=flask
WorkingDirectory=/opt/flask/apps/atletismo
Environment="PATH=/opt/flask/apps/atletismo/.venv/bin"
EnvironmentFile=-/opt/flask/apps/atletismo/.env
ExecStart=/opt/flask/apps/atletismo/.venv/bin/gunicorn \
--workers 4 \
--bind unix:/run/flask/atletismo.sock \
--access-logfile - \
--error-logfile - \
--timeout 30 \
--graceful-timeout 30 \
wsgi:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=5
KillSignal=SIGQUIT
TimeoutStopSec=30
RuntimeDirectory=flask
RuntimeDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
Las diferencias con el servicio de la PoC
Sólo cambian cuatro líneas:
WorkingDirectory: apunta aatletismo.Environment="PATH=...": apunta al venv deatletismo.ExecStart: usa el ejecutable deatletismoy el socketatletismo.sock.- La
Descriptiondescribe la aplicación.
Y como los dos servicios comparten RuntimeDirectory=flask, ambos conviven sin problema: cada uno crea su socket con un nombre distinto dentro del mismo directorio.
Arrancamos el servicio:
sudo systemctl daemon-reload
sudo systemctl enable flask-atletismo
sudo systemctl start flask-atletismo
sudo systemctl status flask-atletismo
Comprobación de los sockets
Ahora debe haber dos sockets, uno por aplicación:
srwxr-xr-x 1 flask flask 0 ... /run/flask/atletismo.sock
srwxr-xr-x 1 flask flask 0 ... /run/flask/mi_app.sock
Y podemos probar cada uno por separado, sin pasar por Nginx:
Paso 17: Configurar el virtual host de Nginx
server {
listen 80;
listen [::]:80;
server_name atletismo.local www.atletismo.local;
server_tokens off;
client_max_body_size 10m;
access_log /var/log/nginx/flask-atletismo.access.log;
error_log /var/log/nginx/flask-atletismo.error.log;
# Los ficheros estaticos los sirve Nginx directamente, sin pasar por Gunicorn
location /static/ {
alias /opt/flask/apps/atletismo/app/static/;
expires 30d;
access_log off;
}
location / {
include proxy_params;
proxy_pass http://unix:/run/flask/atletismo.sock;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
Ojo con la ruta del alias
Los ficheros estáticos de esta aplicación están en app/static/, no en static/. Es un detalle fácil de equivocar: si el alias apunta a un directorio inexistente, Nginx devuelve 404 en todos los CSS y las imágenes, y la página se ve sin estilos.
Compruébalo antes de recargar:
Las cabeceras X-Forwarded-* no son un adorno
Esta aplicación (y cualquier otra) necesita saber cuál era la URL original y el protocolo de la petición. Sin esas cabeceras, url_for() generaría URLs con http:// en lugar de https:// y las redirecciones se irían al sitio equivocado. Con un proxy inverso delante, el backend nunca ve la petición original: sólo ve la conexión con Nginx.
Habilitamos el sitio y recargamos:
sudo ln -s /etc/nginx/sites-available/flask-atletismo /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Comprobación desde el propio servidor
Antes de salir a la máquina local, comprueba que Nginx responde:
Si devuelve 200 OK y el HTML de la página, el despliegue está correcto y lo único que falta es la resolución del nombre en tu máquina.
Añadimos la resolución del nombre en el /etc/hosts de nuestra máquina local:
Y comprobamos desde el navegador:
Segunda aplicación desplegada
Ya tenemos las dos aplicaciones funcionando de forma simultánea, cada una con su propio servicio, su propio socket y su propio virtual host, compartiendo la misma instalación de Nginx.
Y ahora tenemos algo mejor que un "Hello World": una aplicación que consulta datos de una API externa, sirve un mapa interactivo, y se degrada con elegancia cuando esa API no responde.
12. Comparativa: PoC vs aplicación real
| Aspecto | PoC (mi_app) |
Aplicación real (atletismo) |
|---|---|---|
| Origen | Ficheros creados a mano | Repositorio Git clonado |
| Punto de entrada | wsgi.py → wsgi:app |
wsgi.py → wsgi:app |
| Dependencias | Instaladas con uv pip install |
requirements.txt + uv pip install -r |
| Estructura | Dos ficheros sueltos | Paquete app/ con datos, API y vistas separados |
| Datos | En el código | Fichero data/records.json |
| Ficheros estáticos | No hay | CSS y JavaScript del mapa |
| Plantillas | HTML en línea | Jinja2 (index.html) |
| Rutas | Una (/) |
Cinco (/, /api/records, /api/recintos, /api/altitud, /health) |
| Dependencias externas | Ninguna | API de elevación de Open-Meteo |
| Servicio systemd | flask-app.service |
flask-atletismo.service |
| Socket | /run/flask/mi_app.sock |
/run/flask/atletismo.sock |
| Virtual host | flask-app.local |
atletismo.local |
| Código a escribir | application.py + wsgi.py |
Ninguno (ya está en el repo) |
Qué conviene observar
Aunque el procedimiento es el mismo, cada paso tiene su propio detalle: el WorkingDirectory y el PATH cambian, el socket tiene otro nombre, y la ruta de los ficheros estáticos está un nivel más profunda (app/static/ en lugar de static/).
Este es el motivo por el que las variables están separadas en la unidad de servicio y no escritas a fuego en un único fichero: un despliegue real con muchas aplicaciones se parece más a esto.
El salto de la PoC a la aplicación real
La PoC sirve para entender cada pieza por separado. La aplicación real introduce dos cosas que no había: una estructura de proyecto en la que los datos y las vistas están separados del código, y una dependencia de un servicio externo que puede no estar disponible.
Ambas cosas son el día a día de un despliegue real, y por eso la práctica insiste en una cosa: no basta con instalar, hay que ejecutar y comprobar.
13. Comandos de referencia rápida
Python y uv
| Acción | Comando |
|---|---|
Instalar uv en el sistema |
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh |
| Ver la versión | uv --version |
| Crear el entorno | sudo -u flask uv venv --python 3.12 |
| Instalar paquetes | sudo -u flask uv pip install flask gunicorn |
| Crear el entorno virtual | uv venv --python 3.12 |
| Crear el entorno con un Python concreto | uv venv --python 3.13 |
| Instalar las dependencias | uv pip install -r requirements.txt |
| Instalar un paquete | uv pip install flask gunicorn |
| Ver lo instalado | uv pip list |
| Congelar dependencias | uv pip freeze > requirements.txt |
| Ejecutar en el entorno | uv run gunicorn wsgi:app |
Alternativa con venv estándar |
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt |
Flask y Gunicorn
| Acción | Comando |
|---|---|
| Servidor de desarrollo | .venv/bin/flask --app wsgi run --host 0.0.0.0 --port 5000 |
| Gunicorn con TCP | .venv/bin/gunicorn --workers 4 --bind 0.0.0.0:5000 wsgi:app |
| Gunicorn con socket Unix | .venv/bin/gunicorn --workers 4 --bind unix:/run/flask/mi_app.sock wsgi:app |
| Ver el número de cores | nproc |
| Workers recomendados | 2 × nproc + 1 |
| Probar el socket a mano | curl --unix-socket /run/flask/mi_app.sock http://localhost/ |
| Ver los workers | ps -ef \| grep gunicorn |
systemd
| Acción | Comando |
|---|---|
| Recargar los ficheros de unidad | sudo systemctl daemon-reload |
| Habilitar al arranque | sudo systemctl enable flask-app |
| Arrancar | sudo systemctl start flask-app |
| Parar | sudo systemctl stop flask-app |
| Reiniciar | sudo systemctl restart flask-app |
| Estado | sudo systemctl status flask-app |
| Logs en vivo | sudo journalctl -u flask-app -f |
| Logs de las últimas 100 líneas | sudo journalctl -u flask-app -n 100 --no-pager |
| Recarga sin downtime | sudo systemctl reload flask-app |
| Ver el PID principal | systemctl show -p MainPID flask-app |
Nginx
| Acción | Comando |
|---|---|
| Test de configuración | sudo nginx -t |
| Recargar | sudo systemctl reload nginx |
| Estado | sudo systemctl status nginx |
| Logs de acceso | sudo tail -f /var/log/nginx/flask-app.access.log |
| Logs de error | sudo tail -f /var/log/nginx/flask-app.error.log |
| Habilitar un sitio | sudo ln -s /etc/nginx/sites-available/flask-app /etc/nginx/sites-enabled/ |
| Ver los sitios activos | ls -l /etc/nginx/sites-enabled/ |
14. Diagnóstico rápido
Los problemas están ordenados según el momento del despliegue en el que suelen aparecer.
Entorno y dependencias
| Problema | Causa probable | Solución |
|---|---|---|
uv: command not found |
No está en el PATH |
export PATH="$HOME/.local/bin:$PATH" y añade esa línea a ~/.bashrc |
sudo: uv: command not found |
sudo sustituye el PATH por secure_path y uv está en ~/.local/bin |
Instala uv en /usr/local/bin (Paso 4) |
sudo: unable to execute .../uv: Permission denied |
El usuario flask no puede recorrer tu home, que tiene permisos 700 |
Instala uv en /usr/local/bin (Paso 4) |
failed to create directory '.venv': Permission denied |
El directorio del proyecto es de root, así que flask no puede escribir dentro |
sudo chown -R flask:flask /opt/flask (Paso 3) |
No virtual environment found al instalar |
El uv venv anterior falló y no creó el .venv |
Arregla los permisos del directorio y repite el uv venv |
No virtual environment found |
No hay .venv en el directorio |
uv venv --python 3.12 |
AttributeError: module 'ast' has no attribute 'Str' |
Werkzeug 2.2.2 usa una API de ast eliminada en Python 3.12 |
Actualiza a Flask>=3.1 y Werkzeug>=3.1 |
ImportError: cannot import name 'url_quote' from 'werkzeug.urls' |
Flask==2.0.2 con un Werkzeug 2.3 o superior |
Actualiza a Flask>=3.1 y Werkzeug>=3.1 |
ModuleNotFoundError: No module named 'flask' |
Se ejecutó el Python del sistema, no el del venv | Usa .venv/bin/python o .venv/bin/flask |
error: externally-managed-environment (pip) |
Se intentó instalar en el Python del sistema | Instala siempre dentro del venv, o usa uv pip |
Gunicorn
| Problema | Causa probable | Solución |
|---|---|---|
ModuleNotFoundError: No module named 'wsgi' |
Se usó wsgi:app en el repo que sólo tiene app.py |
Usa app:app |
ModuleNotFoundError: No module named 'app' |
Se usó app:app en un proyecto que sólo tiene wsgi.py |
Usa wsgi:app |
Address already in use |
El puerto 5000 ya está ocupado | ss -lntp \| grep 5000 para ver quién lo tiene |
Permission denied al crear el socket |
El directorio no pertenece al usuario del servicio | RuntimeDirectory=flask en la unidad, o revisa la propiedad de /run/flask |
WORKER TIMEOUT en los logs |
Un worker tarda más de --timeout segundos |
Increase --timeout o revisa si hay consultas lentas a la base de datos |
Connection refused al usar el socket |
Gunicorn no está corriendo o el socket no existe | systemctl status flask-app y ls -la /run/flask/ |
Nginx
| Problema | 502 Bad Gateway | Causa: Gunicorn no está corriendo, o el path del socket es incorrecto. Solución: sudo systemctl status flask-app, ls -la /run/flask/, compara con proxy_pass |
| 502 Bad Gateway | La ruta de proxy_pass no coincide con el --bind | Un typo en un carácter y ya no funciona. Compara ambas cadenas |
| 404 Not Found | proxy_pass con barra final | Elimina la barra final: proxy_pass http://unix:/run/flask/mi_app.sock; |
| 403 Forbidden | Permisos de los ficheros estáticos | sudo chown -R flask:flask /opt/flask/apps/atletismo/app/static |
| 502 sólo después de recargar Nginx | Nginx se recargó antes de que Gunicorn creara el socket | sudo systemctl restart flask-app y luego sudo systemctl reload nginx |
| No se ven los estilos | location /static/ con alias mal escrito | La ruta del alias debe terminar en / y coincidir con el directorio real |
DNS y resolución de nombres
| Problema | Causa probable | Solución |
|---|---|---|
ERR_NAME_NOT_RESOLVED |
El /etc/hosts de la máquina local no tiene la línea |
Edita el fichero de tu máquina anfitriona, no el del servidor |
ERR_CONNECTION_REFUSED en el navegador |
El puerto 80 está bloqueado por el cortafuegos o el grupo de seguridad | sudo ufw allow 80/tcp y revisa el grupo de seguridad de la VM |
ping funciona pero el navegador no |
Se editó el /etc/hosts equivocado |
Recuerda: el del portátil, no el del servidor |
15. Cuestiones
Cuestión 1
Busca, lee, entiende y explica qué es un servidor WSGI y para qué sirve. Compara WSGI con el protocolo CGI clásico y explica qué problema resuelve.
Cuestión 2
¿Por qué no debemos usar el servidor de desarrollo de Flask en producción? Enumera al menos cuatro razones y explica en qué momento de esta práctica lo hemos utilizado y con qué objetivo.
Cuestión 3
¿Qué diferencia hay entre WSGI y ASGI? ¿En qué casos conviene uno y en cuáles el otro? Investiga si Flask 3 es un framework WSGI o ASGI, y qué servidores se pueden usar en cada caso.
Cuestión 4
¿Cómo se dimensiona el número de workers de Gunicorn? Explica la regla 2 × cores + 1, cuándo deja de ser válida, y qué tipo de worker (sync, gthread, gevent) convendría para una aplicación con muchas peticiones de E/S (por ejemplo, una que consulta una API externa).
Cuestión 5
¿Por qué en esta práctica usamos un socket Unix en lugar de un puerto TCP entre Nginx y Gunicorn? Enumera las ventajas y las desventajas. ¿En qué situación concreta sería preferible usar un puerto TCP?
Cuestión 6
Explica la diferencia entre pip, pipenv y uv. ¿Por qué pipenv dejó de ser la herramienta de referencia? ¿Qué ventajas tiene uv frente a las otras dos?
16. Tarea de ampliación
Ampliación
Realiza alguna de las siguientes mejoras sobre el despliegue que has construido. Cada una es independiente, así que puedes elegir la que más te interese.
A. Ajuste de rendimiento y medición
- Instala una herramienta de carga como
hey(o usaab, que viene con el paqueteapache2-utils) y mide el throughput de la aplicación con distintos números de workers (1, 2, 4, 8). - Compara el rendimiento del socket Unix con el de un puerto TCP en
127.0.0.1. - Documenta en una tabla qué configuración da mejores resultados en tu servidor y por qué.
B. Monitorización a partir del health check
La aplicación de atletismo ya incluye la ruta /health. El trabajo ahora es conectarla al sistema:
- Configura un
Healthchecken la unidad desystemdo un script que compruebe esa ruta periódicamente concurly reinicie el servicio si falla. - Configura el envío de los logs de Gunicorn a un fichero con rotación mediante
logrotate, en lugar de dejarlos sólo en el journal. - Mide cuánto tarda la aplicación en responder a
/healthconcurl -w "%{time_total}"y compáralo con el tiempo de respuesta de la página principal, que es más lenta porque consulta la API de altitud.
C. WSGI frente a ASGI
- Despliega la misma aplicación con un servidor ASGI (Uvicorn o Hypercorn), usando el soporte asíncrono de Flask (
flask[async]). - Compara el comportamiento de ambos despliegues con la ruta
/api/altitud, que hace una llamada de E/S a un servicio externo. - Redacta una conclusión sobre cuándo compensa cada arquitectura.
Una pista
La ruta /api/altitud es la candidata ideal para esta comparación: hace una llamada HTTP a un servidor externo y, por tanto, espera. Es exactamente el escenario en el que un modelo asíncrono puede mantener más conexiones abiertas con menos recursos.
D. Automatización del despliegue
- Escribe un script de shell (
desplegar.sh) que automatice todo el proceso: clonar, crear el venv, instalar dependencias, copiar la unidad desystemd, enlazar el virtual host, recargar Nginx y verificar el estado. - Documenta cómo lo ejecutarías desde un pipeline de CI/CD.
E. Mejora de la aplicación
- Añade un caché de las altitudes para no consultar la API de Open-Meteo en cada petición (pista:
functools.lru_cache). - Añade un endpoint que devuelva los records por país y otro que devuelva el récord más antiguo y el más reciente, y enlázalos desde la interfaz.
- Comprueba que los datos de
data/records.jsonestán al día, y documenta cómo lo verificarías de forma automática.
Atención
Documenta adecuadamente con explicaciones y capturas de pantalla los procesos de despliegue de ambas aplicaciones Flask, así como las respuestas a las cuestiones planteadas.
Captura, como mínimo, la salida de systemctl status de los dos servicios, la de curl -i sobre el virtual host de cada aplicación y la de journalctl -u <servicio> -n 50.
17. Referencias
API de elevación de Open-Meteo
Guía de despliegue de Flask con Gunicorn y Nginx
Guía de despliegue de Flask en producción
systemd.service: directivas de hardening