Docker + TLS: Let’s Encrypt dentro/fuera de contenedores — elige el patrón seguro

¿Te fue útil?

A las 02:13, tu teléfono de guardia vibra con el pavor específico de «los clientes no pueden iniciar sesión». Abres el panel y lo ves: fallos en el handshake TLS, una cascada de errores 525/526 y un certificado que expiró… ayer. El servicio está bien. La canalización de certificados no.

Docker no causó esto. Pero Docker facilitó esconder las aristas peligrosas: claves privadas en sistemas de archivos efímeros, renovaciones que se ejecutan en el espacio de nombres equivocado, desafíos ACME atrapados detrás de un proxy y un enfoque de «simplemente monta /etc/letsencrypt en algún lado» que se convierte en un circo de permisos y rotación.

La decisión: dónde pertenece Let’s Encrypt

Tienes dos opciones generales:

  1. El cliente ACME se ejecuta en el host (o en una VM/namespace dedicado para «certificados») y escribe certificados en una ubicación controlada; los contenedores los consumen solo lectura.
  2. El cliente ACME se ejecuta dentro de un contenedor (Certbot, lego, Traefik, Caddy, nginx-proxy companion, etc.) y escribe los certificados en un volumen que otros contenedores leen.

Si ejecutas sistemas en producción y te importa el radio de impacto, el valor por defecto seguro es: terminar TLS en un contenedor proxy inverso dedicado, y ejecutar la emisión/renovación de certificados dentro de ese mismo límite (ACME nativo del proxy como Traefik/Caddy) o en el host con permisos de archivos estrictos. Todo lo demás puede comunicarse por HTTP simple en una red interna.

Lo que debes evitar es el patrón «cada contenedor de aplicación gestiona su propio Let’s Encrypt». Parece modular. En realidad es un generador de denegación de servicio (límites de tasa), una pesadilla de observabilidad y un regalo para cualquiera que quiera tus claves privadas esparcidas en volúmenes con permisos de escritura.

Aquí va la regla opinativa: centraliza los certificados por punto de entrada. Si Internet público golpea un solo lugar, ese lugar debe poseer TLS. Tus contenedores de aplicación no deberían necesitar saber qué es ACME.

Hechos e historia que cambian cómo operas

  • Let’s Encrypt se lanzó en 2015 y convirtió la automatización en una expectativa, no en un lujo. El umbral operativo cambió: «renovamos por recordatorio en calendario» dejó de ser aceptable.
  • ACME se convirtió en un estándar IETF (RFC 8555) en 2019. Eso importa porque los clientes son reemplazables; el flujo de trabajo no es un truco de proveedor.
  • Los certificados son de corta duración por diseño (90 días para Let’s Encrypt). No es tacañería; reduce la ventana de daño si una clave privada se filtra.
  • Los límites de tasa son parte del modelo de seguridad. También castigan los «bucles de reintento» cuando tu despliegue falla desafíos cada minuto.
  • La validación HTTP-01 requiere accesibilidad al puerto 80 para el dominio. Si fuerzas solo HTTPS sin una excepción bien planificada, romperás la emisión en el peor momento.
  • La validación DNS-01 no requiere puertos entrantes. Es la opción en entornos cerrados, pero desplaza el riesgo a las credenciales de la API DNS.
  • Los certificados wildcard requieren DNS-01 en Let’s Encrypt. Si tu plan depende de comodines, ya elegiste el tipo de desafío.
  • La terminación TLS también es un límite de confianza. Es donde decides suites de cifrado, HSTS, autenticación por certificado de cliente y dónde residen las claves privadas.
  • Recargar un servidor no es lo mismo que reiniciar un contenedor. Algunos proxies pueden recargar certificados en caliente; otros necesitan reinicio; algunos requieren una señal; otros una llamada a la API.

Una idea parafraseada del equipo del libro SRE de Google (Beyer, Jones, Petoff, Murphy): La esperanza no es una estrategia; la fiabilidad viene de sistemas diseñados y bucles de retroalimentación. Eso aplica dolorosamente bien a las renovaciones de certificados.

Tres patrones, ordenados por seguridad

Patrón A (mejor por defecto): el proxy inverso posee TLS + ACME, las apps permanecen solo HTTP

Este es el enfoque de «el ingreso es un producto». Ejecutas Traefik o Caddy (o nginx con un companion) como el único contenedor expuesto públicamente. Solicita certificados, los almacena, los renueva y los sirve. Los contenedores de aplicación nunca tocan claves privadas.

