Durante bastante tiempo el certificado SSL de mi WordPress en AWS había funcionado de una forma bastante tradicional: compraba el certificado, lo renovaba manualmente una vez al año y lo instalaba en el servidor. Funcionaba, pero tenía un problema evidente: dependía de una intervención manual periódica. Además, mi arquitectura había evolucionado y esa forma de gestionar el certificado ya no encajaba demasiado bien con ella. Vamos a ver en este post como automatizar un certificado SSL usando LetsEncrypt en AWS.
Arquitectura inicial
El blog está desplegado sobre AWS Elastic Beanstalk, utilizando una instancia EC2 en modo SingleInstance.
Delante de Elastic Beanstalk tengo una distribución de CloudFront:
Internet
|
v
CloudFront
|
| HTTPS
v
Elastic Beanstalk
|
v
Nginx
|
v
WordPress
Aquí hay realmente dos conexiones TLS diferentes.
La primera:
Cliente -> CloudFront
utiliza un certificado de AWS Certificate Manager (ACM).
Ese certificado ya estaba completamente automatizado por AWS y no necesitaba ningún cambio.
La segunda:
CloudFront -> Elastic Beanstalk / Nginx
utilizaba otro certificado.
Ese era el que compraba a través de DonDominio y que estaba emitido por Sectigo.
Nginx lo cargaba desde:
ssl_certificate /etc/pki/tls/certs/ssl-bundle.crt;
ssl_certificate_key /etc/pki/tls/certs/server.key;Los archivos estaban almacenados en un bucket privado de S3 y durante el despliegue de Elastic Beanstalk un hook descargaba ambos:
S3
|
v
Elastic Beanstalk prebuild
|
v
/etc/pki/tls/certs/
|
v
Nginx
El mecanismo funcionaba, pero cada renovación implicaba comprar el nuevo certificado, descargarlo, preparar la cadena, subirlo a S3 y desplegarlo.
El segundo problema: la EC2 es Spot
Había otro detalle importante. La instancia utilizada por Elastic Beanstalk es Spot. Eso significa que la EC2 debe tratarse como infraestructura efímera: AWS puede sustituirla. Por tanto, instalar Certbot directamente en esa instancia y dejar que ella se encargase de las renovaciones no era una solución que me gustase demasiado.
Podría hacerse, pero obligaría a persistir el estado de Certbot fuera de la máquina y reconstruirlo cuando la instancia fuese reemplazada.
Preferí desacoplar completamente la renovación del ciclo de vida de EC2.
Objetivo
La idea era llegar a una arquitectura donde:
- Let’s Encrypt generase el certificado.
- La validación fuese completamente automática.
- Ninguna credencial AWS permanente estuviese almacenada en GitHub.
- El certificado sobreviviese a reemplazos de la instancia Spot.
- Una renovación actualizase automáticamente Nginx.
- Una EC2 nueva pudiese recuperar el último certificado disponible.
La arquitectura resultante sería:
GitHub Actions
|
| OIDC
v
AWS
|
+------> Route53
| DNS-01
|
+------> Let's Encrypt
|
+------> S3
| |
| +-- ACME state
| +-- certificate
|
+------> SSM Run Command
|
v
EC2 / Nginx
Validación DNS-01 con Route53
Para generar el certificado decidí utilizar el challenge DNS-01 de ACME.
El certificado cubre:
rubenortiz.es
www.rubenortiz.esComo la zona DNS está alojada en Route53, Certbot puede crear temporalmente los registros TXT necesarios para demostrar a Let’s Encrypt que controla el dominio.
El workflow utiliza:
certbot
+
certbot-dns-route53De esta forma no necesito abrir ninguna URL especial en WordPress ni modificar el tráfico HTTP para realizar la validación.
GitHub Actions y AWS OIDC
La renovación se ejecuta desde GitHub Actions.
En lugar de guardar un AWS_ACCESS_KEY_ID y un AWS_SECRET_ACCESS_KEY como secrets, el workflow utiliza OIDC para asumir un IAM Role temporalmente.
El flujo es:
GitHub Actions
|
| OIDC token
v
AWS STS
|
v
IAM Role
Ese role únicamente recibe los permisos necesarios para:
- gestionar el challenge DNS en Route53;
- leer y escribir los certificados en S3;
- localizar la EC2 activa;
- ejecutar comandos mediante AWS Systems Manager.
Persistiendo el estado de Certbot
Los runners de GitHub Actions también son efímeros.
Cada ejecución empieza en una máquina nueva.
Certbot, sin embargo, mantiene información importante bajo:
/etc/letsencryptAhí almacena cuentas ACME, configuraciones de renovación, certificados anteriores y otra información necesaria.
Para no perder ese estado decidí guardarlo en S3.
Además, en vez de sincronizar directamente el directorio, lo empaqueto como tar.gz, ya que Certbot utiliza enlaces simbólicos entre los directorios live y archive.
El resultado es:
s3://mybucket/ssl/acme-state-prod/
letsencrypt.tar.gz
Al comenzar el workflow:
S3
|
v
restore ACME state
|
v
Certbot
y después de ejecutarlo:
Certbot
|
v
tar.gz
|
v
S3
El certificado activo
El certificado que deben consumir las instancias se mantiene separado del estado interno de Certbot:
s3://mybucket/ssl/current/
fullchain.pem
privkey.pemfullchain.pem contiene tanto el certificado del dominio como la cadena intermedia necesaria para Nginx.
La clave privada se almacena como:
privkey.pemActualizando la EC2 mediante SSM
Generar y almacenar el certificado era solo una parte del problema.
Todavía necesitaba conseguir que la instancia que está ejecutando WordPress empezase a utilizarlo.
Para eso utilizo AWS Systems Manager Run Command.
GitHub Actions localiza primero la instancia activa de Elastic Beanstalk mediante sus tags:
aws ec2 describe-instancesDespués lanza un:
aws ssm send-commandutilizando el documento administrado por AWS:
AWS-RunShellScript
El comando remoto realiza aproximadamente estas operaciones:
crear directorio del certificado
|
v
descargar fullchain.pem desde S3
|
v
descargar privkey.pem desde S3
|
v
nginx -t
|
v
systemctl reload nginx
Antes del reload siempre se ejecuta:
nginx -t
Si la configuración de Nginx no es válida, el proceso falla antes de recargar el servicio.
Nueva configuración de Nginx
También cambié las rutas utilizadas por Nginx para dejar claro que estos certificados pertenecen al nuevo mecanismo:
ssl_certificate /etc/pki/tls/certs/letsencrypt/fullchain.pem;
ssl_certificate_key /etc/pki/tls/certs/letsencrypt/privkey.pem;
La primera emisión real produjo un certificado como este:
subject=CN=www.rubenortiz.es
issuer=C=US, O=Let's Encrypt, CN=YE1
DNS:rubenortiz.es
DNS:www.rubenortiz.es
Después del reload comprobé directamente contra el listener local de Nginx:
openssl s_client \
-connect localhost:443 \
-servername www.rubenortiz.esy pude confirmar que el origin ya estaba sirviendo el certificado de Let’s Encrypt.
¿Qué ocurre si AWS sustituye la instancia Spot?
Esta era una de las partes importantes del diseño.
La renovación no depende de EC2:
GitHub Actions -> Let's Encrypt -> S3
Y S3 actúa como fuente persistente del certificado.
El hook prebuild de Elastic Beanstalk también se modificó para descargar:
ssl/current/fullchain.pem
ssl/current/privkey.pem
Por tanto, si AWS elimina la Spot actual:
EC2 antigua
X
|
v
Nueva EC2
|
v
Elastic Beanstalk prebuild
|
v
S3 /ssl/current/
|
v
Nginx
La nueva instancia recupera automáticamente el certificado vigente.
Renovación automática
Finalmente, el workflow deja de depender de pushes y pasa a ejecutarse mediante un cron de GitHub Actions:
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:Es decir, se ejecuta cada lunes y también puede lanzarse manualmente.
Certbot se ejecuta con:
--keep-until-expiringpor lo que ejecutar el workflow semanalmente no significa emitir un certificado nuevo cada semana.
Mientras el certificado siga siendo suficientemente válido, Certbot mantiene el existente.
Esto es interesante porque da varias oportunidades de renovación antes de que llegue la fecha de expiración.
Receta en Github Actions
En este caso, para saber que EC2 existe en cada momento, saco los datos de la cli de EB.
name: Renew Let's Encrypt Certificate
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
env:
AWS_REGION: "<AWS_REGION>"
AWS_ACCOUNT_ID: "<AWS_ACCOUNT_ID>"
AWS_OIDC_ROLE_NAME: "<OIDC_ROLE_NAME>"
LETSENCRYPT_EMAIL: "<EMAIL_ADDRESS>"
CERT_DOMAIN: "www.example.com"
CERT_APEX_DOMAIN: "example.com"
CERT_BUCKET: "<PRIVATE_S3_BUCKET>"
ACME_STATE_KEY: "certificates/acme-state/state.tar.gz"
CERT_CURRENT_PREFIX: "certificates/current"
EB_ENV_NAME: "<ELASTIC_BEANSTALK_ENVIRONMENT>"
permissions:
id-token: write
contents: read
jobs:
letsencrypt-production:
name: Renew production certificate
runs-on: ubuntu-latest
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_OIDC_ROLE_NAME }}
aws-region: ${{ env.AWS_REGION }}
- name: Install Certbot
run: |
python -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install certbot certbot-dns-route53
- name: Restore Certbot state from S3
run: |
mkdir -p "${{ runner.temp }}/letsencrypt"
if aws s3 ls \
"s3://${{ env.CERT_BUCKET }}/${{ env.ACME_STATE_KEY }}" \
> /dev/null 2>&1; then
aws s3 cp \
"s3://${{ env.CERT_BUCKET }}/${{ env.ACME_STATE_KEY }}" \
"${{ runner.temp }}/letsencrypt.tar.gz"
tar -xzf "${{ runner.temp }}/letsencrypt.tar.gz" \
-C "${{ runner.temp }}/letsencrypt"
fi
- name: Issue Let's Encrypt certificate
run: |
.venv/bin/certbot certonly \
--dns-route53 \
--config-dir "${{ runner.temp }}/letsencrypt" \
--work-dir "${{ runner.temp }}/letsencrypt-work" \
--logs-dir "${{ runner.temp }}/letsencrypt-logs" \
--non-interactive \
--agree-tos \
--keep-until-expiring \
--email "${{ env.LETSENCRYPT_EMAIL }}" \
-d "${{ env.CERT_DOMAIN }}" \
-d "${{ env.CERT_APEX_DOMAIN }}"
- name: Validate certificate
run: |
openssl x509 \
-in "${{ runner.temp }}/letsencrypt/live/${{ env.CERT_DOMAIN }}/fullchain.pem" \
-noout \
-subject \
-issuer \
-dates \
-ext subjectAltName
- name: Persist Certbot state to S3
run: |
tar -czf "${{ runner.temp }}/letsencrypt.tar.gz" \
-C "${{ runner.temp }}/letsencrypt" .
aws s3 cp \
"${{ runner.temp }}/letsencrypt.tar.gz" \
"s3://${{ env.CERT_BUCKET }}/${{ env.ACME_STATE_KEY }}"
- name: Upload active certificate to S3
run: |
aws s3 cp \
"${{ runner.temp }}/letsencrypt/live/${{ env.CERT_DOMAIN }}/fullchain.pem" \
"s3://${{ env.CERT_BUCKET }}/${{ env.CERT_CURRENT_PREFIX }}/fullchain.pem"
aws s3 cp \
"${{ runner.temp }}/letsencrypt/live/${{ env.CERT_DOMAIN }}/privkey.pem" \
"s3://${{ env.CERT_BUCKET }}/${{ env.CERT_CURRENT_PREFIX }}/privkey.pem"
- name: Download the new SSL certificate to the EC2 instance
run: |
set -euo pipefail
INSTANCE_ID=$(aws ec2 describe-instances \
--filters \
"Name=tag:elasticbeanstalk:environment-name,Values=${{ env.EB_ENV_NAME }}" \
"Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].InstanceId' \
--output text)
if [ -z "$INSTANCE_ID" ]; then
echo "No running EC2 instance found"
exit 1
fi
echo "EC2 instance found"
COMMAND_ID=$(aws ssm send-command \
--instance-ids "$INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--comment "Deploy Let's Encrypt certificate" \
--parameters 'commands=[
"mkdir -p /etc/pki/tls/certs/example",
"aws s3 cp s3://${{ env.CERT_BUCKET }}/${{ env.CERT_CURRENT_PREFIX }}/fullchain.pem /etc/pki/tls/certs/example/fullchain.pem",
"aws s3 cp s3://${{ env.CERT_BUCKET }}/${{ env.CERT_CURRENT_PREFIX }}/privkey.pem /etc/pki/tls/certs/example/privkey.pem",
"chmod 600 /etc/pki/tls/certs/example/privkey.pem",
"nginx -t",
"systemctl reload nginx",
"openssl x509 -in /etc/pki/tls/certs/example/fullchain.pem -noout -subject -issuer -dates"
]' \
--query 'Command.CommandId' \
--output text)
echo "SSM command submitted"
set +e
aws ssm wait command-executed \
--command-id "$COMMAND_ID" \
--instance-id "$INSTANCE_ID"
WAIT_EXIT_CODE=$?
set -e
echo "SSM command result:"
aws ssm get-command-invocation \
--command-id "$COMMAND_ID" \
--instance-id "$INSTANCE_ID" \
--query '{
Status:Status,
StandardOutput:StandardOutputContent,
StandardError:StandardErrorContent
}'
if [ "$WAIT_EXIT_CODE" -ne 0 ]; then
echo "SSM command failed or timed out"
exit 1
fiArquitectura final
El resultado queda así:
+------------------+
| GitHub Actions |
| weekly cron |
+--------+---------+
|
OIDC
|
v
+------------------+
| AWS |
+------------------+
| |
| |
Route53 DNS-01 |
| |
v |
Let's Encrypt |
| |
+----+-----+
|
v
+--------------------+
| S3 |
| |
| acme-state-prod/ |
| current/ |
+---------+----------+
|
SSM
|
v
+------------------+
| Elastic Beanstalk|
| EC2 Spot |
+--------+---------+
|
v
Nginx
|
v
WordPress
CloudFront continúa utilizando ACM para el certificado público:
Usuario
|
HTTPS / ACM
|
CloudFront
|
HTTPS / Let's Encrypt
|
Nginx
Resultado
Con este cambio he eliminado del flujo habitual la renovación manual del certificado comprado a un proveedor externo.
Ahora:
- Let’s Encrypt emite y renueva el certificado.
- Route53 resuelve automáticamente el challenge ACME.
- GitHub Actions ejecuta el proceso periódicamente.
- OIDC evita credenciales AWS permanentes.
- S3 mantiene tanto el certificado como el estado ACME.
- SSM actualiza la EC2 activa.
- Nginx valida su configuración antes de recargarse.
- Una nueva instancia Spot puede reconstruirse utilizando el certificado almacenado en S3.
Y, quizá lo más importante, la gestión del certificado ya no depende de la vida de una máquina concreta ni de acordarme una vez al año de renovarlo.
Links
- https://www.rubenortiz.es/category/seguridad/
- https://community.letsencrypt.org/t/how-to-install-an-ssl-certificate-on-an-aws-ec2-instance/202218
¿Te ha ayudado este artículo?
☕ Invítame a un café