CONSEGUIR EL CERTIFICADO ES FÁCIL; LO DIFÍCIL ES OLVIDARTE DE ÉL
Durante años, el certificado SSL de esta web fue de pago. Lo compraba, lo renovaba a mano una vez al año y lo montaba como un par de ficheros en el contenedor de nginx que hace de puerta de entrada al blog (si leíste el post de Digital Ocean - Docker, ya conoces el montaje). Funcionaba, pero pagar por algo que existe gratis y, encima, tener que acordarme de renovarlo, era de esas cosas que sabes que algún día hay que cambiar.
Ese día llegó, y el cambio fue a Let's Encrypt con Certbot. Conseguir el primer certificado es lo de menos: hay mil tutoriales y en diez minutos lo tienes. Lo que de verdad me hizo pensar fue lo otro: que se renueve solo, que nginx se entere y, sobre todo, poder comprobarlo sin esperar a que caduque. Te cuento cómo lo monté y lo que aprendí por el camino.
Dos formas de darle el certificado a nginx
Certbot guarda sus certificados en /etc/letsencrypt, y la forma más habitual que verás por ahí es montar esa carpeta en el contenedor de nginx y apuntar a /etc/letsencrypt/live/tudominio.com/fullchain.pem. Funciona, pero tiene sus pegas:
- nginx ve todo lo que guarda Certbot: la clave de tu cuenta de Let's Encrypt, los certificados antiguos, la configuración de renovación... Mucho más de lo que necesita.
- Hay que montar la carpeta entera. Si montas solo
live/te llevas una sorpresa: dentro hay enlaces simbólicos que apuntan a../../archive/, y en el contenedor esos enlaces no apuntan a nada. - La configuración de nginx queda atada a cómo organiza Certbot sus ficheros.
Yo fui por el otro camino: un pequeño script, un deploy hook, que Certbot ejecuta cada vez que renueva y que copia solo el certificado y su clave a una carpeta ssl/, que es lo único de Certbot que ve nginx. ¿Por qué? Porque venía de un certificado comprado, y nginx ya leía sus ficheros de esa carpeta. Con el hook, nginx ni se enteró del cambio: los ficheros siguen en el mismo sitio, solo cambia quién los pone ahí. Y de regalo, nginx ve únicamente lo que necesita, y si mañana cambio de proveedor de certificados, nginx sigue igual.
El precio es un script más que mantener (y que puede fallar, ya verás). Me pareció un buen trato.
Este es el circuito completo, que iremos viendo paso a paso:

