Cómo proteger una aplicación Django antes de producción

Tue Aug 11 2026

SECURE_PROXY_SSL_HEADER puede corregir la detección de HTTPS o, si tu proxy transmite los encabezados del cliente, hacer que un encabezado controlado por un atacante forme parte de tu modelo de seguridad.

Pero ese límite es donde la mayoría de las listas de comprobación de Django se quedan cortas. DEBUG = False es necesario, pero no es un plan de seguridad; un proxy defectuoso, una ruta de media o un flujo de secretos mal configurado pueden dejar expuesta la aplicación desplegada. Django ofrece valores predeterminados sólidos para ataques comunes. Tu despliegue debe preservarlos.

Por eso, sigue esta lista de comprobación de seguridad de Django en orden: audita la configuración de producción, protege el tráfico del navegador y la identidad, revisa las vías de escape de la aplicación y, después, prueba nginx, Gunicorn, los media subidos, los servicios privados, las alertas y las copias de seguridad.

En este artículo

Antes de cambiar código, ejecuta la auditoría de despliegue de Django

Así que ejecuta la comprobación de despliegue de Django contra el módulo de configuración que utilizará tu proceso de producción:

python manage.py check --deploy --settings=config.settings.production

check --deploy es una alarma de humo, no una inspección contra incendios. Detecta muchas advertencias de configuración del despliegue, incluidos el modo de depuración, la configuración de la clave, las cookies inseguras, la ausencia de redirecciones HTTPS, HSTS y las deficiencias de configuración de hosts. No puede inspeccionar si tu balanceador de carga elimina el X-Forwarded-Proto proporcionado por el cliente, si nginx rechaza los hosts desconocidos, si PostgreSQL es accesible públicamente o si /media/ puede ejecutar un archivo subido.

Tampoco puede determinar si una vista de detalles de un problema olvidó una comprobación de autorización a nivel de objeto. Django puede inspeccionar la configuración; no puede inferir la intención de seguridad de tu aplicación.

Usa el comando como primera barrera:

  1. Configura: corrige cada advertencia que se aplique a producción.
  2. Inspecciona: revisa la configuración del proxy, el servidor web, la base de datos, la caché, el almacenamiento y las alertas.
  3. Verifica: envía solicitudes al servicio desplegado y registra sus respuestas.

Y la lista de comprobación de despliegue de Django te proporciona la base. El trabajo útil comienza donde terminan sus comprobaciones automatizadas.

Mantén los secretos de producción fuera del repositorio

Un SECRET_KEY filtrado puede comprometer las sesiones firmadas, los tokens de restablecimiento de contraseña u otros datos protegidos con esa clave, dependiendo de cómo la utilice tu aplicación. Las credenciales de la base de datos merecen el mismo tratamiento y solo deberían poder utilizarse desde la infraestructura de aplicaciones aprobada.

Django requiere una clave secreta grande y aleatoria. Genera una con Django:

python -m django shell -c \
  "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Y léela desde el entorno o desde un archivo protegido:

# config/settings/production.py
import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["POSTGRES_DB"],
        "USER": os.environ["POSTGRES_USER"],
        "PASSWORD": os.environ["POSTGRES_PASSWORD"],
        "HOST": os.environ["POSTGRES_HOST"],
        "PORT": os.environ.get("POSTGRES_PORT", "5432"),
    }
}

La ausencia de un secreto obligatorio debería detener el arranque. Un servidor que recurre silenciosamente a una clave de desarrollo convierte un error de despliegue en una vulnerabilidad activa.

Para una rotación escalonada, usa SECRET_KEY_FALLBACKS:

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]

SECRET_KEY_FALLBACKS = [
    key.strip()
    for key in os.environ.get("DJANGO_SECRET_KEY_FALLBACKS", "").split(",")
    if key.strip()
]

Usa los valores alternativos durante una breve ventana de migración y después elimina rápidamente las claves antiguas. Conservar para siempre todas las claves históricas convierte la rotación en una mera apariencia.

RevisiónAcción
Repositorio y manifiestosBusca claves, contraseñas, tokens y URLs de bases de datos
Proceso de despliegueConfirma que los secretos llegan a través de la plataforma o de archivos protegidos
RotaciónDocumenta el reemplazo, la duración del valor alternativo y la revocación
Comportamiento ante fallosHaz que la ausencia de secretos obligatorios detenga el arranque
git grep -nE "SECRET_KEY|PASSWORD|DATABASE_URL|TOKEN|API_KEY"

Así que despliega una vez con un secreto ausente deliberadamente en un entorno que no sea de producción. El proceso debería fallar antes de servir tráfico. Comprueba los registros de arranque. Confirma que el valor del secreto previsto nunca aparezca.

Haz que Django falle de forma segura en producción

Pero la configuración de producción debería ser explícita. No dependas de que un archivo de desarrollo quede “mayormente sobrescrito”.

ConfiguraciónDirección en producciónRiesgo reducido
DEBUGFalsePáginas de depuración que exponen código fuente, configuración y variables locales
ALLOWED_HOSTSNombres de host exactos del despliegueAtaques mediante el encabezado Host y aceptación de hosts no previstos
SECRET_KEYValor secreto, aleatorio y cargado desde el entornoFalsificación de datos firmados
SESSION_COOKIE_SECURETrueTransmisión de sesiones mediante HTTP
CSRF_COOKIE_SECURETrueTransmisión de la cookie CSRF mediante HTTP
SECURE_SSL_REDIRECTTrue cuando Django ve HTTPAcceso HTTP accidental

Así que, para un pequeño gestor de incidencias en app.example.com, nginx puede terminar HTTPS y hacer proxy hacia Gunicorn:

# config/settings/production.py
DEBUG = False

ALLOWED_HOSTS = [
    "app.example.com",
]

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True

Sustituye el nombre de host por los nombres exactos a los que accederán tus usuarios. ALLOWED_HOSTS = ["*"] es un atajo que traslada el riesgo a otro lugar. Si no puedes nombrar y probar ese otro validador, no lo uses.

Con DEBUG=False, Django requiere un valor adecuado para ALLOWED_HOSTS. Las respuestas de depuración pueden exponer fragmentos de código fuente, configuración, variables locales y detalles de bibliotecas.

Comprueba el comando del proceso y el archivo que utiliza:

ps aux | grep '[g]unicorn'

Después solicita un host no válido a través de la ruta pública. Una solicitud válida debería llegar a la aplicación; un host desconocido debería recibir un rechazo en lugar de una respuesta normal de la aplicación.

Trata los encabezados Host como un límite de entrada

Django valida el encabezado Host mediante request.get_host() contra ALLOWED_HOSTS. El acceso directo a los metadatos sin procesar evita esa protección.

# Validado por Django
host = request.get_host()
# Entrada sin procesar de la solicitud
host = request.META["HTTP_HOST"]

Busca ambos patrones:

git grep -nE "HTTP_HOST|USE_X_FORWARDED_HOST|get_host"

Usa request.get_host() para validar el host y, después, evita utilizar la entrada del host para seleccionar el tenant o crear redirecciones, a menos que el valor resultante esté permitido por una lista separada. Los valores de host sin procesar pueden influir en las URLs absolutas y los destinos de redirección. Pueden afectar a los enlaces de restablecimiento de contraseña, al enrutamiento de tenants y a las comprobaciones de origen.

USE_X_FORWARDED_HOST = True cambia el origen del host que utiliza Django. Actívalo únicamente cuando un proxy conocido establezca ese encabezado y elimine los valores no confiables proporcionados por el cliente.

Prueba el extremo y la aplicación por separado. Para HTTPS, el SNI coincidente es tan importante como el encabezado Host:

curl -i --resolve app.example.com:443:SERVER_IP \
  https://app.example.com/

Repite la prueba con un encabezado Host inesperado contra la IP pública y, cuando esté permitido, contra el origen. El host desconocido debería llegar a la ruta de rechazo.

Fuerza HTTPS para todo el sitio

Cualquier sitio que acepte inicios de sesión debería forzar HTTPS en todas partes. Las cookies de sesión, los tokens de restablecimiento de contraseña y los tokens de acceso transportan credenciales. Proteger únicamente /login/ o /admin/ sigue dejando disponible una sesión autenticada del navegador para solicitudes HTTP en otros lugares.

Configura Django y el servidor web externo:

SECURE_SSL_REDIRECT = True

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

# Valor inicial de despliegue mientras se verifica la cobertura HTTPS.
SECURE_HSTS_SECONDS = 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False

