Por que NO uses runserver en produccion
python manage.py runserver es un servidor de desarrollo con auto-reload, traceback en HTML y un solo proceso. En produccion:
- No es seguro: expone tracebacks completos.
- No escala: un solo proceso atiende todas las peticiones secuencialmente.
- No tiene healthcheck ni graceful shutdown.
- No reinicia workers que crashean.
La pila estandar moderna es Gunicorn + Uvicorn workers + Nginx.
WSGI vs ASGI
- WSGI (Web Server Gateway Interface): protocolo sincrono historico de Python. Cada peticion bloquea un worker.
- ASGI (Asynchronous Server Gateway Interface): protocolo moderno que soporta async + WebSockets. Necesario si usas async views, Django Channels, server-sent events o respuestas streaming.
Si tu API tiene una sola vista async, ya necesitas ASGI. Como el coste de cambiar es minimo, arranca directamente en ASGI.
Gunicorn con Uvicorn workers
pip install gunicorn uvicorn[standard]
gunicorn core.asgi:application \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 30 \
--graceful-timeout 30 \
--keep-alive 5 \
--max-requests 1000 \
--max-requests-jitter 50 \
--access-logfile - \
--error-logfile -
Parametros clave:
--workers 4: numero de procesos worker. Regla(2 * cores) + 1para WSGI sincrono; para async basta concores.--worker-class uvicorn.workers.UvicornWorker: imprescindible para soporte ASGI.--max-requests 1000: cada worker se reinicia tras 1000 requests para evitar memory leaks lentos.--max-requests-jitter 50: anade aleatoriedad para que los reinicios no sean simultaneos.--graceful-timeout 30: al recibir SIGTERM espera 30s a que los workers terminen requests en curso.--access-logfile -y--error-logfile -: a stdout, asi systemd/docker/kubectl los recogen.
Para WebSockets / Channels larga duracion: arrancalo separado con
daphneouvicorndirecto (no gunicorn). Una conexion WS persistente bloquea el worker mientras dure.
Nginx como reverse proxy
Nginx delante hace TLS, gzip, rate limit por IP, headers de seguridad y sirve estaticos.
# /etc/nginx/sites-available/api.miapp.com
upstream api_backend {
server 127.0.0.1:8000;
}
server {
listen 80;
server_name api.miapp.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.miapp.com;
ssl_certificate /etc/letsencrypt/live/api.miapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.miapp.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 50M;
gzip on;
gzip_types application/json application/xml text/css text/javascript;
location /static/ {
alias /var/www/api/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /var/www/api/media/;
expires 7d;
}
location / {
proxy_pass http://api_backend;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
location = /healthz { proxy_pass http://api_backend; access_log off; }
}
Activar con sudo ln -s sites-available/api.miapp.com sites-enabled/ y nginx -s reload.
El bloque
Upgrade/Connectionpermite que WebSockets (Channels) funcionen a traves de Nginx. Sin esos headers, el101 Switching Protocolsno llega al cliente.
Settings.py para produccion
DEBUG = False
ALLOWED_HOSTS = ["api.miapp.com"]
# Confiar en X-Forwarded-Proto que setea nginx
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
STATIC_ROOT = "/var/www/api/static"
MEDIA_ROOT = "/var/www/api/media"
python manage.py collectstatic --noinput recoge todos los staticos en STATIC_ROOT para que nginx los sirva.
Healthchecks
# core/health.py
from django.http import JsonResponse
from django.db import connection
def healthz(request):
return JsonResponse({"status": "ok"})
def readyz(request):
try:
with connection.cursor() as c:
c.execute("SELECT 1")
return JsonResponse({"status": "ready"})
except Exception:
return JsonResponse({"status": "not ready"}, status=503)
/healthz: liveness. Si responde 200, el proceso esta vivo. Falla -> reiniciar./readyz: readiness. Si responde 200, el proceso puede aceptar trafico. Falla -> sacar de balanceador (no reiniciar).
Systemd unit en VPS
# /etc/systemd/system/api.service
[Unit]
Description=DRF API
After=network.target
[Service]
User=apiapp
Group=apiapp
WorkingDirectory=/opt/api
Environment="DJANGO_SETTINGS_MODULE=core.settings_prod"
EnvironmentFile=/etc/api/env
ExecStart=/opt/api/.venv/bin/gunicorn core.asgi:application \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 127.0.0.1:8000 \
--max-requests 1000 \
--max-requests-jitter 50 \
--access-logfile - \
--error-logfile -
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=35
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now api
sudo systemctl status api
journalctl -u api -f # logs en directo
Dockerfile multi-stage
# Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local/lib/python3.12/site-packages
COPY . .
RUN python manage.py collectstatic --noinput
USER 1000
EXPOSE 8000
CMD ["gunicorn", "core.asgi:application", \
"--workers", "4", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", \
"--max-requests", "1000", \
"--access-logfile", "-"]
# docker-compose.yml
services:
api:
build: .
env_file: .env.production
depends_on: [postgres, redis]
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 10s
timeout: 3s
retries: 3
postgres:
image: postgres:16
volumes: ["pgdata:/var/lib/postgresql/data"]
env_file: .env.postgres
redis:
image: redis:7
nginx:
image: nginx:1.25
ports: ["80:80", "443:443"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/letsencrypt:ro
depends_on: [api]
volumes:
pgdata:
Diagrama del stack
flowchart LR
U[Usuario] -->|HTTPS| LB[Nginx<br>TLS, gzip, rate limit]
LB -->|HTTP/1.1| GU[Gunicorn]
GU --> W1[Uvicorn worker 1]
GU --> W2[Uvicorn worker 2]
GU --> W3[Uvicorn worker 3]
GU --> W4[Uvicorn worker 4]
W1 --> DB[(Postgres)]
W2 --> R[(Redis)]
W3 --> S3[(S3)]
LB -->|/static, /media| FS[(Disco)]
Errores frecuentes
workers=1en produccion: solo atiende una peticion a la vez.workers=200en una maquina con 2 cores: context switching mata el rendimiento.- Sin
max-requests: memory leaks acumulan y el worker se vuelve OOM tras dias. DEBUG=Trueen produccion: filtra tracebacks completos con secrets.- Servir staticos por gunicorn: gunicorn no sirve estaticos. Si Nginx no los sirve, configura WhiteNoise (
pip install whitenoise) anadiendolo al MIDDLEWARE. - Sin
SECURE_PROXY_SSL_HEADERdetras de nginx con TLS: Django cree que la peticion es HTTP y rechaza porSECURE_SSL_REDIRECT.
Kubernetes (opcional)
En K8s gunicorn arranca igual dentro del pod. Anade:
- livenessProbe ->
/healthz. - readinessProbe ->
/readyz. - resources.requests y limits (CPU y memoria).
- HPA (Horizontal Pod Autoscaler) por CPU o por metrica custom Prometheus (
req_per_sec). - Service ClusterIP + Ingress (nginx-ingress o traefik) que sustituyen al nginx VPS.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Django es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Django
Explora más contenido relacionado con Django y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Entender la diferencia WSGI frente a ASGI y cuando necesitas uno u otro. Calcular numero optimo de workers (cores * 2 + 1). Configurar gunicorn con uvicorn workers para soportar async views. Configurar nginx con TLS, gzip, proxy_pass y proxy_set_header. Servir /static/ y /media/ desde nginx. Implementar /healthz y /readyz para healthchecks. Crear un Dockerfile multi-stage para la imagen y un docker-compose.yml. Desplegar con systemd en un VPS.
Cursos que incluyen esta lección
Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje