Debian 13: Nginx devuelve de repente 403/404 — permisos vs configuración, cómo identificar al instante

¿Te fue útil?

Despliegas. Recargas. Pulsas actualizar. En lugar de tu sitio obtienes un nítido e inútil 403 o 404. Ayer funcionaba. Hoy Nginx actúa como si nunca hubiera visto tus ficheros.

En Debian 13, la vía más rápida para salir no es “mirar la configuración hasta que se sequen los ojos.” Es probar si estás frente a un muro de permisos, una descoincidencia de enrutamiento/configuración, o un mecanismo de seguridad (AppArmor, symlinks, entornos tipo chroot) que está haciendo su trabajo. Este artículo es el flujo pragmático que uso en servidores de producción: adivinanzas mínimas, máxima señal.

Guía rápida de diagnóstico

Este es el orden de triaje que gana bajo presión. Está sesgado a responder primero la única pregunta que importa: “¿Se le permite a Nginx leer el archivo que intenta servir, y está siquiera intentando servir el archivo que creo?”

Primero: lee el registro de errores para la petición exacta

No empieces editando la configuración. Empieza viendo qué dice Nginx en el momento del fallo. Para 403/404, el registro de errores normalmente contiene la verdad en una línea: la ruta resuelta, la razón del fallo y, a veces, qué bloque server procesó la petición.

Segundo: confirma qué bloque server está manejando la petición

La mitad de los incidentes de “404 repentino” son en realidad “estás en el host virtual equivocado.” Cambios en Debian, renovaciones de certificados, bloques server añadidos o un nuevo sitio por defecto pueden robar tráfico silenciosamente.

Tercero: valida la resolución del path (root/alias/try_files)

403/404 a menudo se reduce a: la URI que pediste no se asigna al archivo que crees. Nginx tiene un modelo de mapeo muy literal. Si te equivocas por una barra, estás equivocado.

Cuarto: prueba permisos del sistema de archivos a lo largo de toda la ruta

Nginx no solo necesita permiso de lectura sobre el archivo; necesita permiso de ejecución (“travesía”) en cada directorio del camino. Un directorio estricto en medio produce “permission denied” aunque el archivo mismo sea legible por todos.

Quinto: verifica la política LSM (AppArmor es común en Debian)

En Debian, los perfiles de AppArmor pueden negar lecturas de una forma que parece permisos Unix ordinarios o “archivo no encontrado.” Tus logs te dirán si los escuchas.

Sexto: comprueba que no recargaste una configuración que no coincide con el proceso en ejecución

Systemd puede mostrar “active (running)” mientras Nginx está sirviendo una configuración antigua porque una recarga falló, o porque recargaste la instancia/contenedor/chroot equivocado. Valida la configuración cargada rápidamente.

Regla operativa: si no puedes reproducirlo con curl -v mientras sigues los logs de acceso+error, estás depurando un rumor.

Modelo mental instantáneo: qué significan realmente 403 y 404 en Nginx

Nginx es determinista. Si piensas que es aleatorio, simplemente no has encontrado la entrada que lo hace comportarse así.

403 Forbidden: Nginx encontró el “sitio”, pero no servirá el recurso

403 suele ser comúnmente una de estas:

  • El archivo existe pero no es legible por el usuario worker de Nginx.
  • Travesía de directorio bloqueada (sin bit de ejecución en un directorio padre).
  • Índice de directorio prohibido: solicitaste un directorio y Nginx no puede encontrar un archivo index y autoindex está desactivado.
  • Reglas de denegación explícitas en la configuración (por ejemplo, deny all;, listas de IPs permitidas).
  • Política de symlinks: disable_symlinks u opciones de montaje extrañas pueden causar la denegación.
  • Denegación por AppArmor.

404 Not Found: Nginx no pudo mapear la URI a un archivo (o eligió no revelarlo)

404 suele significar “root/alias equivocado” o “bloque server equivocado”, pero también puede ser un disfraz deliberado: algunas configuraciones mapean accesos denegados a 404 para evitar revelar la existencia de archivos sensibles.

Por qué no puedes fiarte solo del código de estado

Nginx puede configurarse para devolver 404 por contenido prohibido, o para reescribir a una ubicación interna que devuelva otra cosa. Así que la jugada es: el código es una pista; los logs son la evidencia.

Broma #1: Cuando un on-call dice “es solo un 404”, yo escucho “es solo un incendio, pero está en los logs.”