Mantén HSTS en staging mientras SECURE_HSTS_SECONDS siga siendo 0; esta configuración es para staging, mientras que la configuración final utiliza una duración mayor. Auméntala únicamente después de que cada nombre de host relevante funcione mediante HTTPS. includeSubDomains cubre los subdominios. La precarga crea un compromiso de mayor duración.

El servidor web debería redirigir cada solicitud HTTP a HTTPS antes de enviar contenido de la aplicación mediante proxy. Comprueba la cadena de redirección:

curl -I http://app.example.com/
curl -I https://app.example.com/

La respuesta HTTP debería redirigir sin representar contenido autenticado. La respuesta HTTPS debería incluir HSTS una vez activado. HSTS enviado mediante HTTP no establece la política.

En un navegador, inicia sesión mediante HTTPS e inspecciona las cookies de sesión y CSRF. Ambas deberían incluir Secure. Registra también los valores HttpOnly y SameSite de la cookie de sesión. Las cookies personalizadas creadas con set_cookie() también necesitan las marcas adecuadas.

No confíes ciegamente en SECURE_PROXY_SSL_HEADER

No pegues SECURE_PROXY_SSL_HEADER en la configuración de producción porque te lo haya dicho un blog. Puedo decirte qué debes verificar aquí; no puedo inferir el límite de confianza de tu proxy únicamente a partir de la configuración de Django.

Un proxy inverso crea un contrato de confianza:

navegador
  │ HTTPS

proxy que termina TLS
  │ X-Forwarded-Proto: https

Gunicorn → Django

Esta configuración solo es adecuada cuando:

  1. El proxy externo termina TLS.
  2. Establece X-Forwarded-Proto: https para las solicitudes HTTPS.
  3. Elimina o reemplaza cualquier X-Forwarded-Proto proporcionado por el cliente.
  4. Gunicorn solo es accesible a través de esa ruta de confianza.
  5. Cada proxy situado delante de Django sigue el mismo contrato.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Si el proxy no establece el encabezado, SECURE_SSL_REDIRECT puede redirigir repetidamente una solicitud del navegador que ya es segura. Las comprobaciones de referer CSRF para solicitudes seguras también pueden fallar porque Django cree que la solicitud es HTTP.

Un diagnóstico práctico debería mostrar el esquema efectivo únicamente a un operador autorizado:

registro de acceso del proxy: forwarded_proto=https
diagnóstico de Django: request.is_secure()=True
respuesta: sin redirección

Registra temporalmente y de forma segura el valor del encabezado recibido en el límite del proxy. Después ejecuta la prueba de suplantación contra el extremo público:

curl -i -H 'X-Forwarded-Proto: http' https://app.example.com/

Compara el registro del encabezado recibido por el proxy con el valor enviado a Gunicorn. Comprueba también el resultado de request.is_secure() en el diagnóstico. El valor proporcionado por el cliente debe eliminarse o reemplazarse antes de que Django lo vea. Cuando esté permitido, repite la prueba contra el origen. Gunicorn debería ser accesible únicamente a través de la ruta de red de confianza.

La referencia del middleware de Django documenta la salvedad del proxy. En muchos despliegues, redirigir HTTP en el servidor web principal es más fácil y seguro porque ese componente ya conoce el estado de TLS.

Mantén intacta la protección CSRF en formularios y AJAX

El CsrfViewMiddleware de Django protege métodos inseguros como POST, PUT, PATCH y DELETE. Mantenlo en MIDDLEWARE:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    # ...
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

Incluye un token en los formularios internos:

<form method="post" action="{% url 'issues:create' %}">
  {% csrf_token %}
  <label>
    Title
    <input name="title">
  </label>
  <button type="submit">Create issue</button>
</form>

El siguiente ejemplo de AJAX supone que un formulario renderizado proporciona el token del DOM:

const token = document.querySelector(
  "[name=csrfmiddlewaretoken]"
).value;

fetch("/issues/42/comment/", {
  method: "POST",
  credentials: "same-origin",
  headers: {
    "X-CSRFToken": token,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ body: "Needs reproduction steps." })
});

Si tu página no renderiza un formulario, utiliza el patrón documentado por Django para leer la cookie cuando la configuración de cookies lo permita. CSRF_COOKIE_HTTPONLY=True cambia ese procedimiento: JavaScript no puede leer la cookie, por lo que se necesita un token del DOM renderizado por el servidor u otra vía de entrega deliberada.

