Automatizar SSL con LetsEncrypt en AWS

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.es

Como 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-route53

De 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/letsencrypt

Ahí 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.pem

fullchain.pem contiene tanto el certificado del dominio como la cadena intermedia necesaria para Nginx.

La clave privada se almacena como:

privkey.pem

Actualizando 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-instances

Después lanza un:

aws ssm send-command

utilizando 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.es

y 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-expiring

por 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
          fi

Arquitectura 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

¿Te ha ayudado este artículo?

☕ Invítame a un café

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Rubenortiz.es
Información sobre privacidad

Este sitio web utiliza cookies para ofrecerte la mejor experiencia de usuario posible. La información sobre cookies se almacena en tu navegador y realiza funciones como reconocerte cuando vuelve a nuestro sitio web y ayudarme a comprender qué secciones de la web resultan más interesantes y útiles.