Por qué es seguro:

  • Un único lugar para endurecer y observar.
  • Un único lugar para recargar certificados correctamente.
  • Las apps pueden escalar/desplegarse sin tocar el estado TLS.
  • Los límites de tasa son más fáciles de respetar.

Dónde te puede morder: debes proteger el almacenamiento ACME del proxy (claves privadas y claves de cuenta). Si tu contenedor proxy se ve comprometido, esa es la identidad de autoridad para ese conjunto de dominios.

Patrón B (muy bueno): cliente ACME en el host, certificados montados solo lectura en el contenedor proxy

Este es el patrón aburrido de Unix. Certbot (o lego) se ejecuta en el host mediante timers de systemd, escribe en /etc/letsencrypt y tu contenedor proxy lee los certificados desde un bind mount en modo solo lectura. La recarga ocurre mediante un hook controlado.

Por qué es seguro:

  • La programación y el logging a nivel de host son predecibles.
  • Puedes usar controles de seguridad del SO (permisos, SELinux/AppArmor) de forma más natural.
  • Tu contenedor proxy no necesita credenciales de API DNS ni claves de cuenta ACME.

Dónde te puede morder: los desafíos HTTP-01 pueden ser incómodos si tu proxy también está conteinerizado. Necesitas una ruta limpia desde Internet hacia el respondedor de desafíos.

Patrón C (aceptable solo cuando hay restricciones): cliente ACME en un contenedor escribiendo en un volumen compartido

Este es el patrón «contenedor Certbot + contenedor nginx» de Compose. Puede funcionar. También tiende a envejecer mal: deriva de permisos, volúmenes copiados entre hosts y renovaciones que pasan desapercibidas hasta que fallan.

Cuándo está justificado:

  • No puedes instalar nada en el host (entornos gestionados, imágenes endurecidas).
  • Estás en restricciones tipo Kubernetes pero aún usas Docker.
  • Tienes un host de propósito único y políticas fuertes de aislamiento de contenedores.

Qué hacer si lo eliges: trata el volumen de certificados como un almacén de secretos. Montajes solo lectura en todas partes excepto el escritor ACME. Propiedad de archivos estricta. Nada de «777 porque funciona».

Broma #1: Los certificados son como la leche. Están bien hasta que olvidas la fecha de caducidad, y entonces el olor llega a la dirección.

El patrón que no deberías enviar: cada servicio ejecuta su propio Certbot

Servicios múltiples compitiendo por el puerto 80, cada uno escribiendo en su propio volumen, renovando en su propio calendario, algunos usando staging vs production de forma distinta. Es una forma elegante de aprender los límites de tasa de Let’s Encrypt en tiempo real.

Almacenamiento de certificados: volúmenes, permisos y el problema de la clave privada

La mayoría de los postmortems sobre TLS no tratan realmente sobre TLS. Tratan sobre el estado: dónde viven las claves, quién puede leerlas y si ese estado sobrevive a los redeploys.

Qué debe ser persistente

  • Claves privadas (privkey.pem): si se pierden, puedes reemitir, pero causarás tiempo de inactividad y potencialmente quedarás fuera de flujos con pinning/HSTS.
  • Cadena de certificados (fullchain.pem): necesaria para que el servidor presente una cadena válida.
  • Clave de cuenta ACME: usada por el cliente para autenticarse ante Let’s Encrypt. Si la pierdes puedes volver a registrarte, pero pierdes continuidad y a veces aparecen sorpresas operativas.

Bind mount vs volumen nombrado

Bind mount es simple y auditable: puedes inspeccionar archivos en el host, hacer copias de seguridad y aplicar permisos del host. Para material sensible, los bind mounts suelen ser más fáciles de razonar.

Volúmenes nombrados son portables dentro de la herramienta Docker, pero pueden convertirse en una caja negra. Están bien si los tratas como un datastore gestionado y sabes cómo se respaldan y restauran.

Permisos: mínimo privilegio, no mínimo esfuerzo

Tu proxy necesita acceso de lectura a la clave. Tu cliente ACME necesita permiso de escritura. Nadie más lo necesita. No montes /etc/letsencrypt en modo lectura-escritura en media docena de contenedores de app porque es conveniente.