Las solicitudes seguras también reciben una comprobación de referer HTTPS. Por tanto, una configuración incorrecta del esquema del proxy o del host puede parecer un defecto de CSRF.

SolicitudResultado esperado
POST sin tokenRechazo CSRF
POST con token de formulario válidoÉxito
POST AJAX con token y credenciales del mismo origenÉxito
PUT, PATCH o DELETE sin tokenRechazo CSRF
Solicitud entre orígenes que cambia el estadoRechazada salvo que esté diseñada y protegida deliberadamente
Endpoint csrf_exemptPrueba independiente de autenticación e integridad

Revisa cada aparición de csrf_exempt. Cada excepción necesita un motivo limitado y una prueba específica.

Refuerza la autenticación donde los atacantes ejercen presión

Usa django.contrib.auth, las sesiones de Django y los validadores de contraseñas en lugar de inventar reemplazos:

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": (
            "django.contrib.auth.password_validation."
            "UserAttributeSimilarityValidator"
        ),
        "OPTIONS": {"max_similarity": 0.7},
    },
    {
        "NAME": (
            "django.contrib.auth.password_validation."
            "MinimumLengthValidator"
        ),
        "OPTIONS": {"min_length": 8},
    },
    {
        "NAME": (
            "django.contrib.auth.password_validation."
            "CommonPasswordValidator"
        ),
    },
    {
        "NAME": (
            "django.contrib.auth.password_validation."
            "NumericPasswordValidator"
        ),
    },
]

Las funciones auxiliares de contraseñas de Django aplican hash y verifican las contraseñas:

from django.contrib.auth.hashers import check_password, make_password

stored = make_password(raw_password)
valid = check_password(raw_password, stored)

Mantén las contraseñas sin procesar fuera de los registros, correos electrónicos, analíticas y mensajes de excepción.

Inspecciona las vistas protegidas y los patrones de URL para comprobar la presencia de @login_required o un control de acceso equivalente. La autenticación demuestra la identidad. La autorización a nivel de objeto debe confirmar que el usuario puede acceder al problema o archivo adjunto solicitado.

Los validadores de contraseñas no ralentizan miles de intentos de inicio de sesión. Añade un control mantenido, como django-ratelimit o django-axes, al inicio de sesión, al restablecimiento de contraseña y a otros flujos sensibles. Elige uno y verifica su comportamiento real en lugar de suponer que la respuesta predeterminada del paquete es adecuada.

La hoja de trucos de seguridad de Django de OWASP recomienda cambiar la URL de administración predeterminada para reducir las sondas automatizadas. Cambiar /admin/ reduce las sondas automatizadas, mientras que la autenticación sigue dependiendo de controles de acceso reales. Trátalo como el último tramo de la defensa.

MFA es mi recomendación para las cuentas sensibles y de administrador. DjangoZen también presenta MFA como una medida de refuerzo de la autenticación, pero el núcleo de Django no proporciona una política MFA completa. Fuérzala mediante un paquete de aplicación o un proveedor de identidad y, después, prueba el registro, la recuperación, la invalidación de sesiones y la aplicación para administradores.

Después del umbral configurado, el flujo de inicio de sesión debe dejar de aceptar intentos ilimitados. Registra el estado, la respuesta y el comportamiento de restablecimiento del control elegido. Intenta también acceder a una ruta administrativa protegida como usuario normal y confirma el rechazo de autorización.

Deja que los valores predeterminados de Django gestionen la inyección SQL hasta que los evadas

Un queryset normal mantiene la entrada del usuario como valor de búsqueda:

def find_issues(term):
    return Issue.objects.filter(title__icontains=term)

La API de queryset de Django parametriza los valores de búsqueda. El SQL sin procesar sigue siendo posible, pero pasa los valores mediante el argumento de parámetros del controlador.

Este es un ejemplo de PostgreSQL porque ILIKE es sintaxis de PostgreSQL:

from django.db import connection

def find_issues(term):
    with connection.cursor() as cursor:
        cursor.execute(
            """
            SELECT id, title
            FROM app_issue
            WHERE title ILIKE %s
            """,
            [f"%{term}%"],
        )
        return cursor.fetchall()

