Módulo 15 14 min de lectura

15 - Despliegue VPS: Nginx y SSL

Reverse proxy con Nginx, SSL con Certbot, hardening de Linux y estrategias de zero-downtime deployment.

#nginx #ssl #vps #deployment #linux #security

1. Arquitectura de Producción en VPS

[Internet] → [Cloudflare/CDN] → [Nginx :443/:80] → [Gunicorn :8000] → [FastAPI]

                              [Static Files]
                              [SSL Termination]
                              [Rate Limiting]
                              [Gzip Compression]

Por qué Nginx delante de Gunicorn

NginxGunicorn
Sirve estáticos eficientementeSolo Python
SSL terminationNo SSL nativo
Rate limitingNo rate limiting
Buffer de requests lentosOcuparía workers
Gzip compressionConsume CPU de la app

2. Configuración de Nginx

/etc/nginx/sites-available/api

# Upstream: tu aplicación
upstream fastapi_app {
    server 127.0.0.1:8000;
    keepalive 32;
}

# Redirect HTTP → HTTPS
server {
    listen 80;
    server_name api.tudominio.com;
    
    # Certbot challenge
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    
    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS Server
server {
    listen 443 ssl http2;
    server_name api.tudominio.com;

    # SSL Certificates (Certbot)
    ssl_certificate /etc/letsencrypt/live/api.tudominio.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.tudominio.com/privkey.pem;
    
    # SSL Configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    
    # Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Logging
    access_log /var/log/nginx/api_access.log;
    error_log /var/log/nginx/api_error.log;

    # Gzip
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json application/javascript;

    # Rate Limiting
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
    
    # API Proxy
    location / {
        limit_req zone=api_limit burst=20 nodelay;
        
        proxy_pass http://fastapi_app;
        proxy_http_version 1.1;
        
        # Headers
        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_set_header Connection "";
        
        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
        
        # Buffering
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }
    
    # Health check (sin rate limit)
    location /health {
        proxy_pass http://fastapi_app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
    
    # Static files (si aplica)
    location /static/ {
        alias /var/www/api/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

Activar Sitio

# Crear symlink
sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/

# Verificar configuración
sudo nginx -t

# Recargar
sudo systemctl reload nginx

3. SSL con Certbot (Let’s Encrypt)

Instalación

# Ubuntu/Debian
sudo apt update
sudo apt install certbot python3-certbot-nginx

# Obtener certificado
sudo certbot --nginx -d api.tudominio.com

# Verificar renovación automática
sudo certbot renew --dry-run

Renovación Automática

Certbot instala un timer de systemd automáticamente:

# Verificar timer
sudo systemctl status certbot.timer

# Logs de renovación
sudo journalctl -u certbot

4. Docker Compose en Producción

# docker-compose.prod.yml
services:
  api:
    image: ghcr.io/tuusuario/tuapi:${VERSION:-latest}
    restart: always
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY=${SECRET_KEY}
    ports:
      - "127.0.0.1:8000:8000"  # Solo localhost (Nginx hace proxy)
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 1G

  db:
    image: postgres:16-alpine
    restart: always
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    # No exponer puerto (solo red interna)

  redis:
    image: redis:7-alpine
    restart: always
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    # No exponer puerto

volumes:
  postgres_data:
  redis_data:

.env de Producción

# .env (NO commitear)
VERSION=v1.2.3
DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/mydb
DB_USER=myuser
DB_PASSWORD=supersecretpassword
DB_NAME=mydb
SECRET_KEY=your-256-bit-secret-key-here

5. Zero-Downtime Deployment

Script de Deploy

#!/bin/bash
# deploy.sh

set -e

# Variables
COMPOSE_FILE="docker-compose.prod.yml"
IMAGE="ghcr.io/tuusuario/tuapi"
VERSION=$1

if [ -z "$VERSION" ]; then
    echo "Usage: ./deploy.sh <version>"
    exit 1
fi

echo "🚀 Deploying version $VERSION..."

# 1. Pull nueva imagen
echo "📥 Pulling image..."
docker pull $IMAGE:$VERSION

# 2. Actualizar variable de versión
export VERSION=$VERSION

# 3. Escalar temporalmente (si tienes múltiples replicas)
# docker compose -f $COMPOSE_FILE up -d --scale api=2 --no-recreate

# 4. Rolling update
echo "🔄 Starting rolling update..."
docker compose -f $COMPOSE_FILE up -d --no-deps api

# 5. Esperar a que pase health check
echo "⏳ Waiting for health check..."
sleep 10

# 6. Verificar
if curl -sf http://localhost:8000/health > /dev/null; then
    echo "✅ Deployment successful!"
else
    echo "❌ Health check failed, rolling back..."
    docker compose -f $COMPOSE_FILE rollback api
    exit 1
fi

# 7. Limpiar imágenes antiguas
echo "🧹 Cleaning up old images..."
docker image prune -f

echo "🎉 Done!"

6. Systemd Service (Sin Docker)

Si prefieres ejecutar sin Docker:

# /etc/systemd/system/fastapi.service
[Unit]
Description=FastAPI Application
After=network.target postgresql.service redis.service

[Service]
Type=exec
User=appuser
Group=appuser
WorkingDirectory=/opt/api
Environment="PATH=/opt/api/.venv/bin"
Environment="DATABASE_URL=postgresql+asyncpg://..."
ExecStart=/opt/api/.venv/bin/gunicorn app.main:app -c gunicorn.conf.py
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

# Security
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/api/logs

[Install]
WantedBy=multi-user.target
# Habilitar y arrancar
sudo systemctl daemon-reload
sudo systemctl enable fastapi
sudo systemctl start fastapi

# Ver logs
sudo journalctl -u fastapi -f

7. Hardening de Linux

Firewall (UFW)

# Instalar
sudo apt install ufw

# Reglas básicas
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# Habilitar
sudo ufw enable
sudo ufw status

Fail2ban

# Instalar
sudo apt install fail2ban

# Configurar para Nginx
sudo cat > /etc/fail2ban/jail.local << EOF
[nginx-req-limit]
enabled = true
filter = nginx-req-limit
action = iptables-multiport[name=ReqLimit, port="http,https"]
logpath = /var/log/nginx/*error.log
findtime = 600
maxretry = 10
bantime = 7200
EOF

# Reiniciar
sudo systemctl restart fail2ban

SSH Hardening

# /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AllowUsers tuusuario

# Reiniciar SSH
sudo systemctl restart sshd

8. Backups Automatizados

Script de Backup

#!/bin/bash
# backup.sh

BACKUP_DIR="/backups"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=7

# PostgreSQL
docker exec -t postgres pg_dump -U $DB_USER $DB_NAME | gzip > $BACKUP_DIR/db_$DATE.sql.gz

# Archivos de configuración
tar -czf $BACKUP_DIR/config_$DATE.tar.gz /opt/api/.env /etc/nginx/sites-available/

# Limpiar backups antiguos
find $BACKUP_DIR -type f -mtime +$RETENTION_DAYS -delete

# (Opcional) Subir a S3
aws s3 sync $BACKUP_DIR s3://mybucket/backups/

Cron Job

# Editar crontab
crontab -e

# Backup diario a las 3 AM
0 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

9. Monitorización Básica

Healthcheck Externo

# Cron cada 5 minutos
*/5 * * * * curl -sf https://api.tudominio.com/health || curl -X POST https://hooks.slack.com/... -d '{"text":"API DOWN!"}'

Disk Space Alert

#!/bin/bash
# disk_alert.sh
THRESHOLD=80
USAGE=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')

if [ $USAGE -gt $THRESHOLD ]; then
    curl -X POST https://hooks.slack.com/... \
        -d "{\"text\":\"⚠️ Disk usage at ${USAGE}%\"}"
fi

10. Checklist de Producción

Servidor

Nginx

Aplicación

Backups


Conclusión

El despliegue en VPS requiere configurar múltiples capas:

  1. Nginx como reverse proxy con SSL
  2. Docker Compose para orquestación
  3. Systemd para restart automático
  4. Hardening de Linux para seguridad
  5. Backups automatizados

Pattern Senior: Usa Nginx para todo lo que no sea lógica de aplicación (SSL, estáticos, rate limiting). Gunicorn/Uvicorn solo deben procesar requests de Python.

En el siguiente capítulo, automatizaremos todo esto con GitHub Actions CI/CD.