Decide el modelo de confianza:

  • Host único: almacena certificados en el sistema de archivos del host, propiedad root, legibles por grupo por un grupo específico con el que corre el proxy dentro del contenedor.
  • Múltiples hosts: evita NFS para claves privadas a menos que estés muy seguro sobre bloqueo de archivos y seguridad. Prefiere emisión por host (DNS-01) o un mecanismo de distribución de secretos con semántica de rotación.

Claves dentro de imágenes: simplemente no

Incluir claves en imágenes es una decisión que limita tu carrera. Las imágenes se envían a registros, se cachean en portátiles, son escaneadas por CI y a veces se filtran. Mantén las claves fuera del contexto de construcción, fuera de las capas, fuera del historial.

Renovación y recarga: lo que realmente significa “automatización”

La renovación tiene tres trabajos:

  1. Obtener un certificado nuevo antes de la expiración.
  2. Ponerlo donde el servidor lo espere.
  3. Hacer que el servidor lo use sin perder tráfico.

Recarga en caliente vs reinicio

Algunos proxies pueden recargar certificados sin cortar conexiones. Otros no. Necesitas saber cuál tienes y probarlo. «Reiniciar el contenedor semanalmente» no es una estrategia; es una ruleta con mejor marca.

Los hooks son tus amigos

Si usas Certbot en el host, usa deploy hooks para recargar nginx/Traefik de forma elegante. Si usas una implementación ACME nativa del proxy, confirma cómo persiste el estado ACME y cómo maneja la recarga.

Broma #2: Nada construye confianza como un trabajo de renovación TLS que solo se ejecuta cuando alguien recuerda que existe.

Tareas prácticas: comandos, salidas y decisiones

Estos no son fragmentos de «copiar-pegar y rezar». Cada tarea incluye qué significa la salida y qué decisión tomar a continuación. Ejecútalas en el host salvo que se indique otra cosa.

Tarea 1: Confirma qué está escuchando realmente en los puertos 80/443

cr0x@server:~$ sudo ss -lntp | egrep ':80|:443'
LISTEN 0      4096         0.0.0.0:80        0.0.0.0:*    users:(("docker-proxy",pid=1123,fd=4))
LISTEN 0      4096         0.0.0.0:443       0.0.0.0:*    users:(("docker-proxy",pid=1144,fd=4))

Significado: Docker está publicando ambos puertos. Eso implica que un contenedor es tu ingreso. Si esperabas que nginx del host fuera el dueño de 80/443, ya encontraste el conflicto.

Decisión: Identifica qué contenedor mapea esos puertos y confirma que es el único punto de terminación TLS.

Tarea 2: Identifica el contenedor que publica los puertos

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES        IMAGE             PORTS
edge-proxy   traefik:v3.1      0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
api          myco/api:1.9.2    127.0.0.1:9000->9000/tcp

Significado: edge-proxy es el punto de entrada público. Bien. La API es solo local.

Decisión: Asegura que todo TLS público ocurra en edge-proxy y elimina cualquier exposición directa de 443 en otros lugares.

Tarea 3: Comprueba el certificado que actualmente se sirve a Internet

cr0x@server:~$ echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -subject -issuer -dates
subject=CN = example.com
issuer=C = US, O = Let's Encrypt, CN = R3
notBefore=Dec  1 03:12:10 2025 GMT
notAfter=Feb 29 03:12:09 2026 GMT

Significado: El certificado en vivo expira el 29 de febrero. Ese es tu plazo. También confirma que SNI es correcto.

Decisión: Si la expiración está dentro de 14 días y no tienes una canalización de renovación verificada, detén lo que haces y arregla eso primero.

Tarea 4: Valida la cadena completa y la calidad del handshake

cr0x@server:~$ openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | egrep 'Verify return code|subject=|issuer='
subject=CN = example.com
issuer=C = US, O = Let's Encrypt, CN = R3
Verify return code: 0 (ok)

Significado: La cadena es buena y los clientes deberían validarla.

Decisión: Si el código de verificación no es 0, comprueba si estás sirviendo fullchain.pem vs cert.pem y si tu proxy está configurado para el bundle correcto.

Tarea 5: Si usas Certbot en el host, lista certificados y expiraciones

cr0x@server:~$ sudo certbot certificates
Saving debug log to /var/log/letsencrypt/letsencrypt.log

Found the following certs:
  Certificate Name: example.com
    Domains: example.com www.example.com
    Expiry Date: 2026-02-29 03:12:09+00:00 (VALID: 57 days)
    Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem
    Private Key Path: /etc/letsencrypt/live/example.com/privkey.pem

