Volver al blog
Agosto 19, 2026Guías

Cómo ejecutar aplicaciones Python con Gunicorn y Nginx en un VPS

Desplegar una aplicación Flask o FastAPI en Ubuntu con un virtualenv, un servicio systemd de Gunicorn, un proxy inverso Nginx, TLS, ficheros de entorno, registro y una lista de comprobación para errores 502 y el dimensionado de workers.

Cómo ejecutar aplicaciones Python con Gunicorn y Nginx en un VPS

El servidor Flask integrado y uvicorn --reload son para desarrollo. En un VPS público quieres un gestor de procesos que reinicie workers, se enlace a localhost y se siente detrás de un proxy inverso que gestione TLS y clientes lentos. Gunicorn es la opción WSGI habitual para Flask y Django. FastAPI puede ejecutarse bajo Gunicorn con una clase de worker Uvicorn. Nginx (o Caddy) termina HTTPS y reenvía a 127.0.0.1:8000.

Esta guía recorre un diseño que sobrevive a los reinicios: proyecto en /srv/app, virtualenv, .env con secretos, una unidad gunicorn.service, un bloque server de Nginx, Let's Encrypt y ficheros de registro que de verdad puedes greppear. También hablaremos del número de workers, de los tiempos de espera y de por qué 502 Bad Gateway casi nunca es «Nginx está roto»: casi siempre Gunicorn no corre, está enlazado al socket equivocado o se cae al importar. El ejemplo usa Flask, con notas para FastAPI donde el comando cambia.

Por qué esta pila

Gunicorn prebifurca procesos worker. Cada worker atiende una petición a la vez salvo que uses otra clase de worker. Nginx almacena en búfer a los clientes lentos para que los workers no se queden atascados enviando bytes a una red móvil. systemd reinicia la aplicación si muere. Junto, es aburrido, que es lo que quieres a las tres de la madrugada.

  • Gunicorn: modelo de proceso WSGI/ASGI estable
  • systemd: arranque al iniciar, reinicio tras un fallo, registros de journald
  • Nginx: TLS, ficheros estáticos, límites de tamaño de petición, gzip
  • venv: el Python del sistema permanece limpio
  • enlace a localhost: la aplicación no es alcanzable salvo a través de Nginx

Requisitos

Python 3.10+ en Ubuntu 22.04/24.04 está bien. No ejecutes pip como root hacia los site-packages del sistema. Necesitas un dominio para TLS. Si prefieres Caddy como proxy, la unidad Gunicorn de este artículo se mantiene: solo cambia la configuración del front (véase el artículo de Caddy).

  • VPS Ubuntu 22.04 o 24.04
  • Tu aplicación con un requirements.txt o equivalente
  • Un punto de entrada WSGI (para Flask: app:app) o ASGI (para FastAPI: app:app con workers uvicorn)
  • Registro A del dominio para HTTPS

Paso 1: Paquetes del sistema, usuario y directorio del proyecto

Crea un usuario de sistema que no pueda iniciar sesión de forma interactiva, dueño del código y que ejecute Gunicorn. Instalar python3-venv y las herramientas de compilación evita fallos de pip en paquetes que aún compilan extensiones C.

bash
ssh root@YOUR_VPS_IP
apt update && apt -y upgrade
apt -y install python3 python3-venv python3-pip python3-dev build-essential nginx curl

adduser --system --group --home /srv/app appuser
mkdir -p /srv/app
chown appuser:appuser /srv/app

# Copy your code (example):
# rsync -a --delete ./myproject/ appuser@YOUR_VPS_IP:/srv/app/

Paso 2: Virtualenv y dependencias

Crea el venv como appuser para que la propiedad de los ficheros sea correcta. Fija versiones en producción. Tras instalar, confirma que puedes importar la aplicación con un gunicorn --check-config puntual o un import de Python. Los errores de importación aquí son los mismos que luego se convierten en 502.

bash
sudo -u appuser -H bash -lc '
cd /srv/app
python3 -m venv /srv/app/venv
/srv/app/venv/bin/pip install --upgrade pip
/srv/app/venv/bin/pip install -r /srv/app/requirements.txt gunicorn
'

# Flask example check:
sudo -u appuser -H /srv/app/venv/bin/python -c "from app import app; print('import ok')"

Paso 3: Fichero de entorno

No incrustes SECRET_KEY ni URL de bases de datos en la unidad systemd de un modo que acabe en un git legible por todos. Usa EnvironmentFile. chmod 640, propietario root, grupo appuser (o propietario appuser si lo prefieres).

bash
cat >/srv/app/.env <<'EOF'
FLASK_ENV=production
SECRET_KEY=replace-with-openssl-rand-hex-32
DATABASE_URL=postgresql://app:password@127.0.0.1:5432/app
EOF
chown appuser:appuser /srv/app/.env
chmod 600 /srv/app/.env

Paso 4: Servicio systemd de Gunicorn

Enlaza a 127.0.0.1:8000, no a 0.0.0.0, salvo que tengas motivo para saltarte Nginx. El número de workers suele ser (2 × CPU) + 1 para workers sync; en un VPS de 2 vCPU son 5, lo que puede ser demasiado si cada worker carga un modelo ML pesado: entonces usa 2–3. Para FastAPI, pon --worker-class uvicorn.workers.UvicornWorker e instala uvicorn. Los tiempos de espera deben superar tu petición honesta más lenta, no 30 segundos si tienes exportaciones de 2 minutos.