Los parámetros protegen los valores. No hacen seguro un nombre de tabla o columna proporcionado por el usuario. Las cláusulas de ordenación y las palabras clave SQL necesitan el mismo tratamiento. Toma esas opciones de listas permitidas fijas:

ORDERING = {
    "newest": "created_at DESC",
    "oldest": "created_at ASC",
}

order_sql = ORDERING.get(request.GET.get("order"), "created_at DESC")

Revisa raw(), RawSQL, extra(), los managers personalizados, las consultas de informes, cursor.execute() y las funciones de base de datos:

git grep -nE "raw\(|RawSQL|extra\(|cursor\(|execute\("

Para cada resultado, confirma que los valores utilizan parámetros y que los identificadores proceden de código controlado. La autorización es una revisión independiente.

Conserva el escape de plantillas en lugar de corregir la salida manualmente

Las plantillas de Django escapan por defecto los caracteres HTML peligrosos, pero la salida específica de cada contexto aún necesita revisión. Mantén los valores no confiables fuera de JavaScript y CSS. Gestiona los atributos HTML y otros contextos con el escape adecuado.

Revisa cada vía de escape deliberada:

  • |safe
  • mark_safe()
  • is_safe
  • {% autoescape off %}

Pueden ser correctas para marcado confiable y construido deliberadamente. Son peligrosas para títulos de problemas, comentarios, nombres de usuario, metadatos subidos y otros valores influenciados por el usuario.

Usa json_script al pasar datos estructurados a JavaScript:

{{ issue_data|json_script:"issue-data" }}

<script>
  const issue = JSON.parse(
    document.getElementById("issue-data").textContent
  );
</script>

Prueba un comentario de problema que contenga corchetes angulares, comillas y saltos de línea. Debería representarse como texto, y la ruta de datos de JavaScript debería analizarse sin crear marcado ejecutable.

CSP merece su lugar después de que funcionen los controles aburridos. Publicar una política complicada antes de comprender tus scripts crea una falsa sensación de seguridad. Django no incluye CSP; añade un paquete mantenido como django-csp o configura tú mismo el encabezado de respuesta.

Mi despliegue recomendado es:

  1. Envía Content-Security-Policy-Report-Only y filtra los informes legítimos.
  2. Corrige las infracciones restantes.
  3. Aplica Content-Security-Policy.

Verifica la respuesta desplegada y el comportamiento del navegador:

curl -sS -D - -o /dev/null https://app.example.com/

Después confirma que un script deliberadamente no permitido se bloquea una vez que comience la aplicación de la política.

Añade los encabezados que Django puede proporcionar y conoce sus límites

Riesgo: los encabezados de respuesta reducen el clickjacking, la confusión de tipos MIME, la filtración de referers y los riesgos de ventanas entre orígenes. Son defensa en profundidad, no un sustituto de una salida segura o HTTPS.

La documentación de seguridad de Django y la guía de Django de OWASP cubren el middleware del framework y los controles de encabezados. Registra la respuesta real de cada capa que sirve contenido.

EncabezadoConfiguraciónAmenaza reducidaVerificación
X-Content-Type-Options: nosniffSecurityMiddleware; SECURE_CONTENT_TYPE_NOSNIFF = TrueConfusión del tipo MIMEcurl -I
X-Frame-OptionsXFrameOptionsMiddleware; X_FRAME_OPTIONS = "DENY" o "SAMEORIGIN"ClickjackingPrueba de encabezado y frame
Referrer-PolicyEstablece este encabezado de respuesta en Django o nginxFiltración de información del refererInspeccionar la respuesta desplegada
Cross-Origin-Opener-PolicyEstablece este encabezado de respuesta en Django o nginxAislamiento de ventanas entre orígenesInspeccionar la respuesta desplegada
Content-Security-PolicyPaquete independiente o encabezado de respuesta deliberadoLimita parte del impacto de XSSConsola del navegador e informes de política

Una base podría verse así:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    # ...
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"

Elige SAMEORIGIN si la aplicación inserta deliberadamente páginas en frames del mismo origen. Los archivos estáticos servidos directamente por nginx nunca pasan por el middleware de Django, así que inspecciona esas respuestas por separado:

curl -sS -D - -o /dev/null https://app.example.com/
curl -sS -D - -o /dev/null https://app.example.com/static/app.css
curl -sS -D - -o /dev/null https://app.example.com/missing-page