Significado: La vista de estado de Certbot. Si esto difiere de lo que muestra openssl s_client, tu proxy no está leyendo los archivos esperados.

Decisión: Alinea la configuración del proxy con las rutas en vivo bajo /etc/letsencrypt/live y asegúrate de que esos symlinks sean accesibles dentro del contenedor.

Tarea 6: Renovaciones en seco (staging) para verificar la canalización

cr0x@server:~$ sudo certbot renew --dry-run
Saving debug log to /var/log/letsencrypt/letsencrypt.log

Processing /etc/letsencrypt/renewal/example.com.conf
Simulating renewal of an existing certificate for example.com and www.example.com

Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/example.com/fullchain.pem (success)

Significado: Tu ruta de desafío, credenciales y hooks funcionan en staging. Esto es lo más parecido a una prueba unitaria que tienes.

Decisión: Si esto falla, no esperes a la renovación en producción. Arregla la falla ahora.

Tarea 7: Inspecciona la alcanzabilidad del desafío para HTTP-01

cr0x@server:~$ curl -i http://example.com/.well-known/acme-challenge/ping
HTTP/1.1 404 Not Found
Server: traefik
Date: Sat, 03 Jan 2026 10:21:42 GMT
Content-Type: text/plain; charset=utf-8

Significado: Puedes alcanzar el host y el proxy responde en el puerto 80. Un 404 está bien para esta URL sintética; lo que importa es que no redirija a HTTPS de una forma que tu cliente ACME no pueda manejar.

Decisión: Si obtienes un timeout de conexión, tu firewall/NAT/publicación de puerto está mal. Si obtienes un 301 a HTTPS, confirma que tu cliente ACME/proxy lo soporta de forma segura o reserva una excepción para la ruta de desafío.

Tarea 8: Verifica los montajes de volúmenes Docker para material de certificados

cr0x@server:~$ docker inspect edge-proxy --format '{{json .Mounts}}'
[{"Type":"bind","Source":"/etc/letsencrypt","Destination":"/etc/letsencrypt","Mode":"ro","RW":false,"Propagation":"rprivate"}]

Significado: El proxy lee /etc/letsencrypt desde el host en modo solo lectura. Esa es la forma que quieres.

Decisión: Si está en RW cuando no debería, bórralo. Si la fuente es un volumen nombrado, confirma que puedes respaldarlo y restaurarlo intencionadamente.

Tarea 9: Confirma permisos y propiedad de archivos en las claves privadas

cr0x@server:~$ sudo ls -l /etc/letsencrypt/live/example.com/privkey.pem
-rw------- 1 root root 1704 Dec  1 03:12 /etc/letsencrypt/live/example.com/privkey.pem

Significado: Solo root puede leerlo. Si tu proxy corre como no-root dentro del contenedor, puede fallar al cargar la clave.

Decisión: O ejecuta el proxy con un usuario que pueda leer la clave vía permisos de grupo, o usa un mecanismo controlado (como un grupo dedicado y chmod 640) en lugar de abrirlo al mundo.

Tarea 10: Revisa los logs del proxy para eventos ACME y recarga de certificados

cr0x@server:~$ docker logs --since 2h edge-proxy | egrep -i 'acme|certificate|renew|challenge' | tail -n 20
time="2026-01-03T08:01:12Z" level=info msg="Renewing certificate from LE : {Main:example.com SANs:[www.example.com]}"
time="2026-01-03T08:01:15Z" level=info msg="Server responded with a certificate."
time="2026-01-03T08:01:15Z" level=info msg="Adding certificate for domain(s) example.com, www.example.com"

Significado: La renovación ocurrió y el proxy cree que cargó el certificado nuevo.

Decisión: Si los logs muestran que la renovación tuvo éxito pero los clientes siguen viendo el certificado antiguo, probablemente tienes múltiples instancias de ingreso o una capa de caché/load balancer sirviendo otro certificado.

Tarea 11: Si usas timers de systemd para Certbot, verifica la programación y la última ejecución

cr0x@server:~$ systemctl list-timers | grep -i certbot
Sun 2026-01-04 03:17:00 UTC  15h left  Sat 2026-01-03 03:17:02 UTC  5h ago  certbot.timer  certbot.service

Significado: El timer existe y corrió recientemente.