El problema del huevo y la gallina
Para darte un certificado, Let's Encrypt tiene que comprobar que el dominio es tuyo. Con el método webroot, Certbot deja un fichero en una ruta concreta (/.well-known/acme-challenge/...) y Let's Encrypt intenta descargarlo por HTTP. Si lo encuentra, el dominio es tuyo.
Así que el bloque del puerto 80 de nginx tiene que servir esa ruta antes de redirigir todo lo demás a HTTPS:
server {
listen 80;
server_name mallotore.com www.mallotore.com;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://www.mallotore.com$request_uri;
}
}
Un detalle que parece tonto y no lo es: la redirección va dentro de location /. Si pones el return 301 directamente en el bloque server, se ejecuta antes que cualquier location, la ruta del desafío también acaba redirigida, y la renovación falla sin que te des cuenta hasta que el certificado caduca.
El docker-compose
Certbot va en su propio contenedor, con la versión fijada (nada de latest: no quiero que una actualización me cambie el comportamiento sin enterarme; en mi caso la versión vive en un fichero de variables, CERTBOT_IMAGE_TAG=v5.8.0). Comparte con nginx la carpeta del desafío, y tiene montados la carpeta ssl/ y el hook. Los fragmentos de compose de este post son los de mi compose.prod.yaml, recortados a lo que importa:
certbot:
image: certbot/certbot:${CERTBOT_IMAGE_TAG}
restart: always
volumes:
- "${NGINX_HOST_PATH}/certbot/conf:/etc/letsencrypt"
- "${NGINX_HOST_PATH}/certbot/www:/var/www/certbot"
- "${NGINX_HOST_PATH}/ssl:/ssl"
- "./scripts/letsencrypt/deploy-hook.sh:/usr/local/bin/deploy-hook.sh:ro"
entrypoint:
- /bin/sh
- -c
- |
trap exit TERM
while :; do
certbot renew --webroot -w /var/www/certbot
sleep 12h & wait $${!}
done
Fíjate en que el entrypoint es el bucle de renovación, no certbot a secas. Esto tiene su miga más adelante, cuando toque pedir el primer certificado. Ese bucle comprueba cada 12 horas si toca renovar. Certbot solo renueva de verdad cuando quedan 30 días o menos (los certificados duran 90), así que en la práctica renueva cada dos meses, pero comprobarlo a menudo te da muchos reintentos si un día falla algo.
Y aquí está lo que casi todos los tutoriales se saltan: renovar el certificado no basta. nginx lo carga al arrancar y lo guarda en memoria, así que seguirá sirviendo el antiguo hasta que se recargue. Si no haces nada, a los 90 días tu web da error de certificado... con el certificado nuevo tan tranquilo en el disco. Yo lo resuelvo con un bucle parecido en nginx, que lo recarga cada 6 horas:
nginx:
volumes:
- "${NGINX_HOST_PATH}/ssl:/etc/nginx/ssl:ro"
- "${NGINX_HOST_PATH}/certbot/www:/var/www/certbot:ro"
command:
- /bin/sh
- -c
- |
while :; do sleep 6h & wait $${!}; nginx -s reload; done & exec nginx -g 'daemon off;'
(Aquí he dejado fuera otros montajes, como nginx.conf o la configuración del sitio. El exec hace que nginx sustituya a la shell y reciba directamente las señales del contenedor.)
La primera vez que lo vi me pregunté si recargar nginx cada 6 horas no cortaría las visitas. No lo hace: nginx -s reload es una recarga en caliente. nginx arranca procesos nuevos con la configuración y el certificado actualizados para las conexiones nuevas, mientras los antiguos terminan lo que tenían a medias y luego se cierran solos. Ninguna petición se corta y la web no deja de responder ni un segundo. Otra cosa sería reiniciar el contenedor, que sí deja unos segundos sin servicio.
¿Y por qué no recargar solo cuando hay certificado nuevo, desde el propio hook? Porque el hook se ejecuta dentro del contenedor de Certbot, y para avisar a nginx necesitaría acceso a Docker en el servidor. Darle eso a Certbot sería darle control sobre todos los contenedores, mucho más de lo que necesita. Recargar cada 6 horas, haya o no certificado nuevo, es menos elegante, pero no cuesta nada y es bastante más seguro.
El hook
El script es muy simple. Certbot le pasa en RENEWED_LINEAGE la carpeta del certificado recién renovado, y él copia los dos ficheros a /ssl con los nombres que nginx ya esperaba:
#!/bin/sh
set -eu
# Certbot rellena esta variable; si falta, alguien ha lanzado el script a mano
: "${RENEWED_LINEAGE:?RENEWED_LINEAGE not set by certbot}"
# Los nombres exactos que espera nginx; /ssl es donde se monta la carpeta ssl/
CHAIN_FILE=/ssl/mallotore_com_chain.crt
KEY_FILE=/ssl/mallotore.key
# cp y no mv: así se conserva el mismo fichero (inodo) que nginx tiene montado
cp "$RENEWED_LINEAGE/fullchain.pem" "$CHAIN_FILE"
cp "$RENEWED_LINEAGE/privkey.pem" "$KEY_FILE"
chmod 600 "$KEY_FILE"
echo "deploy-hook: installed new certificate to $CHAIN_FILE and $KEY_FILE"
Dos detalles que no son obvios. El cp en vez de mv es por precaución: con un fichero montado suelto, un mv lo sustituiría por otro nuevo que el contenedor no vería (te cuento más abajo esta trampa). Aquí monto la carpeta entera, así que seguramente no pasaría, pero cp no cuesta nada y me ahorra pensarlo. Y el chmod 600 es para que la clave privada no quede legible para cualquiera, que cp crea el fichero con los permisos por defecto.
El primer certificado
La primera vez sí hay que hacerlo a mano, y con el DNS del dominio apuntando ya al servidor (si no, Let's Encrypt no puede validar nada). Este es el comando de Certbot, tal cual:
docker-compose -f compose.yaml -f compose.prod.yaml run -T --rm --entrypoint certbot certbot \
certonly --webroot -w /var/www/certbot \
--cert-name mallotore.com -d mallotore.com -d www.mallotore.com \
--deploy-hook "sh /usr/local/bin/deploy-hook.sh" \
--agree-tos --no-eff-email -m tu@email.com
Dos cosas que me costaron entender. La primera: el --entrypoint certbot. Como el entrypoint del servicio es el bucle de renovación, si lanzas docker-compose run --rm certbot certonly ... a pelo, esos argumentos se los traga la shell del bucle y lo que arrancas es la renovación, no el certonly. La segunda: el --deploy-hook queda guardado en la configuración de renovación, así que a partir de ahí cada renovación lo ejecuta sola. Le pongo sh delante para no depender de que el script tenga permiso de ejecución.
Y hay un problema del huevo y la gallina dentro del huevo y la gallina: el bloque del puerto 443 de nginx apunta a unos ficheros de certificado que todavía no existen, así que nginx ni arranca (y sin nginx no hay desafío). Además, si montas un fichero que no existe, Docker crea una carpeta en su lugar. Por eso no lanzo el comando a pelo, sino un script mío, scripts/letsencrypt/issue-first-cert.sh, que en un servidor nuevo hace esto:
- Comprueba que se puede descargar la imagen de Certbot antes de tocar nada.
- Si ya hay un certificado para ese dominio, no hace nada (a no ser que le pases
--force). - Crea las carpetas y hace una copia de seguridad de lo que hubiera.
- Genera un certificado autofirmado de un día, solo para que nginx tenga ficheros reales con los que arrancar.
- Levanta los contenedores y lanza primero el comando con
--dry-run, y solo si pasa, el de verdad. - Recarga nginx y muestra quién emite el certificado y cuándo caduca.
Se usa así, y el correo se lo pasas tú (aquí va un placeholder):
scripts/letsencrypt/issue-first-cert.sh --email tu@email.com \
--domain mallotore.com --domain www.mallotore.com
El primer --domain es el nombre del certificado.
Probarlo sin esperar 60 días
Esta es la parte que más me importaba. Si la renovación falla, no te enteras hasta dentro de dos meses, cuando ya es tarde. Por suerte, Certbot permite simularla contra el servidor de pruebas de Let's Encrypt:
docker exec blog-certbot-1 certbot renew --dry-run --run-deploy-hooks
El --dry-run hace todo el proceso sin pedir un certificado real, y el --run-deploy-hooks ejecuta también el hook, que normalmente se salta en las simulaciones. Así compruebas de una vez el desafío, los permisos y el script.
La primera vez que lo lancé se quedó varios minutos parado tras "Processing...", y ya me veía buscando qué había roto. Mirando el log de Certbot apareció la explicación: cuando se ejecuta sin nadie delante, Certbot añade una espera aleatoria (a mí me tocaron 270 segundos) para que no todos los servidores del mundo llamen a Let's Encrypt a la vez. No estaba colgado, estaba siendo educado. Si no quieres esperar, --no-random-sleep-on-renew se la salta.
Y un consejo: haz todas las pruebas con --dry-run. Let's Encrypt tiene límites de peticiones, y un bucle de pruebas con certificados reales puede dejarte el dominio bloqueado unos días.
Dos trampas de Docker
Por el camino me encontré dos sorpresas de Docker que no tienen que ver con Let's Encrypt, pero que te pueden hacer perder un buen rato:
- Si montas un fichero que no existe, Docker crea una carpeta con ese nombre. Si la ruta del hook está mal escrita, Certbot se encuentra una carpeta vacía donde esperaba un script. Por eso, después de cualquier cambio, compruebo que es un fichero de verdad:
docker exec blog-certbot-1 ls -l /usr/local/bin/deploy-hook.sh. - Montar un fichero suelto (como
nginx.conf) ata el contenedor al fichero original. Si lo editas con algunos editores, que en realidad crean un fichero nuevo, el contenedor sigue viendo el viejo. Tras cambiarlo:docker restartdel contenedor de nginx.
Cómo compruebo que todo va bien
Dos comandos que ahora lanzo después de cualquier cambio:
curl -sI http://mallotore.com | head -3 # debe redirigir (301) a https
openssl s_client -connect www.mallotore.com:443 -servername www.mallotore.com </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -dates # quién lo emite y cuándo caduca
Si la fecha de caducidad avanza después de una renovación, todo el circuito funciona: Certbot renovó, el hook copió y nginx recargó.
Para cerrar
Pensaba que el reto era conseguir el certificado, y resultó ser lo más fácil. Lo que de verdad cuesta es todo lo que pasa después: que se renueve solo, que nginx se entere, y poder comprobarlo hoy en vez de descubrirlo dentro de dos meses con la web caída. Al final, son un script de pocas líneas, un par de bucles y un --dry-run. Pero la diferencia entre tenerlo y no tenerlo es la diferencia entre olvidarte del certificado... y que el certificado se olvide de ti.