Ubuntu 24.04: Certbot renueva pero tu app sigue fallando — arregla permisos y hooks de recarga

¿Te fue útil?

Ejecutas certbot renew. Dice “Congratulations.” Tu monitorización dice “Absolutamente no.” Los usuarios ven errores TLS, o la aplicación sigue sirviendo un certificado que expiró ayer. Este es ese tipo de molestia particular donde la consola está optimista y la producción está en llamas.

En Ubuntu 24.04, el culpable habitual no es Let’s Encrypt en sí. Es la plomería aburrida alrededor: permisos de archivos, enlaces simbólicos en /etc/letsencrypt/live, timers de systemd que renuevan pero no reinician nada, y aplicaciones que no pueden (o no quieren) recargar certificados sin un empujón firme.

Qué ocurre realmente cuando la renovación “tiene éxito”

Que Certbot renueve un certificado es solo una pata de un taburete de tres patas:

  1. Emisión/renovación: Let’s Encrypt firma un nuevo certificado y Certbot lo guarda en /etc/letsencrypt/archive/<name>/, luego actualiza los enlaces simbólicos en /etc/letsencrypt/live/<name>/.
  2. Acceso: Tu aplicación (nginx, Apache, HAProxy, un servicio Java, un contenedor) debe poder leer fullchain.pem y privkey.pem. “Poder” significa permisos Unix y la capacidad de atravesar cada directorio padre. No es solo “el archivo existe”.
  3. Recarga: El proceso debe recargar los archivos (o reiniciarse) después de la renovación. Algunos demonios recargan con SIGHUP. Otros necesitan una prueba de configuración antes. Algunos requieren un reinicio completo. Algunos nunca recargan certificados en tiempo de ejecución y servirán el antiguo hasta la siguiente ventana de despliegue.

La mayoría de fallos están en las patas 2 o 3. Certbot no sabe automáticamente cómo tu servicio consume certificados. Además, en Ubuntu 24.04, systemd y los valores por defecto empaquetados te empujan hacia la automatización (timers, servicios), lo cual es genial hasta que nadie conecta la parte de “recargar la cosa real”.

Una verdad operacional: un certificado renovado es inútil hasta que el proceso que lo presenta haya reabierto el par de claves. A tu cliente solo le importan los bytes que recibe durante el apretón de manos TLS, no lo que imprimió certbot.

Una cita exacta, porque sigue siendo cierta décadas después: “La esperanza no es una estrategia.” — General Gordon R. Sullivan

Broma #1: La renovación TLS sin un hook de recarga es como cambiar las pilas de un detector de humo que no has instalado. Técnicamente progreso, prácticamente humo.

Guion de diagnóstico rápido (primero/segundo/tercero)

Si estás de guardia, no quieres una lección. Quieres el camino más corto a “¿El certificado está renovado, legible y cargado?” Usa este orden. Minimiza la oscilación.

Primero: ¿qué certificado está viendo realmente el cliente?

  • Comprueba la fecha de expiración y el número de serie del certificado servido desde la perspectiva del cliente.
  • Si es antiguo: esto es un problema de recarga/selección/enrutamiento. Deja de culpar a Let’s Encrypt.
  • Si es nuevo pero los clientes aún fallan: puede haber problemas de cadena, desajuste SNI o la vhost equivocada enlazada.

Segundo: ¿el proceso del servicio tiene acceso a la ruta de la clave privada?

  • Confirma que la ruta de archivo en la configuración del servicio coincide con el enlace simbólico en /etc/letsencrypt/live que pretendes.
  • Prueba permisos como el usuario del servicio (o usa namei para comprobar el recorrido).
  • Busca restricciones AppArmor/SELinux (en Ubuntu, frecuentemente AppArmor).

Tercero: ¿qué está (o no) disparando la recarga?

  • Certbot puede ejecutarse en un timer y renovar silenciosamente. Tu nginx no lo notará mágicamente.
  • Revisa los logs de Certbot buscando “Deploying Certificate” y la ejecución de hooks.
  • Añade un deploy hook que recargue tu servicio solo cuando realmente se haya producido una renovación.

Sólo después de esas tres cosas debes profundizar en desafíos DNS, límites de ACME, reglas de firewall o comportamientos aleatorios de balanceadores en la nube. Ocurren, pero no son el caso común cuando la renovación indica éxito.