Decisión: Si no existe, no tienes automatización. Si existe pero no ha corrido, comprueba si el host estuvo caído o si el timer está mal configurado.

Tarea 12: Valida que los deploy hooks realmente recarguen el proxy

cr0x@server:~$ sudo grep -R "deploy-hook" -n /etc/letsencrypt/renewal | head
/etc/letsencrypt/renewal/example.com.conf:12:deploy_hook = docker kill -s HUP edge-proxy

Significado: Tras la renovación, Certbot envía HUP al contenedor proxy. Ese es un patrón de recarga controlada.

Decisión: Confirma que el proxy soporta la semántica de recarga por SIGHUP. Si no, reemplaza el hook con el comando de recarga correcto (o una llamada a la API) y pruébalo en horario de trabajo.

Tarea 13: Confirma qué archivo de certificado está configurado en el proxy

cr0x@server:~$ docker exec -it edge-proxy sh -c 'grep -R "fullchain.pem\|privkey.pem" -n /etc/traefik /etc/nginx 2>/dev/null | head'
/etc/nginx/conf.d/https.conf:8:ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
/etc/nginx/conf.d/https.conf:9:ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

Significado: Estás sirviendo la cadena completa y la clave desde las rutas estándar live.

Decisión: Si ves rutas bajo /tmp o directorios específicos de la app, espera sorpresas durante redeploys.

Tarea 14: Comprueba el riesgo de límites de tasa contando intentos fallidos recientes

cr0x@server:~$ sudo awk '/urn:ietf:params:acme:error/ {count++} END {print count+0}' /var/log/letsencrypt/letsencrypt.log
0

Significado: No hay entradas de error ACME en el log. Bien.

Decisión: Si este número sube, detén los reintentos automáticos y arregla el problema de validación subyacente antes de tocar los límites de tasa.

Tarea 15: Confirma que la hora del contenedor es sensata (sí, importa)

cr0x@server:~$ docker exec -it edge-proxy date -u
Sat Jan  3 10:23:01 UTC 2026

Significado: La hora es correcta. Una hora incorrecta puede hacer que la validación de certificados falle de maneras que parecen «errores TLS aleatorios».

Decisión: Si la hora está mal, arregla NTP del host primero. Los contenedores heredan la hora del host; si está mal, todo está mal.

Guía rápida de diagnóstico

Cuando TLS está en llamas, no «investigas». Triasas. Aquí está el orden que encuentra el cuello de botella rápidamente.

Primero: ¿se está sirviendo el certificado equivocado, o no hay certificado?

  • Ejecuta openssl s_client contra el endpoint público y comprueba notAfter, subject e issuer.
  • Si está expirado: estás ante una falla de renovación o de recarga.
  • Si tiene CN/SAN equivocado: estás golpeando la instancia de ingreso equivocada, enrutamiento SNI incorrecto o un certificado por defecto.

Segundo: ¿cree el cliente ACME que renovó?

  • Revisa logs de Certbot/ACME para entradas de éxito y marcas de tiempo.
  • Comprueba timestamps en el sistema de archivos de fullchain.pem y privkey.pem.
  • Si los archivos se actualizan pero el certificado servido es antiguo: es un problema de recarga/distribución.

Tercero: ¿se puede satisfacer el desafío ahora mismo?

  • Para HTTP-01: confirma la alcanzabilidad del puerto 80 y que /.well-known/acme-challenge/ se enruta al respondedor correcto.
  • Para DNS-01: confirma que las credenciales de la API DNS están presentes y válidas; revisa retrasos de propagación.

Cuarto: confirma que hay una única fuente de verdad

  • Busca múltiples contenedores de ingreso o múltiples hosts detrás de un balanceador que no compartan estado de certificados intencionalmente.
  • Asegúrate de no mezclar endpoints de staging y producción.

Quinto: límites de tasa y tormentas de reintentos

  • Si ves fallos repetidos, detén temporalmente el job de renovación. Los límites de tasa no perdonan y prolongarán la interrupción.
  • Arregla el problema de enrutamiento/DNS y luego haz un reintento controlado.

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

1) Síntoma: la renovación «tuvo éxito», pero los navegadores siguen mostrando el certificado antiguo

Causa raíz: El proxy nunca recargó los archivos nuevos, o actualizaste certificados en un host mientras el tráfico llega a otro.

Solución: Añade un deploy hook para recargar el proxy (señal/API) y verifica qué instancia sirve tráfico con openssl s_client desde varios puntos.