Hechos interesantes y contexto histórico (útil, no trivia)

  • Nginx fue diseñado para concurrencia predecible (modelo basado en eventos) donde una petición mal enrutada puede fallar muy consistentemente a escala—genial para depurar si miras una petición representativa.
  • 403 vs 404 tiene historia de seguridad: muchas organizaciones retornan intencionalmente 404 para rutas protegidas para reducir la enumeración de endpoints.
  • El bit “execute” de Unix en directorios significa “puede traversar”, no “puede ejecutar”. Un directorio puede ser legible pero no traversable, lo cual confunde incluso a desarrolladores experimentados.
  • Los valores por defecto del empaquetado de Debian favorecen la seguridad: el layout estándar bajo /etc/nginx/sites-available y sites-enabled está pensado para reducir exposiciones accidentales, pero también crea incidentes de “vhost equivocado” durante cambios.
  • AppArmor llegó como control mainstream en muchas distribuciones para proporcionar control de acceso obligatorio sin la carga operativa de SELinux, y puede bloquear silenciosamente rutas fuera de los roots web esperados.
  • Alias vs root ha sido un arma de doble filo: alias de Nginx se comporta diferente a root en bloques location, y una barra faltante puede convertir una URI válida en un 404 garantizado.
  • Las ubicaciones “internal” de Nginx se usan ampliamente para autenticación y manejo de errores; pueden hacer que tu navegador vea 404 mientras Nginx está realmente golpeando un fallo de permisos en otro sitio.
  • Las reglas de selección del servidor por defecto importan: si ningún server_name coincide, Nginx elige un predeterminado. Añadir un nuevo bloque server puede cambiar quién es “por defecto” y causar 404 “repentinos”.

Tareas prácticas: comandos, qué significa la salida y qué decides después

Estas son tareas reales que ejecuto en producción. Copia/pega. Cada una incluye: comando, qué buscar y la decisión que dispara.

Task 1: Reproducir con curl y capturar cabeceras