Los media subidos no son confiables y no deben ejecutarse

Riesgo: una carga de archivos puede convertirse en una ruta de ejecución en el servidor o exponer datos privados de problemas si el servidor web y el límite de autorización están mal configurados.

Mantén separados el código de la aplicación, los archivos estáticos recopilados y los media de los usuarios:

STATIC_ROOT = "/srv/app/static/"
MEDIA_ROOT = "/srv/app/media/"
STATIC_URL = "/static/"
MEDIA_URL = "/media/"

En el gestor de incidencias, los archivos adjuntos contienen datos de problemas potencialmente privados. Para los archivos adjuntos privados, autoriza cada descarga antes de servirla. No puedo saber por esta lista de comprobación si la autorización de tus archivos adjuntos es correcta; pruébala con dos usuarios reales y un archivo privado.

nginx debería servir /static/ y /media/ como archivos, mientras Gunicorn sirve las rutas de la aplicación. La ruta de media no debe solaparse con el árbol de código Python ni con ninguna raíz de documentos capaz de ejecutar scripts en la configuración de servidor web elegida. Revisa los handlers del servidor:

sudo nginx -T

Inspecciona la ubicación /media/ y verifica que no tenga ningún handler de intérprete FastCGI, CGI, uWSGI, proxy o equivalente. Después sube un archivo de prueba de texto plano en staging y solicítalo mediante la ruta de media desplegada. La respuesta debería ser una respuesta de archivo ordinaria, nunca una ruta de ejecución de la aplicación.

Si los archivos adjuntos privados deberían descargarse en lugar de representarse, verifica el Content-Disposition elegido; la configuración de media de Django no lo impone.

Haz que el servidor web rechace los hosts desconocidos antes de que Django los vea

Riesgo: un virtual host predeterminado explícito evita que los valores Host desconocidos lleguen a la ruta de enrutamiento normal de la aplicación.

ALLOWED_HOSTS de Django sigue siendo obligatorio. nginx también debería tener un comportamiento predeterminado explícito para los virtual hosts que no coincidan. El patrón HTTP oficial es:

server {
    listen 80 default_server;
    server_name _;
    return 444;
}

Para HTTPS, configura un servidor TLS predeterminado explícito según tu versión de nginx, la disposición de certificados y los requisitos de selección de certificados. No copies las rutas de certificados de este artículo como valores universales. La propiedad importante es que un virtual host TLS sin coincidencia tenga un comportamiento deliberado y no envíe tráfico arbitrario mediante proxy a la aplicación.

Prueba HTTP y HTTPS por separado. Para HTTPS, utiliza un SNI coincidente:

curl -i -H 'Host: unknown.example' http://SERVER_IP/
curl -i --resolve app.example.com:443:SERVER_IP \
  https://app.example.com/

El host HTTP desconocido debería llegar a la ruta de rechazo de nginx. El nombre de host HTTPS válido debería llegar al virtual host TLS previsto.

Comprueba que los servicios de producción que Django supone privados lo sean

Riesgo: una aplicación orientada al navegador aún puede verse comprometida mediante una base de datos expuesta, una caché, una sesión obsoleta, una recopilación de estáticos ausente o una copia de seguridad no probada.

ServicioConfigurar e inspeccionarVerificación
PostgreSQLRestringir el acceso a los servidores de aplicaciones; proteger la contraseñaConectarse desde una red no autorizada y esperar un rechazo
Redis o cachéRestringir el acceso a los servidores de aplicaciones; revisar la autenticaciónIntentar una conexión no autorizada y esperar un rechazo
Archivos estáticosEstablecer STATIC_ROOT; ejecutar collectstaticSolicitar un recurso conocido e inspeccionar su capa de servicio
SesionesIdentificar el backend y el comportamiento de expiraciónCerrar sesión y solicitar después un recurso protegido
Copias de seguridadDefinir la retención y un responsableRestaurar en aislamiento y registrar el resultado

Para el gestor de incidencias, Redis puede contener sesiones y datos de caché, mientras PostgreSQL almacena problemas y usuarios. Inspecciona la dirección de enlace de Gunicorn y los permisos del socket Unix. Revisa los grupos de seguridad y las reglas del firewall y, después, prueba desde una red no autorizada. “El puerto no está en la configuración de la aplicación” no es una prueba.