2) Síntoma: Certbot falla HTTP-01 con «connection refused» o «timeout»

Causa raíz: El puerto 80 no es alcanzable (firewall, NAT, publicación Docker incorrecta) o otro servicio lo está vinculando.

Solución: Asegura que el puerto 80 esté publicado por el contenedor de ingreso y permitido en grupos de seguridad/firewalls. Ejecuta ss -lntp para confirmarlo.

3) Síntoma: HTTP-01 falla con «unauthorized» y el contenido del token es incorrecto

Causa raíz: La ruta de desafío está siendo redirigida/enrutada a la app, no al respondedor ACME. A menudo causado por reglas de «forzar HTTPS» aplicadas demasiado pronto o una regla de proxy inverso demasiado agresiva.

Solución: Añade una ruta específica para /.well-known/acme-challenge/ que evite redirecciones y apunte al respondedor ACME.

4) Síntoma: alcanzas límites de tasa de Let’s Encrypt durante un incidente

Causa raíz: Reintentos automáticos golpean la emisión en producción tras fallos repetidos de desafío.

Solución: Usa --dry-run en staging para pruebas; implementa backoff; alerta sobre fallos. Durante un incidente, detén el job y arregla la alcanzabilidad primero.

5) Síntoma: el proxy no puede leer privkey.pem dentro del contenedor

Causa raíz: Los permisos de archivo son solo root y el contenedor se ejecuta como no-root, o el etiquetado SELinux bloquea el acceso.

Solución: Usa permisos legibles por grupo para un grupo dedicado, ejecuta el proxy con ese grupo y si SELinux está activado usa etiquetas adecuadas en el bind mount.

6) Síntoma: después de un redeploy, los certificados desaparecen y el proxy sirve un certificado por defecto/autofirmado

Causa raíz: El almacenamiento ACME estaba en el filesystem del contenedor (efímero) o en un volumen sin respaldo que se recreó.

Solución: Persiste el almacenamiento ACME en un volumen nombrado o bind mount con backups. Trátalo como datos stateful.

7) Síntoma: solicitudes de certificados wildcard fallan aunque HTTP-01 funcione

Causa raíz: Los comodines requieren DNS-01, no HTTP-01.

Solución: Implementa DNS-01 vía la API del proveedor DNS, asegura las credenciales y prueba el comportamiento de propagación.

8) Síntoma: «Funciona en un host pero no en otro»

Causa raíz: Cerebro dividido: múltiples nodos de ingreso emitiendo independientemente, o tiempo inconsistente, o configuración inconsistente.

Solución: Elige un modelo de propiedad único (emisión por host con DNS-01, o ingreso centralizado con secretos distribuidos) y hazlo cumplir con gestión de configuración.

Tres mini-historias corporativas desde las trincheras

Mini-historia 1: El incidente causado por una suposición equivocada

Tenían un stack Docker Compose ordenado: un proxy inverso, un par de APIs, un frontend y un servicio «certbot». La suposición era simple: el contenedor certbot renueva certificados y el proxy los usa mágicamente. Nadie escribió la parte de la «magia».

Llegó el día de la renovación. Certbot renovó. Los archivos en disco se actualizaron. Pero el proceso del proxy había cargado el certificado al inicio y nunca volvió a mirarlo. Servía felizmente el certificado antiguo desde memoria mientras el disco contenía la versión nueva, como un bibliotecario que se niega a aceptar nuevas ediciones.

El equipo persiguió pistas falsas: DNS, reglas de firewall, caídas de Let’s Encrypt. Mientras tanto los navegadores gritaban «certificado expirado» y los clientes asumieron compromiso. Seguridad intervino. Dirección también. El sueño abandonó el edificio.

La solución tomó minutos una vez vista: un deploy hook que envió la señal de recarga correcta al contenedor proxy, más un paso de validación que comparó el certificado servido en vivo con el del sistema de archivos después de la renovación. La solución mayor tomó una semana: añadieron una alerta de «certificado expira en 14 días» y escribieron un runbook que empieza con openssl s_client.

Mini-historia 2: La optimización que rebotó

Otra empresa quería despliegues más rápidos y menos piezas móviles. Alguien propuso: «Que cada contenedor de servicio solicite su propio certificado. Así escalar es fácil y los equipos son autónomos.» Sonaba moderno y era algo que podrías decir en una reunión sin que te cuestionaran.

