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
| Nginx | Gunicorn |
|---|---|
| Sirve estáticos eficientemente | Solo Python |
| SSL termination | No SSL nativo |
| Rate limiting | No rate limiting |
| Buffer de requests lentos | Ocuparía workers |
| Gzip compression | Consume 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
- Usuario no-root para la aplicación
- Firewall configurado (UFW)
- Fail2ban activo
- SSH solo con keys, sin root login
- Updates automáticos de seguridad
Nginx
- SSL con Let’s Encrypt
- HTTP → HTTPS redirect
- Security headers
- Rate limiting
- Gzip habilitado
Aplicación
- Variables de entorno (no hardcodeadas)
- Health check endpoint
- Logs estructurados
- Restart automático (systemd/Docker)
Backups
- Base de datos (diario)
- Configuración
- Offsite storage (S3, etc.)
- Test de restore
Conclusión
El despliegue en VPS requiere configurar múltiples capas:
- Nginx como reverse proxy con SSL
- Docker Compose para orquestación
- Systemd para restart automático
- Hardening de Linux para seguridad
- 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.