Problema
En entornos donde Nginx actúa como reverse proxy para varios contenedores Docker, es frecuente encontrarse con que, de forma intermitente, el cliente recibe el certificado TLS del host “default” en lugar del certificado correspondiente al dominio solicitado. El síntoma típico es un error de certificate mismatch al abrir el sitio en el navegador o al ejecutar openssl s_client -servername <dominio> -connect <dominio>:443. La falla no ocurre siempre; en pruebas repetidas el mismo dominio puede devolver su certificado correcto en una ejecución y el certificado del host principal en otra.
Este comportamiento afecta a cualquier arquitectura que combine:
- Nginx como terminador TLS y balanceador de capa 7.
- Docker Compose con varios contenedores que exponen servicios HTTP/HTTPS.
- Let’s Encrypt (acme‑companion o similar) que genera y renueva certificados automáticamente.
- Un único puerto 443 expuesto en el host y redirigido a la instancia de Nginx.
El problema no es exclusivo de una configuración concreta; cualquier despliegue que dependa de SNI para seleccionar el certificado puede presentar este comportamiento si alguna de las piezas de la cadena (DNS, resolución interna, reglas de iptables, orden de carga de Nginx) no está alineada.
Causa
1. SNI no llega al contenedor correcto
Nginx decide qué certificado usar basándose en el nombre del servidor enviado por el cliente (SNI). Si la petición llega a otro contenedor o a la propia red de Docker antes de que Nginx la procese, el server_name no se evalúa y Nginx recurre al bloque marcado como default_server.
2. default_server mal colocado
Definir listen 443 ssl default_server; en más de un bloque o en un bloque que no sea el “catch‑all” hace que Nginx elija arbitrariamente el primer bloque que coincida, lo que genera la sustitución del certificado.
3. Resolución DNS interna de Docker inconsistente
Docker usa un resolvedor interno (127.0.0.11). Cuando Nginx necesita resolver el nombre de un upstream (por ejemplo, webapp:80) y la caché DNS está desactualizada, la petición puede ser redirigida a la IP del contenedor del proxy mismo, provocando un bucle y, en última instancia, la entrega del certificado por defecto.
4. Reglas NAT / iptables que redirigen tráfico antes de que llegue a Nginx
Si el host tiene reglas que DNAT el puerto 443 directamente a un contenedor distinto (por ejemplo, al contenedor de la aplicación), el handshake TLS se completa sin pasar por Nginx, y el certificado que el cliente ve corresponde al contenedor que escuchó primero.
5. Recarga de Nginx después de renovación de certificados
El companion de Let’s Encrypt escribe los archivos de certificado en un volumen compartido, pero si Nginx no se recarga o no se le notifica el cambio, sigue usando la versión anterior (a veces la del host). En entornos con alta rotación de contenedores, la señal SIGHUP puede perderse.
6. Orden de inicio y dependencias en Docker Compose
Si el contenedor de Nginx arranca antes de que el companion haya creado los archivos de certificado, Nginx carga un certificado vacío o el predeterminado y lo mantiene hasta que se reinicie manualmente.
7. Falta de proxy_ssl_server_name on; en bloques location / que hacen proxy_pass
Cuando Nginx actúa como cliente hacia un upstream TLS, necesita reenviar el SNI. Sin la directiva, el upstream recibe un SNI vacío y responde con su propio certificado, que puede ser el del host principal.
Solución
Paso 1 – Redefinir la configuración de listen
Mantén un solo bloque default_server y colócalo en un archivo de captura, por ejemplo 00-default.conf:
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/certs/default.crt;
ssl_certificate_key /etc/nginx/certs/default.key;
return 444;
}
Todos los demás bloques deben usar listen 443 ssl; sin la palabra clave default_server.
Paso 2 – Asegurar que Nginx reciba SNI y lo propague
En cada bloque que hace proxy_pass a un backend TLS, agrega:
proxy_ssl_server_name on;
proxy_ssl_name $host;
Esto garantiza que el nombre del cliente se envíe al upstream y evita que el backend devuelva su propio certificado por defecto.
Paso 3 – Configurar el resolvedor interno
Incluye en el contexto http de Nginx:
resolver 127.0.0.11 valid=30s ipv6=off;
resolver_timeout 5s;
Con esto Nginx usa la caché DNS de Docker y refresca cada 30 s, evitando que una IP antigua apunte al contenedor del proxy.
Paso 4 – Aislar la red del proxy
Define una red Docker dedicada y fija para el contenedor de Nginx y el companion:
networks:
proxy_net:
driver: bridge
ipam:
config:
- subnet: 172.25.0.0/16
En el docker‑compose.yml:
services:
nginx:
image: nginx:1.31.0
networks:
- proxy_net
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- acme-companion
acme-companion:
image: nginxproxy/acme-companion:3.1.3
networks:
- proxy_net
volumes:
- ./certs:/etc/nginx/certs
- ./acme:/etc/acme.sh
environment:
- NGINX_DOCKER_GEN_CONTAINER=nginx
- ACME_CA_URI=https://acme-v02.api.letsencrypt.org/directory
Al mantener ambos contenedores en la misma red aislada, evitamos que reglas NAT del host interfieran con el tráfico TLS.
Paso 5 – Forzar recarga automática de Nginx
El companion ya envía docker kill -s HUP <nginx> cuando un certificado cambia, pero solo si los contenedores comparten el mismo Docker socket. Asegúrate de montar /var/run/docker.sock en el companion:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Sin este montaje, la señal no llega y el certificado no se actualiza.
Paso 6 – Ordenar el arranque
Utiliza depends_on y restart: unless-stopped para que Nginx espere a que el companion haya creado los certificados. También puedes añadir un script de espera en el entrypoint de Nginx:
#!/bin/bash
while [ ! -f /etc/nginx/certs/${HOSTNAME}.crt ]; do
echo "Esperando certificado..."
sleep 2
done
exec nginx -g 'daemon off;'
Paso 7 – Revisar iptables / UFW
Desactiva reglas que DNAT el puerto 443 directamente a otro contenedor. En UFW, permite solo:
ufw allow 80/tcp
ufw allow 443/tcp
Y elimina cualquier regla -A PREROUTING -p tcp --dport 443 -j DNAT ... que apunte fuera de la red proxy_net.
Alternativa – Usar Traefik o Caddy
Si el problema persiste, considera migrar a un reverse proxy que gestione SNI y renovación de certificados de forma nativa (Traefik v2, Caddy). Estos proyectos reducen la superficie de error al no requerir un contenedor adicional para ACME.
Cuándo aplicar esta solución
- Síntomas: errores “certificate mismatch”, logs de Nginx que indican
ssl_certificatedel host por defecto, o resultados inconsistentes al ejecutaropenssl s_clienten bucles. - Entorno: Docker Compose con Nginx como terminador TLS, varios dominios gestionados por Let’s Encrypt, y una única IP pública.
- No aplica: cuando el proxy está desplegado en Kubernetes (el controlador Ingress gestiona SNI) o cuando se usa un balanceador externo (HAProxy, Cloudflare) que ya termina TLS antes de Docker.
Código
# docker-compose.yml (extracto esencial)
services:
nginx:
image: nginx:1.31.0
container_name: nginx-proxy
restart: unless-stopped
networks:
- proxy_net
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- acme-companion
acme-companion:
image: nginxproxy/acme-companion:3.1.3