Funcionó por un tiempo, de la misma manera en que una cocina funciona cuando nadie cocina. Luego ocurrió una migración: docenas de servicios se redeployaron en unas pocas horas. Cada uno intentó emitir un certificado. Algunos usaron staging, otros production, y unos pocos tenían dominios mal configurados que fallaron la validación y reintentaron agresivamente.

Golpearon límites de tasa. Algunos servicios no pudieron obtener certificados y sirvieron fallback autofirmados. Clientes con pinning fallaron con severidad. Los tickets de soporte se dispararon. El incidente fue técnicamente «solo certificados», pero operativamente fue un sistema distribuido autogestionado.

Hicieron rollback a un ingreso centralizado que emitía un pequeño conjunto de certificados y enruta internamente. La autonomía volvió, pero en una forma mejor: los equipos gestionaban rutas y headers, no ciclos de vida de claves privadas. La optimización había sido «eliminar el cuello de botella». Lo que eliminaron fue el único lugar que alguien observaba.

Mini-historia 3: La práctica aburrida pero correcta que salvó el día

Una empresa regulada tenía una regla poco glamorosa: todo TLS expuesto a Internet termina en un clúster proxy endurecido y el estado de certificados se respalda como parte del estado de infraestructura. Los ingenieros se quejaban. Parecía lento. Parecía papeleo.

Entonces llegó un cambio inesperado en la cadena de autoridad de certificación y un subconjunto de clientes antiguos se comportó mal. El equipo no tuvo que correr por 40 repositorios de aplicaciones buscando configuraciones TLS. Ajustaron la configuración del borde, verificaron la presentación de la cadena y desplegaron un cambio controlado con un canary. Las apps no se movieron.

Más tarde ese año, un host murió. El host de reemplazo arrancó, se aplicó la configuración, se restauraron los certificados y el tráfico se reanudó. Sin emisión de último minuto, sin drama por límites de tasa, sin misterio de «¿por qué el volumen está vacío?».

Lo que salvó fue la propiedad de los límites, backups y hooks de recarga probados trimestralmente. Fue aburrido, como los cinturones de seguridad.

Listas de verificación / plan paso a paso

Elige un patrón (haz esto antes de escribir archivos Compose)

  1. Host único, ingreso simple: Patrón A (ACME nativo del proxy) o Patrón B (Certbot en host + proxy lee en solo lectura).
  2. Múltiples hosts detrás de LB: Prefiere DNS-01 y emisión por host, o un enfoque centralizado de distribución de certificados. Evita «NFS compartido de /etc/letsencrypt» a menos que entiendas muy bien los modos de fallo.
  3. Hosts muy cerrados: Patrón A con un mecanismo ACME de almacenamiento bien entendido, más backups y controles de acceso.

Checklist de hardening (lo que lamentas saltarte)

  • Solo un ingreso público publica los puertos 80/443.
  • Los certificados y claves se persisten y se respaldan.
  • Las claves privadas son legibles únicamente por el ingreso (y el renovador si es separado).
  • Se prueba y programa la renovación en staging.
  • Se implementa y verifica el mecanismo de recarga (señal/API/recarga elegante).
  • Monitorización: alerta en expiración de certificado, fallos de renovación y errores ACME.
  • Runbook: el primer comando es openssl s_client, no «revisar Grafana».

Paso a paso: Certbot en host + nginx/Traefik conteinerizado leyendo solo lectura

  1. Instala Certbot en el host y obtén el certificado inicial usando un método compatible con tu enrutamiento (standalone/webroot/DNS).
  2. Almacena certificados en /etc/letsencrypt en el host.
  3. Bind mount /etc/letsencrypt en el contenedor proxy en modo solo lectura.
  4. Configura el proxy para usar fullchain.pem y privkey.pem.
  5. Añade un deploy hook de Certbot para recargar el proxy de forma elegante.
  6. Habilita y verifica un timer de systemd para renovaciones.
  7. Ejecuta certbot renew --dry-run y verifica que el certificado servido en vivo coincida con el sistema de archivos después de la recarga.

Paso a paso: ACME nativo del proxy (estilo Traefik/Caddy)

  1. Persiste el estado ACME en un volumen/bind mount (esto no es opcional).
  2. Restringe permisos en el almacenamiento ACME (las claves de la cuenta viven ahí).
  3. Usa HTTP-01 solo si el puerto 80 es confiablemente accesible; de lo contrario usa DNS-01 con credenciales de API DNS con alcance limitado.
  4. Prueba el comportamiento de renovación y observa logs de eventos de renovación.
  5. Respalda el almacenamiento ACME y prueba la restauración en una instancia no productiva.