bash
cat >/etc/systemd/system/gunicorn.service <<'EOF'
[Unit]
Description=Gunicorn for the web app
After=network.target

[Service]
User=appuser
Group=appuser
WorkingDirectory=/srv/app
EnvironmentFile=/srv/app/.env
ExecStart=/srv/app/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 --timeout 60 --access-logfile - --error-logfile - app:app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now gunicorn
systemctl status gunicorn --no-pager
ss -tulpn | grep 8000

Paso 5: Proxy inverso Nginx y TLS

Nginx escucha en 80/443 y hace proxy hacia Gunicorn. client_max_body_size importa en las subidas. proxy_read_timeout debe igualar o superar el tiempo de espera de Gunicorn. Cuando el bloque server funcione en HTTP, emite un certificado con Certbot (o pasa el front a Caddy). El fragmento de abajo es solo HTTP para probar; luego ejecuta Certbot, que puede editar el fichero.

bash
cat >/etc/nginx/sites-available/app <<'EOF'
server {
    listen 80;
    server_name app.example.com;
    client_max_body_size 20m;

    location /static/ {
        alias /srv/app/static/;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        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_read_timeout 90s;
    }
}
EOF

ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
nginx -t && systemctl reload nginx

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable

apt -y install certbot python3-certbot-nginx
certbot --nginx -d app.example.com

Paso 6: Ficheros estáticos, permisos y particularidades de Flask/FastAPI

Nginx debería servir los ficheros estáticos si puedes; es más rápido que Gunicorn. Django collectstatic, Flask send_from_directory para aplicaciones minúsculas, o un CDN más adelante. appuser debe poder leer el árbol. Si usas FastAPI, instala uvicorn[standard] en el venv y cambia ExecStart para usar UvicornWorker. Si usas sockets Unix en lugar de TCP, apunta proxy_pass al socket y alinea permisos para que www-data pueda escribir en él.

bash
# FastAPI ExecStart example:
# ExecStart=/srv/app/venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 2 --bind 127.0.0.1:8000 app:app

# Unix socket variant:
# --bind unix:/run/gunicorn/app.sock
# Nginx: proxy_pass http://unix:/run/gunicorn/app.sock:

chown -R appuser:appuser /srv/app

Workers, memoria y recargas sin tiempo de inactividad

Los workers sync de Gunicorn son simples y bastan para API de petición/respuesta ligeras en CPU. Si necesitas muchas esperas de E/S lentas concurrentes, valora gevent o un worker ASGI: mide, no adivines. Cada worker carga tu aplicación; RAM ≈ workers por el RSS de la aplicación. Un VPS de 2 GB con 8 workers de una aplicación de 300 MB hará swap y se sentirá «lento al azar». systemctl reload gunicorn (HUP) puede reiniciar workers con el código nuevo si desplegaste ficheros en su sitio; un reinicio completo es más claro cuando cambian las dependencias.

bash
# After git pull / rsync of new code:
sudo -u appuser -H /srv/app/venv/bin/pip install -r /srv/app/requirements.txt
systemctl restart gunicorn
curl -I https://app.example.com/health

Resolver 502 y caídas silenciosas

502 significa que Nginx no obtuvo una respuesta válida del origen. journalctl -u gunicorn -e es el primer comando. Causas habituales: nombre module:app incorrecto, clave .env que falta, Postgres parado, enlazado a 127.0.0.1 pero Nginx en otro host, SELinux (raro en Ubuntu) o la aplicación escuchando solo en IPv6. 504 es un tiempo de espera. Los bucles 301 ocurren cuando la aplicación redirige a HTTP mientras se ignora X-Forwarded-Proto.

  • systemctl status gunicorn — ¿está activo?
  • journalctl -u gunicorn -n 100 — ImportError, entorno que falta, base de datos
  • curl -v http://127.0.0.1:8000/ desde el VPS: si esto falla, Nginx es inocente
  • nginx -t y error.log — upstream prematurely closed connection
  • ss -tulpn | grep 8000 — nada escucha
  • Disco lleno: los workers se caen de formas misteriosas

Seguridad

La aplicación nunca se enlaza en público. Los secretos se quedan en .env. Mantén el venv y el sistema parcheados. No ejecutes Gunicorn como root. Si gestionas inicios de sesión, pon las cookies de sesión Secure y SameSite y configura el marco para confiar en X-Forwarded-Proto solo desde Nginx. Limita la tasa de las rutas de acceso en Nginx o en la aplicación.

  • enlazar 127.0.0.1 o un socket Unix
  • chmod 600 .env
  • User= no root en systemd
  • TLS mediante Certbot o Caddy
  • Desactiva el modo debug y la recarga automática en producción

Consejos

  • Añade una ruta /health que compruebe la conectividad de la base para Compose y los equilibradores de carga
  • Entrega los recursos estáticos con una cabecera cache-control en Nginx
  • Usa un worker o cola aparte (Redis + systemd) para correos y trabajos pesados
  • Fija las versiones de gunicorn y uvicorn en requirements.txt
  • Haz una instantánea antes del primer corte a producción

Una aplicación Python en un VPS está lista para producción cuando corre bajo Gunicorn como servicio systemd, se enlaza solo a localhost y se alcanza a través de Nginx o Caddy con TLS. Pon los secretos en un EnvironmentFile, dimensiona los workers según la RAM y no según una fórmula de blog, y depura los 502 primero desde el diario de Gunicorn. Cuando este camino esté documentado para tu repositorio, cada despliegue posterior es rsync o git pull, pip install y systemctl restart gunicorn.