Hechos interesantes y contexto (por qué esto sigue mordiendo a los equipos)

  • El debut de Let’s Encrypt (2015) convirtió TLS en una expectativa por defecto, pero también convirtió la “renovación de certificados” en una tarea operativa recurrente en lugar de un recordatorio en el calendario.
  • El modelo de almacenamiento de Certbot usa un diseño “archive” más enlaces “live” específicamente para permitir actualizaciones seguras y casi atómicas: los archivos nuevos aterrizan en archive/, los enlaces simbólicos se mueven en live/.
  • La clave privada en /etc/letsencrypt/live suele ser 0600 root:root. Esa es la postura de seguridad correcta, y también la razón por la que las aplicaciones no root frecuentemente se rompen tras refactorizaciones “útiles”.
  • Los timers de systemd reemplazaron a cron para muchas renovaciones empaquetadas porque los timers se integran con journald y la salud del servicio. También facilitan olvidar que “renovación” no es “despliegue”.
  • Nginx puede recargar la configuración sin soltar conexiones, pero solo si se dispara la recarga. Sin ella, nginx seguirá usando lo que cargó al inicio.
  • El comportamiento de recarga graciosa de Apache difiere por MPM y pila de módulos; es capaz, pero permisos rotos o una prueba de configuración fallida pueden hacer que siga funcionando con el certificado antiguo.
  • Los desafíos ACME (http-01, dns-01, tls-alpn-01) resuelven la emisión, no el despliegue. Los equipos confunden “challenge succeeded” con “sitio arreglado” porque ambos ocurren en la misma salida del comando.
  • Certbot empaquetado como snap cambió rutas y comportamiento de confinamiento para algunas instalaciones, lo que puede sorprender a quienes migran entre versiones de Ubuntu o siguen posts obsoletos.

Tareas prácticas: comandos, salidas y decisiones (12+)

Estas son tareas operativas reales. Cada una incluye: comando, salida realista, lo que significa y la decisión que tomas.

Task 1: Confirmar qué certificado ve el cliente (expiración, sujeto, emisor)

cr0x@server:~$ echo | openssl s_client -servername app.example.com -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -subject -issuer -dates
subject=CN = app.example.com
issuer=C = US, O = Let's Encrypt, CN = R11
notBefore=Dec 29 02:10:11 2025 GMT
notAfter=Mar 29 02:10:10 2026 GMT

Qué significa: El proceso en localhost:443 está sirviendo un certificado válido hasta Mar 29. Si tu alerta dice “expirado”, tu alerta puede estar comprobando un endpoint distinto, o una capa de proxy diferente está sirviendo el certificado antiguo.

Decisión: Si el certificado servido es antiguo/expirado, salta a recarga/permiso. Si es reciente, verifica enrutamiento/SNI y la cadena intermedia.

Task 2: Comprobar los archivos de certificado que Certbot cree son actuales

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

Found the following certs:
  Certificate Name: app.example.com
    Serial Number: 4e6a0f9a4b3c17c2a3b9e1d0c4a1a9f2
    Key Type: ECDSA
    Domains: app.example.com www.app.example.com
    Expiry Date: 2026-03-29 02:10:10+00:00 (VALID: 89 days)
    Certificate Path: /etc/letsencrypt/live/app.example.com/fullchain.pem
    Private Key Path: /etc/letsencrypt/live/app.example.com/privkey.pem

Qué significa: La vista de Certbot está bien: tiene un certificado válido y rutas canónicas.

Decisión: Si Certbot muestra una nueva expiración pero los clientes ven la expiración antigua, tu servicio no está leyendo estas rutas, no puede leerlas o no se ha recargado.

Task 3: Verificar que los enlaces simbólicos live apunten a la versión más reciente del archivo de archive

cr0x@server:~$ sudo ls -l /etc/letsencrypt/live/app.example.com/
total 4
lrwxrwxrwx 1 root root  43 Dec 29 02:10 cert.pem -> ../../archive/app.example.com/cert4.pem
lrwxrwxrwx 1 root root  44 Dec 29 02:10 chain.pem -> ../../archive/app.example.com/chain4.pem
lrwxrwxrwx 1 root root  48 Dec 29 02:10 fullchain.pem -> ../../archive/app.example.com/fullchain4.pem
lrwxrwxrwx 1 root root  46 Dec 29 02:10 privkey.pem -> ../../archive/app.example.com/privkey4.pem
-rw-r--r-- 1 root root 692 Jun  1  2024 README

Qué significa: Los enlaces live apuntan ahora a “4”. La renovación rotó los symlinks.

Decisión: Si el número del enlace simbólico no cambió tras la renovación, puede que no se haya producido la renovación; revisa logs y límites de tasa.

Task 4: Confirmar que la configuración de nginx/Apache referencia las rutas correctas

cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -R --line-number -E "ssl_certificate(_key)?\s" /etc/nginx/sites-enabled/* | head
/etc/nginx/sites-enabled/app.conf:12:    ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
/etc/nginx/sites-enabled/app.conf:13:    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

Qué significa: nginx está configurado para usar las rutas de symlink esperadas.

Decisión: Si ves /etc/letsencrypt/archive/... hardcodeado, cámbialo. Hardcodear archivos de archive es cómo garantizas dolor en la próxima renovación.

Task 5: Comprobar si el servicio está realmente en ejecución y con qué usuario corre

cr0x@server:~$ systemctl status nginx --no-pager
● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since Mon 2025-12-29 02:11:03 UTC; 2h 14min ago
       Docs: man:nginx(8)
   Main PID: 1842 (nginx)
      Tasks: 3 (limit: 19092)
     Memory: 8.3M
        CPU: 1.742s
     CGroup: /system.slice/nginx.service
             ├─1842 "nginx: master process /usr/sbin/nginx -g daemon on; master_process on;"
             ├─1843 "nginx: worker process"
             └─1844 "nginx: worker process"

Qué significa: nginx está en ejecución. Los workers suelen ser www-data.

Decisión: Si el servicio está fallando o en flapping, los errores de permisos probablemente aparecerán en journald aquí mismo. Si está en ejecución pero sirve el certificado antiguo, probablemente no se ha recargado.

Task 6: Buscar errores de permisos en journald

cr0x@server:~$ sudo journalctl -u nginx -n 50 --no-pager
Dec 29 02:10:59 server nginx[1842]: nginx: [emerg] cannot load certificate "/etc/letsencrypt/live/app.example.com/fullchain.pem": BIO_new_file() failed (SSL: error:8000000D:system library::Permission denied:calling fopen(/etc/letsencrypt/live/app.example.com/fullchain.pem, r) error:10080002:BIO routines::system lib)
Dec 29 02:10:59 server systemd[1]: nginx.service: Control process exited, code=exited, status=1/FAILURE
Dec 29 02:10:59 server systemd[1]: nginx.service: Failed with result 'exit-code'.

Qué significa: Clásico. nginx no puede leer el archivo de certificado. A menudo porque el archivo es solo para root y nginx está ejecutándose sin privilegios en el momento equivocado, o porque el recorrido de directorios está bloqueado.

Decisión: No uses chmod 777 para salir del problema. Arregla permisos con un modelo deliberado (ver la sección de permisos).

Task 7: Comprobar recorrido de rutas con namei (esto atrapa la trampa “directorio es 0700”)

cr0x@server:~$ sudo namei -l /etc/letsencrypt/live/app.example.com/privkey.pem
f: /etc/letsencrypt/live/app.example.com/privkey.pem
drwxr-xr-x root root /
drwxr-xr-x root root etc
drwxr-xr-x root root letsencrypt
drwx------ root root live
drwxr-xr-x root root app.example.com
lrwxrwxrwx root root privkey.pem -> ../../archive/app.example.com/privkey4.pem

Qué significa: /etc/letsencrypt/live es 0700, por lo que usuarios no root no pueden atravesarlo, incluso si el archivo en sí tuviera bits más permisivos.

Decisión: Si tu aplicación corre como no-root y lee directamente desde live, fallará. O bien haz que la app lea desde una ruta copia controlada, o usa ACLs con cuidado.

Task 8: Validar que los archivos de certificado se parseen correctamente (atrapa escrituras parciales o archivo equivocado)

cr0x@server:~$ sudo openssl x509 -in /etc/letsencrypt/live/app.example.com/fullchain.pem -noout -text | grep -E "Not After|Subject:"
        Subject: CN = app.example.com
            Not After : Mar 29 02:10:10 2026 GMT

Qué significa: El archivo en disco es un certificado X.509 válido y tiene la expiración esperada.

Decisión: Si el parseo falla, puede que hayas apuntado al archivo equivocado o haya corrupción. Arregla eso antes de tocar recargas.

Task 9: Confirmar que el timer/servicio de Certbot está presente y cuándo se ejecutó por última vez

cr0x@server:~$ systemctl list-timers --all | grep -E "certbot|letsencrypt"
Mon 2025-12-29 02:07:41 UTC  10h left Mon 2025-12-29 00:08:12 UTC  11h ago certbot.timer                certbot.service

Qué significa: La renovación la maneja un timer. Eso está bien, pero necesitas ver qué hace realmente el servicio.

Decisión: Inspecciona la definición de certbot.service y el comportamiento de los hooks a continuación.

Task 10: Inspeccionar qué ejecuta el servicio systemd de Certbot (donde los hooks pueden o no estar conectados)

cr0x@server:~$ systemctl cat certbot.service
# /usr/lib/systemd/system/certbot.service
[Unit]
Description=Certbot
Documentation=file:///usr/share/doc/certbot/readme.Debian.gz
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true

Qué significa: Ejecuta certbot -q renew silenciosamente. No hay --deploy-hook aquí. Así que a menos que hayas configurado hooks en otro sitio, nada se recarga.

Decisión: Añade un deploy hook en /etc/letsencrypt/renewal-hooks/deploy/ o sobreescribe la unidad systemd (más abajo hay más sobre eso).

Task 11: Comprobar los logs de Certbot para la ejecución de hooks y resultado de la renovación

cr0x@server:~$ sudo tail -n 60 /var/log/letsencrypt/letsencrypt.log
2025-12-29 02:10:11,214:INFO:certbot._internal.renewal:Cert is due for renewal, auto-renewing...
2025-12-29 02:10:12,992:INFO:certbot._internal.client:Successfully received certificate.
2025-12-29 02:10:13,103:INFO:certbot._internal.storage:Writing new private key to /etc/letsencrypt/archive/app.example.com/privkey4.pem.
2025-12-29 02:10:13,214:INFO:certbot._internal.storage:Deploying certificate to /etc/letsencrypt/live/app.example.com/fullchain.pem.
2025-12-29 02:10:13,215:INFO:certbot._internal.storage:Deploying key to /etc/letsencrypt/live/app.example.com/privkey.pem.

Qué significa: La renovación sucedió. Pero no hay evidencia de que un deploy hook se ejecutara (esas líneas mostrarían la ejecución del hook si estuviera configurado).

Decisión: Implementa deploy hooks. Si los hooks existen pero no se ejecutaron, revisa permisos de archivo / bits ejecutables en los scripts de hook.

Task 12: Recarga manual con prueba de configuración (evita reiniciar con config rota)

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

Qué significa: La recarga es segura.

Decisión: Procede a recargar. Si la prueba falla, arregla la configuración primero — ningún hook debería recargar un demonio en fallo.

Task 13: Recargar el servicio y volver a comprobar el certificado servido

cr0x@server:~$ sudo systemctl reload nginx
cr0x@server:~$ echo | openssl s_client -servername app.example.com -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -dates
notBefore=Dec 29 02:10:11 2025 GMT
notAfter=Mar 29 02:10:10 2026 GMT

Qué significa: Tras la recarga, el certificado servido coincide con el certificado renovado.

Decisión: Tu solución es “asegurar que la recarga ocurra después de una renovación exitosa.” Ahora automatízalo con un deploy hook.

Task 14: Probar permisos como el usuario del servicio (la única prueba que importa)

cr0x@server:~$ sudo -u www-data bash -lc 'head -n 1 /etc/letsencrypt/live/app.example.com/fullchain.pem'
head: cannot open '/etc/letsencrypt/live/app.example.com/fullchain.pem' for reading: Permission denied

Qué significa: Como se espera, www-data no puede leer el archivo. Si nginx necesita leer certificados como www-data en el momento de recarga, fallarás.

Decisión: No “arregles” esto haciendo las claves privadas legibles por el mundo. Usa un modelo de lectura por root con recarga privilegiada, o un enfoque de copia/ACL controlado.

Arreglar permisos sin crear un incidente de seguridad

La tentación es inmediata: chmod -R 755 /etc/letsencrypt, recargar, y a casa. Eso funciona hasta que te das cuenta de que acabas de hacer las claves privadas legibles por más actores de los previstos. En algunos entornos, eso ya es un incidente por sí mismo.

Este es el modelo mental práctico:

  • El secreto de la clave privada es lo importante. Si un atacante lee privkey.pem, puede suplantar tu servicio hasta que el certificado sea revocado/rotado y los clientes dejen de confiar en la cadena antigua.
  • La mayoría de demonios no necesitan que la clave sea legible por el usuario worker si el proceso maestro inicia/recarga como root y luego reduce privilegios. nginx es un ejemplo clásico: el master corre como root, lee las claves y luego los workers se ejecutan sin privilegios.
  • Los problemas aparecen cuando ejecutas todo el servicio como no-root (contenedores, unidades hardenizadas, usuario personalizado) y aun así lo apuntas a /etc/letsencrypt/live.

Elige uno de tres patrones sensatos

Patrón A (preferido): la recarga del servicio se ejecuta como root; los archivos de certificado siguen siendo solo root

Si puedes recargar nginx/Apache/HAProxy como root mediante systemd, conserva los valores por defecto de /etc/letsencrypt. Esto es lo más seguro y sencillo.

Qué hacer: crea un deploy hook que ejecute nginx -t y luego systemctl reload nginx. Los archivos de certificado permanecen solo para root. La recarga es privilegiada, por lo que el servicio puede leer la clave.

Patrón B: copia controlada a un directorio legible por la app (bueno para apps no-root)

Algunas apps (o contenedores) se ejecutan completamente sin root y deben leer material de clave directamente. No las apuntes a /etc/letsencrypt/live. En su lugar:

  • Crea un directorio dedicado como /etc/ssl/app.example.com/ con propiedad y permisos estrictos.
  • En el deploy hook, copia fullchain.pem y privkey.pem allí con install (establece modo/propietario de forma suficientemente atómica para nuestros propósitos).
  • Recarga el servicio después de copiar.

Esto reduce el radio de impacto: no debilitas /etc/letsencrypt, expones solo lo que la app necesita, al usuario exacto que la ejecuta.

Patrón C: ACLs en rutas específicas (usar con moderación, documentar agresivamente)

Puedes usar ACLs POSIX para conceder derechos de lectura/travesía al usuario del servicio solo para los archivos/directorios necesarios. Esto puede funcionar, pero es fácil de olvidar y difícil de auditar a toda prisa.

Si eliges ACLs, incorpora la verificación en tu runbook. De lo contrario, tu yo del futuro lo “arreglará” de nuevo con chmod a las 3am.

Qué no hacer (a menos que disfrutes las retrospectivas)

  • No hagas privkey.pem legible por el grupo con un grupo amplio como www-data si ese grupo contiene otros servicios. Eso es movimiento lateral como característica.
  • No apuntes servicios a /etc/letsencrypt/archive. La renovación incrementa nombres de archivo; tu configuración no seguirá esos cambios.
  • No construyas hooks que reinicien servicios críticos en cada ejecución del timer aunque no haya nada renovado. Eso es agitación auto infligida.

Hooks de recarga bien hechos (deploy hooks, systemd y trampas)

Certbot tiene varios tipos de hooks. El que quieres para “recargar tras renovación exitosa” suele ser el deploy hook. Se ejecuta solo cuando un certificado realmente se renueva (o se emite), lo que mantiene tus servicios estables en días donde nada cambia.

El directorio de deploy hooks (la opción más sencilla y menos sorprendente)

Coloca un script ejecutable en:

  • /etc/letsencrypt/renewal-hooks/deploy/

Certbot lo ejecutará después de haber escrito el nuevo certificado y actualizado los enlaces simbólicos live.

Ejemplo: hook de recarga de nginx con comprobaciones de seguridad

cr0x@server:~$ sudo install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
cr0x@server:~$ sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx >/dev/null <<'EOF'
#!/bin/bash
set -euo pipefail

# Only reload if nginx is installed and running.
if ! command -v nginx >/dev/null 2>&1; then
  exit 0
fi

if ! systemctl is-active --quiet nginx; then
  exit 0
fi

# Validate config before reload; fail hook if config is broken.
nginx -t

# Reload picks up new cert without dropping connections.
systemctl reload nginx
EOF
cr0x@server:~$ sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx

Por qué este script es opinado: no hace nada si nginx no está presente o activo (útil en servidores multi-rol), y se niega a recargar una configuración rota. Los hooks deben ser seguros ante cambios no relacionados.

Probar el hook sin esperar al día de renovación

Usa el dry run de Certbot. Simula la renovación (entorno staging) y ejecuta hooks.

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

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

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

Qué significa: El dry-run tuvo éxito. Ahora confirma que el hook se ejecutó comprobando logs de recarga de nginx o marcas de tiempo en journald.

Decisión: Si el dry-run funciona pero la renovación en producción no recarga, inspecciona ejecutabilidad de archivos, SELinux/AppArmor o diferencias de confinamiento Snap.

Cuándo deberías usar --deploy-hook en su lugar

Si quieres que el hook esté ligado a una invocación específica (por ejemplo una unidad especial o un certificado concreto), puedes pasar --deploy-hook en la línea de comandos. Pero en Ubuntu con timers de systemd, el directorio de hooks suele ser más sencillo, porque aplica de forma consistente incluso cuando humanos ejecutan certbot renew manualmente.

Sobrescrituras systemd: cuando debes controlar el comportamiento centralmente

Si tu organización insiste en que “todo debe estar en unidades systemd,” sobreescribe el servicio:

cr0x@server:~$ sudo systemctl edit certbot.service
cr0x@server:~$ sudo systemctl cat certbot.service
# /usr/lib/systemd/system/certbot.service
[Unit]
Description=Certbot
Wants=network-online.target
After=network-online.target

# /etc/systemd/system/certbot.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/bin/certbot -q renew --deploy-hook "systemctl reload nginx"

Qué significa: Reemplazaste ExecStart por uno que contiene un deploy hook. Recargará nginx solo en renovaciones reales.

Decisión: Elige directorio de hooks o overrides de unidad. No hagas ambos a menos que te gusten las recargas dobles misteriosas.

Trampa: recarga vs reinicio

Prefiere reload si el servicio lo soporta correctamente. Es menos disruptivo. Usa restart cuando:

  • El demonio no puede recargar certificados limpiamente (algunos servidores de aplicaciones).
  • Estás dentro de un contenedor donde no existe “reload” y tienes que reiniciar el proceso.
  • Has verificado que la recarga no recoge nuevas claves (raro pero real, dependiendo de la integración).

Broma #2: Si tu hook de renovación usa restart para todo, has reinventado “tiempo de inactividad planificado”, solo que con más sorpresa.

Contenedores y proxies inversos: donde las recargas mueren

Ubuntu 24.04 no inventó los contenedores, pero heredó su propiedad favorita: hacen suposiciones sobre el sistema de archivos que son problema de otra persona. Certbot corre en el host, actualiza archivos en el host, y tu terminador TLS puede ser:

  • nginx en el host (simple)
  • nginx en un contenedor (problemas de compartición de archivos y señalización)
  • Traefik/HAProxy en un contenedor (las opciones de recarga dinámica varían)
  • un balanceador de carga en la nube (la renovación de certbot es irrelevante a menos que subas los certificados allí)

Caso contenedor 1: certificados del host montados en solo-lectura

Patrón común: montar /etc/letsencrypt/live/app.example.com dentro del contenedor. El contenedor puede leer los archivos, pero no sabrá que cambiaron a menos que:

  • el proceso haga polling o vigile los cambios, o
  • tú dispares una señal de recarga dentro del contenedor.

Certbot renovó; el contenedor sigue sirviendo el certificado antiguo; todo parece “bien.” Eso no es un problema de certificado. Es un problema de ciclo de vida.

Caso contenedor 2: contenedor no-root no puede atravesar /etc/letsencrypt/live

Incluso con un bind mount, los permisos del directorio pueden bloquear el acceso. Recuerda la salida de namei anterior mostrando live como 0700. Si montas /etc/letsencrypt ampliamente, aún puedes ser bloqueado en la raíz del mount. El movimiento correcto suele ser el Patrón B: copiar certificados a un directorio legible por el contenedor con permisos estrictos, y montar ese directorio.

Desajuste en la capa de proxy inverso

Otro clásico: renuevas certificados en el servidor de la app, pero TLS termina en un proxy frontal (nginx/HAProxy) o en un balanceador de carga. Tu app nunca presenta un certificado a los clientes, así que renovarlo no hace nada. Mientras tanto, el certificado que importa está en otro lado, expirando en paz.

Consejo operacional: mapea la ruta del handshake. El certificado que importa es el del primer salto TLS desde el cliente. Todo lo que está detrás es tráfico interno a menos que hagas mTLS de extremo a extremo.

Tres mini-historias del mundo corporativo (con dolor)

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

Tenían una configuración limpia: Certbot en Ubuntu, nginx terminando TLS y un par de servicios upstream. Alguien rotó un montón de servidores a una “base hardened” y orgullosamente eliminó privilegios root de varias unidades systemd. nginx estaba entre ellas, por “mínimos privilegios”. La solicitud de cambio parecía razonable; incluso tenía un visto bueno de seguridad.

El día de la renovación de Certbot llegó. El timer se ejecutó, escribió archivos nuevos, actualizó symlinks y mostró éxito. El deploy hook disparó una recarga. nginx intentó reabrir los archivos de certificado. Y falló porque la unidad ahora se ejecutaba como un usuario sin privilegios con sin acceso a /etc/letsencrypt/live (que es 0700 a nivel de directorio).

La suposición fue sutil: “Si los workers de nginx pueden correr sin root, entonces nginx puede correr sin root.” No siempre. El proceso maestro de nginx tradicionalmente arranca como root precisamente para poder enlazar a puertos bajos y leer material de clave, luego reduce privilegios para los workers. Ejecutarlo sin root cambia lo que puede leer, y de pronto la renovación de certificados se convierte en un evento de fiabilidad.

Lo arreglaron revirtiendo ese endurecimiento de la unidad para nginx (manteniendo la separación de privilegios de workers) y documentando una regla explícita: los servicios que terminan TLS deben tener un método claro y revisado para acceder a claves privadas. El postmortem no culpó a Certbot. Culminó la falta de una prueba end-to-end que validara “expiración del certificado servido tras la renovación”.

Mini-historia 2: La optimización que salió mal

Un equipo de plataforma quiso reducir el churn de recargas. Tenían docenas de certificados en muchos vhosts y decidieron ejecutar certbot renew cada hora “por si acaso”, pero solo recargar nginx una vez al día en un job separado. Menos recargas, menos ruido, menos posibilidades de interrumpir conexiones de larga duración. La idea sonaba limpia en una hoja de cálculo.

Entonces añadieron un dominio nuevo con un certificado que renovó antes que la hora programada de recarga diaria. Certbot lo renovó felizmente, pero nginx siguió sirviendo el certificado antiguo durante casi 24 horas. Un cliente con comprobaciones TLS estrictas empezó a fallar. El equipo de soporte vio “renovación exitosa” en los logs y asumió que era un problema del cliente. No lo era.

La optimización rompió el contrato oculto: la renovación debe estar acoplada al despliegue. Puedes optimizar la frecuencia de recargas solo si tu terminador TLS soporta cargar certificados dinámicamente por handshake (muchos no) o implementas una condición de recarga más inteligente. De lo contrario estás optimizando la variable equivocada: el número de recargas en lugar de la corrección.

La solución fue recargar en renovaciones reales únicamente (deploy hook), y añadir una comprobación guardián: “el certificado servido por el proxy tiene al menos 20 días de validez” desde el propio proxy. Eso reemplazó suposiciones por medición.

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

Otra organización tenía una regla poco glamorosa: cada endpoint TLS debe tener un script local que imprima la expiración del certificado servido y la compare con la que está en disco. El script se ejecutaba como chequeo de salud diario y durante despliegues. A nadie le encantaba. Nadie se lo ponía en una camiseta. Simplemente estaba ahí.

Una mañana, ocurrió una renovación y el proxy no recargó. El hook existía, pero una actualización de empaquetado reemplazó un override de systemd y eliminó una bandera --deploy-hook personalizada. El timer todavía se ejecutó. Certbot todavía renovó. El servicio siguió arriba y siguió sirviendo el certificado antiguo, así que las alarmas de uptime no saltaron.

El script aburrido lo detectó antes que los usuarios: “la expiración servida no coincide con la expiración en disco.” El on-call tuvo un único comando para ejecutar, un único lugar para mirar y una única solución: restaurar el deploy hook vía /etc/letsencrypt/renewal-hooks/deploy (que sobrevivió mejor a cambios de empaquetado).

Esa práctica no evitó la mala configuración, pero la convirtió en un ticket de mantenimiento calmado en lugar de un outage público. Aburrida, correcta y extrañamente heroica.

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

1) “Certbot dice renovado, pero el navegador muestra certificado expirado”

Síntoma: certbot renew informa éxito; los clientes todavía ven una expiración antigua.

Causa raíz: El servicio no se recargó, o el endpoint TLS no es el servicio que renovaste (desajuste proxy/balanceador).

Solución: Añade un deploy hook para recargar el proceso correcto; verifica desde el lado cliente usando openssl s_client contra el endpoint real.

2) “nginx falla al recargar tras la renovación con Permission denied”

Síntoma: journald muestra BIO_new_file() failed ... Permission denied.

Causa raíz: Ruta de clave/cert no legible debido a recorrido de directorios (/etc/letsencrypt/live es 0700) o el servicio corre sin root.

Solución: Mantén el maestro de nginx privilegiado para lecturas de clave, o copia cert/clave a un directorio controlado legible por el usuario del servicio.

3) “El hook de recarga se ejecuta, pero el servicio sigue sirviendo certificado antiguo”

Síntoma: El hook se ejecutó; el comando de recarga tuvo éxito; el certificado servido no cambió.

Causa raíz: El servicio no usa rutas /etc/letsencrypt/live, o estás alcanzando otra vhost/SNI, o hay otro terminador TLS enfrente.

Solución: Confirma rutas de configuración con nginx -T / configs vhost de Apache; revisa SNI con -servername; traza la ruta del handshake.

4) “Todo funciona manualmente, pero la automatización falla”

Síntoma: Ejecutar certbot renew manualmente funciona; el timer systemd renueva pero no recarga.

Causa raíz: Tu comando manual incluye flags/hook; la unidad del timer no. O los scripts de hook no son ejecutables en el contexto del timer.

Solución: Coloca hooks en /etc/letsencrypt/renewal-hooks/deploy y asegúrate de chmod 0755. Valida con certbot renew --dry-run.

5) “Tras la renovación, algunos clientes fallan con errores de cadena”

Síntoma: Fallos intermitentes, “unable to get local issuer certificate,” mientras algunos clientes trabajan.

Causa raíz: Configuraste ssl_certificate a cert.pem en lugar de fullchain.pem, o hay una cadena mixta entre proxies.

Solución: Sirve fullchain.pem para configuraciones típicas nginx/HAProxy; prueba con openssl s_client -showcerts.

6) “La renovación funciona, pero el contenedor sigue sirviendo el certificado antiguo”

Síntoma: Archivos del host actualizados; el tráfico del contenedor muestra certificado antiguo.

Causa raíz: El proceso en contenedor no se recarga, o puntos de montaje/permisos impiden ver actualizaciones.

Solución: Implementa un hook que envíe señal al contenedor (o lo reinicie) tras la renovación, o mueve la terminación TLS a un proxy que soporte recarga dinámica.

Listas de verificación / plan paso a paso

Checklist A: Detener la hemorragia (restaurar TLS válido ahora)

  1. Comprueba el certificado servido desde el endpoint que el usuario alcanza (openssl s_client con SNI).
  2. Si el certificado servido es antiguo: recarga el terminador TLS (systemctl reload nginx tras nginx -t).
  3. Si la recarga falla: lee journald por errores de permisos y arregla el modelo de acceso (no hagas chmod amplio a claves privadas).
  4. Vuelve a comprobar el certificado servido tras la recarga. Solo entonces cierra el incidente.

Checklist B: Hacer que la renovación se despliegue realmente (para que no vuelva a pasar)

  1. Decide tu patrón de permisos: A (recarga como root), B (copia controlada) o C (ACLs).
  2. Crea un script de deploy hook en /etc/letsencrypt/renewal-hooks/deploy/.
  3. En el hook: prueba la configuración (nginx -t / apachectl configtest) antes de recargar/reiniciar.
  4. Ejecuta certbot renew --dry-run y verifica que el hook se ejecutó (marcas de tiempo en journald).
  5. Añade un chequeo de salud que compare la expiración servida vs la expiración en disco (detecta hooks rotos tras actualizaciones).

Checklist C: Modelo de permisos para servicios no-root (Patrón B)

  1. Crea un directorio restringido propiedad del usuario del servicio (o un grupo dedicado) con 0750 o más estricto.
  2. Usa install en un deploy hook para copiar certificado y clave con modo/propietario específicos.
  3. Apunta la app a las rutas copiadas, no al directorio live de Let’s Encrypt.
  4. Recarga/reinicia el servicio tras la copia.
  5. Audita: confirma que solo los principales previstos pueden leer la clave privada.

Ejemplo de hook Patrón B: copiar archivos y recargar

cr0x@server:~$ sudo tee /etc/letsencrypt/renewal-hooks/deploy/publish-app-cert >/dev/null <<'EOF'
#!/bin/bash
set -euo pipefail

DOMAIN="app.example.com"
SRC_DIR="/etc/letsencrypt/live/${DOMAIN}"
DST_DIR="/etc/ssl/${DOMAIN}"

install -d -m 0750 -o root -g appsvc "${DST_DIR}"

# Copy with explicit permissions. Private key is readable only by root and group appsvc.
install -m 0644 -o root -g appsvc "${SRC_DIR}/fullchain.pem" "${DST_DIR}/fullchain.pem"
install -m 0640 -o root -g appsvc "${SRC_DIR}/privkey.pem"   "${DST_DIR}/privkey.pem"

# Validate service config if applicable, then reload.
if systemctl is-active --quiet appsvc; then
  systemctl reload appsvc || systemctl restart appsvc
fi
EOF
cr0x@server:~$ sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/publish-app-cert

Punto de decisión: Haz de appsvc un grupo cerrado con solo la cuenta del servicio. No reutilices un grupo compartido solo porque existe.

FAQ

1) ¿Por qué Certbot renueva con éxito pero mi sitio sigue mostrando el certificado antiguo?

Porque la renovación actualiza archivos en disco, no el proceso en ejecución. Tu terminador TLS debe recargar o reiniciarse para volver a leer cert/clave.

2) ¿Debería apuntar nginx a /etc/letsencrypt/archive para evitar symlinks?

No. Los nombres en archive se incrementan en cada renovación (fullchain4.pem, fullchain5.pem). Usa /etc/letsencrypt/live para que las actualizaciones sigan los symlinks.

3) ¿Es seguro hacer /etc/letsencrypt/live legible por www-data?

Normalmente no. Amplía quién puede leer la clave privada. Prefiere recargas realizadas por root (Patrón A) o una copia controlada a un directorio dedicado con un grupo dedicado (Patrón B).

4) ¿Cuál es la diferencia entre un deploy hook y un post hook?

Un deploy hook se ejecuta solo cuando un certificado realmente se renueva/emite. Un post hook se ejecuta después de cada ejecución de Certbot, incluso si nada cambió. Para recargas, los deploy hooks son la opción sensata por defecto.

5) ¿Por qué importa certbot renew --dry-run?

Valida el flujo ACME y ejecuta tus hooks sin esperar a la expiración real. Es la forma más rápida de detectar “hook no ejecutable” y “comando de recarga falla”.

6) Mi servicio corre en Docker. ¿Cómo lo recargo desde Certbot?

O bien (a) monta un directorio publicado de certificados dentro del contenedor y envía una señal/reinicia vía el runtime del contenedor desde un deploy hook, o (b) termina TLS fuera del contenedor en un proxy del host.

7) Recargué nginx pero los clientes siguen fallando TLS. ¿Qué hago ahora?

Revisa la configuración de la cadena (sirve fullchain.pem), desajuste SNI (usa -servername en pruebas) y si un proxy frontal/balanceador de carga está sirviendo otro certificado.

8) ¿Ubuntu 24.04 cambia algo acerca de Certbot específicamente?

El cambio mayor es en empaquetado y expectativas de automatización: los timers de systemd son comunes, las instalaciones Snap vs apt pueden diferir y el confinamiento puede cambiar suposiciones de sistema de archivos. Tus hooks deben coincidir con tu método de instalación.

9) ¿Cómo puedo demostrar que el proceso en ejecución ha cargado el certificado nuevo?

Compara el número de serie/expiración del certificado servido (vía openssl s_client) con el certificado en disco en live. Si coinciden, el proceso lo cargó. Si no, no lo hizo.

Conclusión: próximos pasos que perduran

El camino fiable en Ubuntu 24.04 es directo y efectivo:

  1. Verifica lo que ven los clientes, no lo que afirma Certbot.
  2. Mantén las claves privadas cerradas. Arregla el acceso por diseño, no por pánico con chmod.
  3. Conecta la renovación con el despliegue mediante un deploy hook que pruebe la configuración y recargue el servicio correcto.
  4. Añade una comprobación de salud que compare la expiración servida con la expiración en disco para que actualizaciones de empaquetado o refactorizaciones no te rompan en silencio.

Si solo haces una cosa después de leer esto: crea un deploy hook en /etc/letsencrypt/renewal-hooks/deploy/ que recargue de forma segura tu terminador TLS, y luego pruébalo con certbot renew --dry-run. Así conviertes “los certificados están automatizados” de un eslogan en una propiedad de tu sistema de producción.

← Anterior
Ubuntu 24.04: montaje CIFS muestra «Permiso denegado» — las opciones exactas que lo solucionan
Siguiente →
Controladores que reducen el rendimiento: el ritual post-actualización

Deja un comentario