Preguntas frecuentes

¿Debería ejecutar Certbot dentro de un contenedor?

Puedes, pero no deberías por defecto. Si el host puede ejecutar Certbot, las renovaciones basadas en host más montajes de solo lectura en el proxy son más fáciles de auditar y recuperar.

¿Es seguro el ACME de Traefik/Caddy?

Sí, si persistes y proteges el almacenamiento ACME. La versión insegura es dejar los datos ACME en el filesystem efímero del contenedor o montarlo en modo RW por todas partes.

¿Por qué no terminar TLS en cada contenedor de aplicación?

Porque las claves privadas se dispersan, las renovaciones se multiplican y depurar se vuelve una caza del tesoro. Centraliza TLS en el borde salvo que tengas una necesidad de cumplimiento o de arquitectura específica.

¿Cuál es el tipo de desafío más seguro con Docker?

DNS-01 es el más amigable con la infraestructura cuando el puerto 80 está enredado (balanceadores, firewalls cerrados, múltiples ingresos). Pero desplaza el riesgo a las credenciales de la API DNS y al tiempo de propagación.

¿Necesito el puerto 80 abierto si uso HTTPS en todas partes?

Si usas HTTP-01, sí. El servidor ACME debe recuperar el token por HTTP. La solución habitual es: permitir HTTP solo para /.well-known/acme-challenge/ y redirigir todo lo demás a HTTPS.

¿Cómo evito tiempo de inactividad cuando los certificados se renuevan?

Usa un proxy que soporte recarga elegante y actívalo vía deploy hook (o confía en la recarga integrada del proxy). Verifica con openssl s_client tras la renovación.

¿Dónde debo guardar certificados en disco?

En el host en un directorio protegido (comúnmente /etc/letsencrypt) si el host ejecuta ACME. O en un volumen dedicado y respaldado si el proxy ejecuta ACME. Mantén la clave privada legible solo por lo que debe servirla.

¿Qué hago si tengo múltiples hosts Docker detrás de un balanceador?

Elige uno: emisión por host (DNS-01 es común) o un enfoque centralizado de distribución de certificados. Evita archivos compartidos ad-hoc a menos que hayas probado bloqueo, backups y comportamiento de failover.

¿Cómo sé si estoy cerca de los límites de tasa de Let’s Encrypt?

Busca errores ACME repetidos en tus logs y detén las tormentas de reintentos. Unos pocos reintentos controlados están bien; bucles cerrados durante una caída son cómo acabas esperando periodos de enfriamiento.

¿Son los secretos de Docker un buen lugar para las claves privadas TLS?

En Swarm, Docker secrets puede ser un buen primitivo, pero aún necesitas rotación y semántica de recarga. En Compose simple, los «secretos» con frecuencia degeneran en archivos montados sin gestión de ciclo de vida.

Conclusión: los próximos pasos prácticos

Si solo tomas una decisión de esto: centraliza la propiedad de TLS en el ingreso. Luego elige cómo se emiten los certificados:

  • Usa ACME nativo del proxy cuando puedas persistir y proteger su estado limpiamente.
  • Usa Certbot en el host cuando quieras programación, logging y control de filesystem predecibles.
  • Usa Certbot en contenedor solo cuando las instalaciones en host estén prohibidas, y trata el volumen de certificados como un almacén de secretos.

Próximos pasos que puedes hacer esta semana:

  1. Ejecuta la comprobación de «certificado servido» con openssl s_client y registra la fecha de expiración en un lugar visible.
  2. Haz una prueba de renovación en seco y arregla fallos mientras no estás bajo presión.
  3. Confirma las semánticas de recarga e implementa un deploy hook que esté probado.
  4. Añade una alerta: «certificado expira en 14 días». Alerta aburrida. Alerta que salva vidas.

Después de eso, puedes discutir sobre suites de cifrado y HTTP/3 como gente civilizada. Primero, evita que los certificados expiren.

← Anterior
WordPress 404 en entradas: arreglar permalinks sin romper el SEO
Siguiente →
Contenedores vs VMs: qué perfil de CPU gana para cada caso

Deja un comentario