cr0x@server:~$ curl -sv -o /dev/null http://example.internal/static/app.css
*   Trying 127.0.0.1:80...
* Connected to example.internal (127.0.0.1) port 80 (#0)
> GET /static/app.css HTTP/1.1
> Host: example.internal
> User-Agent: curl/8.6.0
> Accept: */*
< HTTP/1.1 404 Not Found
< Server: nginx/1.26.2
< Date: Mon, 29 Dec 2025 10:12:32 GMT
< Content-Type: text/html
< Content-Length: 153
< Connection: keep-alive

Significado: confirmaste que es Nginx el que responde, no un CDN o un upstream, y tienes la URI y el encabezado Host exactos.

Decisión: conserva el valor exacto de Host; lo usarás para identificar el bloque server.

Task 2: Seguir el error log mientras reproduces

cr0x@server:~$ sudo tail -Fn0 /var/log/nginx/error.log
2025/12/29 10:12:32 [error] 1842#1842: *921 open() "/srv/www/example/static/app.css" failed (2: No such file or directory), client: 127.0.0.1, server: example.internal, request: "GET /static/app.css HTTP/1.1", host: "example.internal"

Significado: Nginx intentó abrir una ruta de archivo concreta. El código de error (2) es “No such file or directory.” Eso no es permisos; es mapeo de ruta o archivo ausente.

Decisión: verifica que el archivo exista en esa ruta exacta y confirma la lógica de root/alias para /static/.

Task 3: Seguir el access log para confirmar qué código de estado escribió Nginx

cr0x@server:~$ sudo tail -n 3 /var/log/nginx/access.log
127.0.0.1 - - [29/Dec/2025:10:12:32 +0000] "GET /static/app.css HTTP/1.1" 404 153 "-" "curl/8.6.0"

Significado: confirma que no es un artefacto de caché del navegador o códigos mezclados. Una petición, un código.

Decisión: continuar en Nginx+sistema de archivos, no en la aplicación upstream.

Task 4: Comprobar sintaxis de configuración antes de investigar más

cr0x@server:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Significado: no estás persiguiendo una recarga rota que nunca se aplicó.

Decisión: procede a “qué bloque server y qué root” en lugar de “¿Nginx siquiera está parseando?”

Task 5: Volcar la configuración cargada completa (el paso “¿qué está ejecutando Nginx realmente?”)

cr0x@server:~$ sudo nginx -T 2>/dev/null | sed -n '1,120p'
# configuration file /etc/nginx/nginx.conf:
user www-data;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;

events {
    worker_connections 768;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

Significado: ves includes y valores por defecto clave, especialmente el usuario worker (www-data por defecto en Debian).

Decisión: confirma que el archivo del sitio que editaste esté en sites-enabled y no esté sombreado por otro include.

Task 6: Identificar qué bloque server coincide con tu Host header

cr0x@server:~$ sudo nginx -T 2>/dev/null | awk '
$1=="server" && $2=="{" {inserver=1; sn=""; ls=""; fn=FILENAME}
inserver && $1=="server_name" {sn=$0}
inserver && $1=="listen" {ls=$0}
inserver && $1=="}" {print ls " | " sn; inserver=0}
'
listen 80; | server_name example.internal;
listen 80 default_server; | server_name _;

Significado: puedes ver si tu host se corresponde explícitamente o cae en el servidor por defecto.

Decisión: si estás golpeando server_name _; o un server por defecto inesperado, arregla el orden de vhosts / default_server, y deja de tocar permisos.

Task 7: Validar que la ruta de archivo resuelta exista

cr0x@server:~$ sudo ls -la /srv/www/example/static/app.css
ls: cannot access '/srv/www/example/static/app.css': No such file or directory

Significado: el archivo realmente no existe en la ruta que Nginx intentó.

Decisión: encuentra el web root correcto, corrige root/alias/try_files, o arregla tu despliegue que no envió el asset.

Task 8: Si el archivo existe, prueba acceso de lectura como el usuario de Nginx

cr0x@server:~$ sudo -u www-data head -c 64 /srv/www/example/static/app.css
head: cannot open '/srv/www/example/static/app.css' for reading: Permission denied

Significado: clásico problema de permisos. Ahora tienes prueba con el mismo usuario con el que corre Nginx.

Decisión: arregla propiedad/permisos/ACLs a lo largo de la cadena de directorios. No “chmod 777” y provocar un informe de incumplimiento.

Task 9: Comprobar bits de traversía de directorios a lo largo de la ruta

cr0x@server:~$ namei -l /srv/www/example/static/app.css
f: /srv/www/example/static/app.css
drwxr-xr-x root root /
drwxr-xr-x root root srv
drwx------ root root www
drwxr-xr-x root root example
drwxr-xr-x root root static
-rw-r--r-- root root app.css

Significado: /srv/www es drwx------. Aunque el archivo sea legible, www-data no puede traversar ese directorio, así que Nginx fallará.

Decisión: ajusta permisos de directorio (bit de ejecución para el usuario/grupo de Nginx) o mueve el contenido a un root web pensado para ser servido.

Task 10: Detectar rápidamente denegaciones de AppArmor

cr0x@server:~$ sudo journalctl -k -g apparmor --since "10 minutes ago"
Dec 29 10:11:58 server kernel: audit: type=1400 audit(1767003118.123:91): apparmor="DENIED" operation="open" profile="nginx" name="/srv/www/example/static/app.css" pid=1842 comm="nginx" requested_mask="r" denied_mask="r" fsuid=33 ouid=0

Significado: control de acceso obligatorio bloqueó la lectura. Los permisos Unix podrían estar bien, pero la política dice “no”.

Decisión: o bien ajustas el perfil para permitir esa ruta, o sirves archivos desde ubicaciones permitidas. Cambiar chmod no solucionará esto.

Task 11: Confirmar el usuario y procesos maestro/worker en ejecución

cr0x@server:~$ ps -o user,pid,cmd -C nginx
USER       PID CMD
root      1721 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
www-data   1842 nginx: worker process
www-data   1843 nginx: worker process

Significado: los workers corren como www-data. Esa es la identidad que debe leer tu contenido.

Decisión: deja de adivinar “qué usuario.” Arregla acceso para www-data (o para el usuario configurado si lo cambiaste).

Task 12: Detectar si la petición está siendo reescrita a otra cosa

cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -R --line-number -E 'try_files|rewrite|return 404|error_page 404|internal' /etc/nginx/sites-enabled
/etc/nginx/sites-enabled/example.conf:27:    try_files $uri $uri/ /index.html;
/etc/nginx/sites-enabled/example.conf:41:    error_page 403 404 = /errors/notfound.html;
/etc/nginx/sites-enabled/example.conf:42:    location = /errors/notfound.html { internal; }

Significado: incluso un 403 puede mapearse a una página 404 interna, y try_files puede dirigir archivos estáticos faltantes a un entrypoint SPA. Tu navegador podría ver 404 mientras Nginx hace exactamente lo que le dijiste.

Decisión: decide si el comportamiento de reescritura es intencional; si no lo es, ajusta try_files o el mapeo de error_page.

Task 13: Comprobar si estás golpeando el sitio por defecto “equivocado”

cr0x@server:~$ ls -l /etc/nginx/sites-enabled
total 0
lrwxrwxrwx 1 root root 34 Dec 29 09:48 default -> /etc/nginx/sites-available/default
lrwxrwxrwx 1 root root 34 Dec 29 09:49 example.conf -> /etc/nginx/sites-available/example.conf

Significado: el sitio por defecto de Debian está habilitado. Si tiene default_server en tu directiva listen, puede atrapar hosts no coincidentes y devolver el contenido equivocado (o 404).

Decisión: o bien desactiva el sitio por defecto en producción, o haz explícito tu vhost previsto como el por defecto.

Task 14: Verificar que las opciones de montaje del sistema de archivos no te saboteen

cr0x@server:~$ findmnt -no SOURCE,TARGET,FSTYPE,OPTIONS /srv
/dev/mapper/vg0-srv /srv ext4 rw,relatime

Significado: las opciones de montaje normalmente no son la causa de 403/404, pero pueden importar (por ejemplo, estado de solo lectura inesperado, bind mounts, o comportamiento overlay en contenedores).

Decisión: si ves ro inesperado o bind/overlay mounts, confirma que las rutas de despliegue y los mounts en contenedores coincidan con tus expectativas.

Task 15: Confirmar manejo de index cuando se solicita un directorio

cr0x@server:~$ curl -svo /dev/null http://example.internal/static/
*   Trying 127.0.0.1:80...
> GET /static/ HTTP/1.1
> Host: example.internal
< HTTP/1.1 403 Forbidden
< Server: nginx/1.26.2

Significado: solicitar un directorio puede producir 403 cuando no existe un archivo index y autoindex está desactivado.

Decisión: añade un index (por ejemplo, index.html), habilita autoindex (rara vez correcto en producción), o cambia el enrutamiento para evitar URIs de directorio.

Task 16: Confirmar que el despliegue no cambió la propiedad de archivos inesperadamente

cr0x@server:~$ sudo stat -c '%U %G %a %n' /srv/www/example /srv/www/example/static /srv/www/example/static/app.css
root root 755 /srv/www/example
root root 750 /srv/www/example/static
deploy deploy 640 /srv/www/example/static/app.css

Significado: el archivo es propiedad de deploy con modo 640. Si www-data no está en el grupo deploy, no puede leerlo.

Decisión: arregla la estrategia de propiedad/grupo (común: contenido legible por grupo + directorios setgid), o usa ACLs para www-data.

Task 17: Comprobar denegación relacionada con symlinks

cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -R --line-number 'disable_symlinks' /etc/nginx
/etc/nginx/nginx.conf:63:    disable_symlinks if_not_owner from=$document_root;

Significado: si tu contenido usa symlinks (común en despliegues), Nginx puede negarse a servirlos dependiendo de la propiedad.

Decisión: o bien alinea la propiedad, elimina symlinks para contenido servido, o ajusta la política intencionalmente (con los ojos abiertos).

Task 18: Confirmar que la recarga realmente ocurrió y no falló silenciosamente

cr0x@server:~$ sudo systemctl reload nginx; sudo systemctl status nginx --no-pager -l
● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since Mon 2025-12-29 09:40:10 UTC; 33min ago
       Docs: man:nginx(8)
   Main PID: 1721 (nginx)
      Tasks: 5 (limit: 18754)
     Memory: 8.4M
        CPU: 1.142s
     CGroup: /system.slice/nginx.service
             ├─1721 "nginx: master process /usr/sbin/nginx -g daemon on; master_process on;"
             ├─1842 "nginx: worker process"
             └─1843 "nginx: worker process"

Significado: el estado muestra que Nginx está corriendo, pero no demuestra que la recarga se haya aplicado. Combínalo con nginx -T y marcas de tiempo en logs cuando hagas cambios.

Decisión: si sospechas fallo de recarga, revisa entradas de journald por errores de recarga y haz nginx -t otra vez.

Fallos de permisos que parecen errores de configuración

La trampa de traversía de directorio (la que pica a los adultos)

Puedes poner chmod 644 en un archivo todo el día. Si algún directorio padre carece del bit de ejecución para el usuario de Nginx (o su grupo), Nginx no puede llegar. El resultado es un 403, y el registro de errores a menudo dirá (13: Permission denied).

En Debian, Nginx típicamente corre como www-data. Así que la prueba canónica es: ¿puede www-data leer el archivo? No “¿puede root leerlo?” Root puede leer tu diario; Nginx no.

Deriva de propiedad durante despliegues

Los pipelines CI/CD que hacen rsync de archivos, desempaquetan tarballs o cambian symlinks pueden cambiar propiedad y modos. Un usuario de despliegue deja archivos como deploy:deploy con 640, y de repente los assets estáticos están muertos.

Arréglalo en la fuente: establece un grupo consistente para el contenido servido, aplica umask, o usa ACLs. “Arreglar permisos después de cada despliegue” no es una estrategia; es un incidente recurrente.

AppArmor: permisos que no sabías que tenías

Debian usa comúnmente AppArmor. Si tu perfil de Nginx permite /var/www/** y sirves desde /srv/www/**, Nginx puede registrar “permission denied” aunque los bits de modo Unix sean perfectos.

Los logs de auditoría del kernel nombrarán el perfil y la ruta. Esa es tu pistola humeante. Si no la revisas, pasarás horas “arreglando” chmod y no lograrás nada.

Symlinks, propiedad y “disable_symlinks”

Los symlinks son populares para despliegues atómicos: current -> releases/2025-12-29. Nginx puede configurarse para restringir servir symlinks para prevenir un tipo de problemas de traversal y sorpresas por mala propiedad. Eso es un buen control—hasta que alguien olvida que existe.

Si ves errores relacionados con symlinks, decide si quieres que Nginx los sirva. Si sí, hazlo consistente y explícito; si no, haz que tu despliegue deje de usar symlinks bajo el document root.

Fallos de configuración que parecen permisos

Bloque server equivocado (ruleta del servidor por defecto)

Si el Host header no coincide con ningún server_name, Nginx selecciona un predeterminado. Ese predeterminado puede tener un root distinto, puede denegar todo, o puede apuntar a un directorio vacío. Resultado: 404 o 403 “repentino”.

La solución es aburrida: haz que tu server_name coincida con la realidad y controla default_server intencionalmente. No dejes que “el archivo que ordena primero” defina comportamiento en producción.

Alias vs root: palabras parecidas, física distinta

Esto merece franqueza: si usas alias sin entenderlo, terminarás enviando un 404 eventualmente.

root concatena la URI (o parte de ella, dependiendo de location) a una ruta base. alias reemplaza el prefijo de la location por la ruta alias. Eso significa que la presencia o ausencia de una barra final puede cambiar la ruta resultante. Nginx no es “inteligente” aquí; es consistente.

try_files: el reruteador silencioso

try_files es fantástico: te permite servir assets estáticos cuando existen y recurrir a una ruta de aplicación cuando no. También es una máquina para crear 404 confusos cuando enrutas archivos faltantes a un fallback que a su vez falta o está prohibido.

Al depurar, localiza la secuencia exacta de try_files y verifica que el fallback exista y sea legible.

Manejo de index: 403 que no es “permisos”

Una petición a un directorio como /static/ puede devolver 403 porque Nginx prohíbe listar directorios a menos que actives autoindex, y porque no existe un archivo index. Esto no es el SO negando acceso; es Nginx negándose a proporcionar un listado de directorio.

Error_page personalizado que enmascara

Los equipos de seguridad corporativos adoran mapear 403 a 404. A veces tienen razón. Pero complica la vida del on-call. Si heredas una configuración, busca error_page y ubicaciones internas antes de decidir qué “significa” el código de estado.

Broma #2: “Mapeamos 403 a 404 por seguridad” es como repintar la luz de check-engine—técnicamente efectivo hasta que el motor explota.

Errores comunes: síntoma → causa raíz → solución

1) Síntoma: 404 en todas las rutas de un dominio conocido

Causa raíz: bloque server equivocado está capturando peticiones (mismatch de Host; default_server cambiado; vhost nuevo añadido).

Solución: verifica Host con curl, luego confirma el server_name que coincide. Haz el vhost previsto explícito; deshabilita el sitio por defecto de Debian si no es necesario.

2) Síntoma: 403 en directorios, 200 en ficheros conocidos

Causa raíz: se solicitó el directorio sin archivo index; autoindex desactivado.

Solución: añade index index.html; y asegúrate de que el archivo exista, o evita enlazar a URIs de directorio, o activa intencionalmente autoindex on; (rara vez correcto).

3) Síntoma: 403 en archivos estáticos específicos tras un despliegue

Causa raíz: deriva de propiedad/modo: archivos creados como 640 por el usuario de despliegue; el grupo no incluye www-data.

Solución: impón propiedad consistente, por ejemplo root:www-data con 644, o directorios setgid con archivos legibles por grupo, o ACLs para www-data.

4) Síntoma: 404, pero el registro de errores muestra “permission denied”

Causa raíz: la configuración enmascara lo prohibido como no encontrado vía mapeo error_page o reescrituras internas.

Solución: quita el enmascaramiento mientras depuras; inspecciona error_page y reglas de rewrite; arregla el problema subyacente de permisos/LSM.

5) Síntoma: 404 en assets bajo /static, pero el archivo existe en otra ruta

Causa raíz: alias usado con la barra final equivocada, o root declarado al nivel incorrecto (server vs location).

Solución: calcula la ruta resuelta. Prefiere patrones consistentes; valida con la ruta open() que aparece en el registro de errores.

6) Síntoma: Todo funciona como root cuando “pruebas”, pero Nginx aún da 403

Causa raíz: probaste como root, no como www-data, y perdiste bits de traversía o ACLs.

Solución: siempre prueba lecturas usando sudo -u www-data y namei -l.

7) Síntoma: Los permisos parecen correctos, aún 403/404

Causa raíz: AppArmor niega acceso a la ruta (a menudo contenido movido a /srv, /data o un bind mount).

Solución: confirma la denegación en logs del kernel; actualiza el perfil de AppArmor o reubica contenido en rutas permitidas.

8) Síntoma: 404 aleatorio después de añadir un sitio nuevo

Causa raíz: nuevo bloque server se convierte en predeterminado para un listener; o server_name y listen solapan causando coincidencia ambigua.

Solución: establece exactamente un default por socket de escucha; valida con nginx -T y curl con Host apuntado.

Listas de verificación / plan paso a paso

Paso a paso: diagnosticar un 403 repentino

  1. Reproducir con curl usando el encabezado Host correcto. Captura estado y encabezado server.
  2. Seguir error.log mientras reproduces; busca (13: Permission denied), directory index of ... is forbidden, o access forbidden by rule.
  3. Confirmar bloque server (coincidencia server_name, comportamiento default_server).
  4. Calcular la ruta de archivo objetivo a partir de la línea del log. No adivines.
  5. Probar lectura como www-data y ejecutar namei -l para capturar un directorio bloqueado.
  6. Comprobar denegaciones de AppArmor en logs del kernel.
  7. Arreglar lo más pequeño (un modo de directorio, una ACL, una línea root), recargar y volver a probar.

Paso a paso: diagnosticar un 404 repentino

  1. Confirma que es Nginx y no el upstream comprobando cabeceras y access log.
  2. Encuentra la ruta open() en error.log. Si es (2: No such file or directory), estás en territorio de mapeo/despliegue.
  3. Verifica que el archivo exista en esa ruta exacta.
  4. Si el archivo existe en otra parte, revisa root/alias y coincidencia de location; comprueba try_files.
  5. Confirma que estás en el vhost previsto y no en un catchall por defecto.
  6. Busca reglas de enmascaramiento que conviertan lo prohibido en no encontrado.
  7. Recarga con validación (nginx -t y luego recarga) y vuelve a probar.

Checklist operativo: endurecer contra repeticiones

  • Deshabilita el sitio por defecto de Debian en hosts de producción a menos que realmente lo necesites.
  • Registra la ruta resuelta (error.log ya lo hace; mantenlo activado a un nivel razonable).
  • Haz que la propiedad y permisos formen parte del artefacto de despliegue, no de un paso posterior.
  • Mantén el contenido servido bajo un número pequeño de roots conocidos y alinea la política de AppArmor a esos.
  • Usa nginx -t en CI y antes de recargas; falla rápido.
  • Cuando uses despliegues por symlink, decide la política de disable_symlinks intencionalmente y documéntalo.

Tres micro-historias del mundo corporativo (todas anonimadas, todas plausibles)

Micro-historia #1 (suposición equivocada): “403 significa que nuestro WAF lo está bloqueando”

El síntoma parecía claro: una ráfaga de 403s en una ruta de assets estáticos justo después de un release menor. El ingeniero on-call supuso que era la capa edge/WAF, porque la página 403 no coincidía con la típica página de error de Nginx. Todos corrieron al dashboard de seguridad.

Mientras tanto, el registro de errores en el origin contaba otra historia: open() ".../app.css" failed (13: Permission denied). El job de despliegue había pasado de empaquetar archivos como root:www-data a empaquetarlos como deploy:deploy con una umask restrictiva. El contenido aterrizó como 640, ilegible para www-data.

La teoría del “WAF” duró porque la gente confió más en el cuerpo HTML de la respuesta que en los logs del servidor. Pero el cuerpo venía de un mapeo error_page personalizado que devolvía una página de marca tanto para 403 como 404. El código de estado era real; la página era decoración.

La solución fue aburrida y durable: hacer que la build produzca propiedad y modo correctos en el artefacto, aplicarlo en el paso de despliegue, y añadir una prueba de humo que lea un archivo conocido como www-data antes de marcar el despliegue como sano.

La lección no fue “no culpéis al WAF.” Fue: dejad de tratar las páginas de estado como evidencia. Los logs son evidencia.

Micro-historia #2 (optimización que salió rana): “Endurezcamos symlinks por seguridad”

Un equipo de plataforma endureció Nginx con disable_symlinks if_not_owner from=$document_root;. La intención era razonable: prevenir una clase de trucos con symlinks y reducir el radio de impacto si un desarrollador apunta accidentalmente a algo sensible.

Luego un equipo de producto implantó un patrón de despliegue atómico usando symlinks bajo el document root: /srv/www/app/current apuntaba a un nuevo directorio de release creado por el usuario de despliegue. La propiedad difería entre el objetivo del symlink y el document root, porque los directorios de release los creó CI con un mapeo UID distinto.

Resultado: 403 intermitentes dependiendo de qué assets se resolvían a través de symlinks, y de qué archivos estaban propiedad de qué usuario tras el paso de build. Los errores eran correctos. La configuración era correcta. El diseño del sistema no estaba alineado con la política.

El rollback lo arregló rápido, pero fue una llamada de atención: los “interruptores de endurecimiento” no son gratis. Si activas un control de seguridad que cambia la semántica del sistema de archivos, debes validarlo contra tu modelo de despliegue. Seguridad y fiabilidad no son enemigas, pero requieren coordinación.

Micro-historia #3 (práctica correcta y aburrida que salvó el día): “Siempre seguimos logs durante la reproducción”

Un equipo tenía una regla simple: cuando puedes reproducir un fallo web, lo reproduces desde el servidor con curl mientras sigues logs. Sin excepciones, sin debate. No era una cultura heroica; era una cultura que ahorra tiempo.

Una mañana, un host Debian 13 empezó a devolver 404 para un único dominio mientras otros dominios en la misma instancia Nginx estaban bien. La suposición instintiva fue “alguien borró archivos” o “rsync falló.” El on-call hizo el ritual de todos modos: curl + tail access/error logs.

El registro de errores mostró que Nginx buscaba bajo el root equivocado, y el campo server en la línea de error no coincidía con el dominio previsto. Eso llevó directo al problema real: un nuevo vhost con listen 80 default_server; se había desplegado como parte del cambio de otro equipo, robando hosts no coincidentes silenciosamente.

La solución llevó minutos: quitar default_server del vhost no previsto, recargar, verificar el enrutamiento con curl. No hubo restauraciones de archivos. No hubo cambio de permisos. No hubo sala de guerra dramática.

La “práctica aburrida” no era seguir logs. Era acordar que la evidencia vence a la teoría, y hacer esa regla operativa.

Cómo identificar al instante: la jerarquía de evidencia

Si quieres una única lección, es esta jerarquía—úsala para mantenerte honesto:

  1. Línea del registro de errores para la petición (contiene la ruta resuelta y el código de error del kernel).
  2. Entrada del access log (confirma host, URI, código de estado, hora).
  3. Reproducción desde el host con curl (elimina variabilidad de DNS y edge).
  4. Prueba de sistema de archivos como www-data (prueba los permisos reales).
  5. Logs de auditoría LSM (prueban denegaciones por política obligatoria).
  6. Sólo entonces: revisión y refactorización de configuración.

Una cita que tengo presente cuando los incidentes se ponen ruidosos:

idea parafraseada — W. Edwards Deming: Sin datos, eres solo otra persona con una opinión.

Eso es básicamente on-call en una frase.

FAQ

1) “Si es un 404, no puede ser permisos, ¿verdad?”

Incorrecto. A menudo es mapeo, pero las configuraciones pueden convertir intencionalmente 403 en 404 (error_page o reescrituras internas). Revisa siempre error.log para (13) vs (2).

2) “¿Dónde está el registro de errores de Nginx en Debian 13?”

Típicamente /var/log/nginx/error.log, a menos que se sobrescriba con error_log en /etc/nginx/nginx.conf o en un archivo de sitio. Confírmalo con nginx -T.

3) “¿Por qué obtengo 403 en un directorio pero 200 en archivos dentro?”

Porque solicitar el directorio activa el manejo de index. Si no hay un archivo index y autoindex está desactivado, Nginx devuelve 403 (“directory index … is forbidden”). Eso no es permisos del SO.

4) “¿Cuál es la forma más rápida de confirmar un mismatch de vhost?”

Usa curl -sv con el encabezado Host previsto, luego inspecciona el campo server: en la línea del error (a menudo imprime el server_name que coincidió). También vuelca la configuración con nginx -T y busca bloques server.

5) “Cambié permisos y sigue fallando. ¿Ahora qué?”

Revisa denegaciones de AppArmor en los logs del kernel. Si ves apparmor="DENIED" para nginx, chmod no ayudará. O bien actualiza el perfil o sirve desde rutas permitidas.

6) “¿Debería ejecutar los workers de Nginx como otro usuario distinto a www-data?”

Sólo si tienes un objetivo claro de aislamiento y disciplina operativa. Cambiar el usuario worker sin arreglar propiedad y estrategia de ACLs es una buena forma de manufacturar 403s.

7) “¿Por qué importa namei si ya comprobé permisos de archivo?”

Porque los directorios necesitan permiso de ejecución para la traversía. namei -l muestra permisos en cada segmento de ruta, así puedes detectar el directorio bloqueado que lo rompe todo.

8) “¿Cómo evito errores por alias/root en archivos estáticos?”

Elige una convención y cúmplela. Si usas alias, sé meticuloso con las barras finales y confirma la ruta resuelta vía error log. Si puedes usar root de forma limpia, a menudo es más simple.

9) “¿Puede una recarga fallida mantener a Nginx sirviendo configuración antigua?”

Sí. Una recarga puede fallar por sintaxis o permisos. Ejecuta siempre nginx -t antes de recargar, y verifica con nginx -T cuando lo que está en juego es crítico.

10) “¿Por qué un despliegue causaría 404 en lugar de 403?”

Si los assets no se enviaron a la ruta esperada (cambio en el build, exclusiones en rsync, artefacto equivocado), Nginx literalmente no los encontrará: (2: No such file or directory). Eso es un 404, y es la canalización de despliegue hablando.

Conclusión: siguientes pasos que puedes hacer ya

Si tu Debian 13 Nginx empieza de repente a devolver 403/404, no negocies con tus suposiciones. Haz la guía rápida:

  1. Reproduce con curl -sv usando el encabezado Host real.
  2. Sigue /var/log/nginx/error.log y captura la línea exacta de open() (ruta + errno).
  3. Confirma la selección de vhost (nginx -T y server_name/default_server).
  4. Prueba acceso al sistema de archivos como www-data y verifica traversía de directorios con namei -l.
  5. Si aún no cuadra, revisa denegaciones de AppArmor en logs del kernel.

Luego arregla una cosa, recarga y vuelve a probar. La mejor respuesta a incidentes es la que deja una protección: una política de permisos en despliegues, una comprobación de sanity de vhost, o una regla de AppArmor alineada con tu layout real de ficheros.

← Anterior
Docker + UFW: Por qué tus puertos siguen abiertos — ciérralos correctamente
Siguiente →
WordPress hackeado: respuesta a incidentes paso a paso que no lo empeora

Deja un comentario