Si utilizas sesiones respaldadas por la base de datos, identifica el trabajo de limpieza requerido por tu despliegue y prográmalo. Para otros backends, verifica por separado su expiración y comportamiento de expulsión. Las sesiones en caché, las conexiones persistentes a la base de datos y el cargador de plantillas en caché de Django cuando DEBUG=False son comprobaciones operativas; no sustituyen las restricciones de red.

Restaura la copia de seguridad más reciente en una base de datos aislada, ejecuta una consulta conocida o una comprobación de migración y registra la duración, la marca de tiempo y el resultado de la restauración.

Configura los informes de fallos sin filtrar detalles de los fallos

Riesgo: los usuarios deberían recibir errores de producción genéricos, mientras que los responsables del mantenimiento reciben información suficiente para responder.

ADMINS = [
    ("Operations", "ops@example.com"),
]
MANAGERS = ADMINS

DEFAULT_FROM_EMAIL = "app@example.com"
SERVER_EMAIL = "server@example.com"

IGNORABLE_404_URLS = [
    # Añade solo patrones ruidosos conocidos, como una ruta de escáner documentada.
]

ADMINS recibe notificaciones de errores del servidor. MANAGERS recibe notificaciones de errores 404. Los informes por correo electrónico no escalan bien. Usa un servicio de monitorización de errores escalable cuando el volumen o el tiempo de respuesta lo requieran.

Elimina Authorization, las cookies y las contraseñas antes de reenviar eventos. Elimina también los tokens de restablecimiento, las credenciales de la base de datos y el contenido subido. Mantén esa revisión en la configuración de monitorización en lugar de suponer que un recopilador es seguro para los cuerpos de solicitud sin procesar.

Prueba en staging o mediante un mecanismo temporal de mantenimiento autenticado que se elimine antes del lanzamiento. Provoca un error 500 conocido y solicita una URL ausente conocida. Las respuestas visibles para el usuario no deberían contener ninguna página de depuración; las alertas deberían llegar al canal previsto.

Verifica la aplicación desplegada el día del lanzamiento

La revisión final debería seguir la ruta desplegada, no volver a leer el archivo de configuración.

Ejecuta la barrera automatizada:

python manage.py check --deploy --settings=config.settings.production

Después registra las respuestas desplegadas:

curl -I http://app.example.com/
curl -sS -D - -o /dev/null https://app.example.com/
curl -i -H 'Host: unexpected.example' http://SERVER_IP/
curl -i --resolve app.example.com:443:SERVER_IP \
  https://app.example.com/

Usa los registros para marcar estas comprobaciones:

[ ] El módulo de configuración de producción lo utilizan los comandos web, worker, de migración y de auditoría.
[ ] Los secretos son externos, están ausentes del código fuente y los registros, y la rotación está documentada.
[ ] DEBUG=False y los ALLOWED_HOSTS exactos están activos.
[ ] HTTP redirige a HTTPS; las cookies de sesión muestran Secure, HttpOnly y SameSite.
[ ] Los fallos CSRF y las solicitudes válidas de formularios/AJAX se comportan como se espera.
[ ] La limitación de inicios de sesión detiene los intentos ilimitados; MFA se aplica a las cuentas designadas.
[ ] El SQL sin procesar y las vías de escape de plantillas tienen responsables identificados y pruebas aprobadas.
[ ] Las respuestas HTTPS incluyen HSTS cuando está habilitado, nosniff, X-Frame-Options, Referrer-Policy y COOP según la configuración.
[ ] nginx rechaza hosts desconocidos; Gunicorn es privado; media no tiene ningún handler de intérprete.
[ ] Los archivos adjuntos privados rechazan a un usuario no autorizado.
[ ] PostgreSQL y Redis rechazan clientes de red no autorizados.
[ ] Los archivos estáticos, las alertas de errores y el filtrado 404 de alcance limitado funcionan.
[ ] Se completó y registró una restauración de la base de datos.
[ ] Las versiones desplegadas de Django y sus dependencias tienen soporte y se revisaron las vulnerabilidades conocidas.

Pospone el lanzamiento si alguna comprobación de límites carece de evidencia registrada. Asigna un responsable. Un diff de configuración en verde no es una prueba.