
En 2021 rehice este blog con Gatsby. En aquel artículo expliqué cómo había montado las páginas desde cero, el modelo de datos con GraphQL, el pipeline de Markdown y toda la colección de plugins que daban forma al sitio. Durante años ha cumplido perfectamente su función, pero había un problema cada vez más difícil de ignorar: el build completo tardaba demasiado. Publicar una corrección pequeña o un artículo nuevo implicaba esperar a que Gatsby reconstruyese el grafo de datos y procesase de nuevo todo el sitio, incluidas sus imágenes.
Ese tiempo de construcción fue el motivo principal de la migración. La pipeline completa tardaba alrededor de 8 minutos con Gatsby; después del cambio a Astro quedó por debajo de 2 minutos. Es una reducción superior al 75 % y una pipeline más de cuatro veces más rápida. El proyecto, además, se había quedado congelado en Gatsby 3, React 17 y Node 14, y actualizarlo ya no consistía en cambiar tres versiones: implicaba arrastrar un ecosistema de plugins antiguo para resolver un problema que, en realidad, es bastante sencillo.
El cambio a Astro 7 resolvía la generación, pero no quería limitar la migración a sustituir un framework por otro. Aproveché para replantear también cómo llegaba el resultado a producción. El blog es un conjunto de HTML, CSS, JavaScript e imágenes: no necesita un servidor encendido permanentemente ni una instancia que mantener. La arquitectura natural era almacenar ese artefacto en Amazon S3 y poner CloudFront delante como CDN, terminación HTTPS y único punto de acceso público.

La migración simplifica la generación del blog y conecta el artefacto estático con S3 y la red de distribución de CloudFront
En este artículo Astro queda deliberadamente en segundo plano. Me voy a centrar en la arquitectura AWS, en las decisiones de seguridad y en los pasos necesarios para desplegar el sitio desde GitHub Actions. Como punto de partida me ha resultado especialmente útil el artículo Host a Fast, Free Portfolio Website with S3 and CloudFront, completándolo con los detalles específicos de este blog: URLs de directorio, página 404, build Docker y despliegue automático.
Todo el código está disponible en github.com/davidpoza/davidinformatico.com.
El objetivo: publicar más rápido y eliminar servidores
Gatsby seguía funcionando, pero el build completo tardaba demasiado. Publicar una corrección pequeña obligaba a reconstruir el grafo de datos, ejecutar las consultas GraphQL y atravesar una cadena considerable de plugins de Markdown e imágenes. Ese tiempo fue el motivo principal para migrar y el criterio con el que evaluar si el cambio había merecido la pena.
Resultado medido: la pipeline de construcción y despliegue ha pasado de tardar aproximadamente 8 minutos con Gatsby a completarse en menos de 2 minutos con Astro. El cambio de generador ha reducido el tiempo en más de un 75 % y ha hecho la pipeline más de cuatro veces más rápida.
Astro genera el mismo tipo de artefacto estático en dist/, conservando las URLs y el contenido histórico. Para lo que viene después da prácticamente igual que el directorio lo produzca Astro, Hugo o una colección de HTML escrito a mano. El contrato de la plataforma es muy sencillo:
entrada: repositorio Git
salida: directorio dist/ autocontenidoA partir de ahí quería cumplir cinco condiciones:
- El bucket no debía ser público: solo CloudFront puede leerlo.
- El dominio debía funcionar por HTTPS con un certificado gestionado.
- Rutas como
/apuntes-git/debían resolver a/apuntes-git/index.html. - Cada
pushamaindebía construir, publicar e invalidar la caché. - El coste debía permanecer dentro de la capa gratuita para el tráfico normal de un blog personal.
La arquitectura con el modelo C4
Voy a utilizar el modelo C4 para acercarme progresivamente a la solución. En un sitio estático las fronteras entre aplicación e infraestructura se mezclan un poco: el generador desaparece después del build y el sistema que atiende las peticiones está compuesto casi por completo por servicios administrados.
Nivel 1 — Contexto
En el nivel de contexto solo aparecen las personas y los sistemas con los que se relacionan:
Diagrama C4 de contexto: el autor publica contenido mediante GitHub y los lectores reciben el blog a través de AWS
Como autor, trabajo contra el repositorio de GitHub. GitHub Actions construye y publica cada versión. Como lector, entro por davidinformatico.com y CloudFront resuelve la entrega desde su red de puntos de presencia. La API de comentarios permanece como sistema externo y no condiciona la disponibilidad del contenido.
Hay una separación importante: GitHub forma parte del camino de publicación, pero no del de lectura. Si Actions deja de estar disponible, no podré desplegar una versión nueva; S3 y CloudFront seguirán sirviendo la última versión publicada.
Nivel 2 — Contenedores
En C4, un contenedor es una unidad ejecutable o desplegable, no necesariamente un contenedor Docker:
Diagrama C4 de contenedores: el proceso de build genera el artefacto, S3 lo almacena y CloudFront lo distribuye
- Build Docker: proceso efímero que transforma el repositorio en
dist/. - Artefacto estático: HTML, CSS, JavaScript, imágenes y sitemap.
- Bucket S3 privado: origen duradero de los objetos, sin acceso público.
- CloudFront: punto de entrada, caché, HTTPS y distribución global.
- Navegador: ejecuta únicamente las pequeñas piezas interactivas incluidas en el artefacto.
No hay Node, Apache, contenedores ni máquinas virtuales atendiendo peticiones en AWS. Solo existen objetos y servicios administrados. Eso elimina parches de sistema operativo, procesos que reiniciar y capacidad que dimensionar.
Nivel 3 — Componentes de la entrega en AWS
Al abrir el contenedor de entrega aparecen las piezas que hacen segura y compatible la publicación:
Diagrama C4 de componentes AWS: DNS, certificado, función de reescritura, caché, OAC y bucket privado
Cloudflare DNS, en su plan gratuito, es el proveedor autoritativo del dominio y lo resuelve hacia la distribución. Lo utilizo como DNS, no como una segunda CDN: el tráfico HTTP pasa directamente del lector a CloudFront.
AWS Certificate Manager (ACM) proporciona el certificado TLS. Para CloudFront debe solicitarse en us-east-1, aunque el bucket se encuentre en otra región. Es uno de esos detalles que parecen arbitrarios hasta que pierdes diez minutos buscando por qué el certificado no aparece en el desplegable.
CloudFront Function reescribe las URLs limpias antes de consultar el origen. Con un bucket S3 normal y privado, /apuntes-git/ no se convierte automáticamente en /apuntes-git/index.html.
El comportamiento de caché admite solo lectura, redirige HTTP a HTTPS, comprime respuestas y decide cuánto tiempo permanece cada objeto en el borde.
Origin Access Control (OAC) firma las peticiones que CloudFront envía a S3. El bucket bloquea todo acceso público y su política concede s3:GetObject exclusivamente al servicio CloudFront cuando la petición procede de esta distribución.
Las respuestas de error convierten tanto el 403 como el 404 del origen en la página /404.html con código HTTP 404. No uso la solución típica de una SPA —devolver siempre /index.html con un 200— porque este blog genera una página HTML real por ruta y quiero conservar una semántica correcta para buscadores y clientes.
Nivel 4 — Código en el borde y política del origen
El nivel de código puede concentrarse en dos elementos pequeños pero críticos. El primero es la función asociada al evento viewer request del comportamiento por defecto:
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith("/")) {
request.uri += "index.html";
} else if (!uri.includes(".")) {
request.uri += "/index.html";
}
return request;
}Así, /apuntes-git y /apuntes-git/ buscan el objeto apuntes-git/index.html, mientras que /favicon.ico o /_astro/estilos.hash.css se dejan intactos.
El segundo elemento es la política de lectura del bucket. Sustituyendo los tres valores en mayúsculas, queda así:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontReadOnly",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::NOMBRE_DEL_BUCKET/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ID_DE_CUENTA:distribution/ID_DE_DISTRIBUCION"
}
}
}
]
}La condición AWS:SourceArn importa: sin ella estaríamos dando permiso al servicio CloudFront en general, no únicamente a nuestra distribución. Es la política recomendada en la documentación de OAC.
Despliegue paso a paso
La infraestructura se configura una vez; GitHub Actions se ocupa después de las publicaciones. Este es el orden que seguí.
1. Generar y comprobar el artefacto
El Dockerfile fija Node, pnpm y las dependencias. Su etapa final artifact contiene exclusivamente los ficheros que deben llegar a S3:
FROM scratch AS artifact
COPY /site/dist/ /BuildKit permite exportarla directamente:
docker build --target artifact --output type=local,dest=dist .Antes de tocar AWS conviene servir dist/ localmente y comprobar la portada, un artículo, una ruta inexistente, los recursos y el sitemap. S3 no va a corregir enlaces rotos ni diferencias entre mayúsculas y minúsculas.
2. Crear un bucket S3 privado
En S3 creo un bucket de propósito general en una región cercana. El nombre debe ser globalmente único, pero no necesita coincidir con el dominio porque no voy a utilizar el endpoint de website hosting.
Mantengo estas opciones:
- Block all public access activado.
- Object Ownership en Bucket owner enforced.
- Cifrado SSE-S3, que S3 aplica por defecto.
- Versionado opcional; resulta útil para recuperar un objeto, aunque no sustituye un rollback coherente de todo el sitio.
No hay que activar «Static website hosting» ni hacer público el bucket. Para usar OAC, CloudFront debe apuntar al endpoint REST de S3, no al endpoint s3-website. Los website endpoints se comportan como orígenes HTTP públicos y no admiten OAC.
Para una primera prueba puedo subir el contenido manualmente:
aws s3 sync dist/ s3://NOMBRE_DEL_BUCKET/ --delete--delete es importante: elimina del bucket los objetos que ya no existen en el build. También exige cuidado, porque el bucket debe estar dedicado al sitio.
3. Solicitar el certificado en ACM
Si voy a usar el dominio de CloudFront (dxxxxx.cloudfront.net), este paso no es necesario. Para davidinformatico.com solicito en ACM un certificado público que cubra el dominio raíz y, si lo necesito, www.davidinformatico.com.
La regla no negociable es solicitarlo en US East (N. Virginia), us-east-1. CloudFront solo permite asociar certificados ACM de esa región, como indica la documentación de certificados de CloudFront. Valido la propiedad mediante el registro CNAME que proporciona ACM. Si el certificado no es exportable y se usa directamente con CloudFront, ACM no añade coste.
4. Crear la distribución CloudFront
Creo una distribución estándar para un único sitio y elijo el bucket como origen. La configuración esencial es:
- Origin domain: el bucket S3, usando su endpoint REST.
- Origin access: Origin Access Control, con peticiones firmadas siempre.
- Viewer protocol policy: Redirect HTTP to HTTPS.
- Allowed methods:
GETyHEAD;OPTIONSsolo si alguna necesidad real lo exige. - Compress objects automatically: activado.
- Default root object:
index.html, sin una barra inicial. - Alternate domain names: el dominio y subdominios que cubre el certificado.
- Custom SSL certificate: el certificado creado en
us-east-1.
La consola actual puede crear el OAC y actualizar la política de S3 automáticamente. Aun así, reviso que el bucket continúe privado y que la política final equivalga a la mostrada antes. La guía oficial de AWS también propone S3 privado con OAC como configuración de partida.
El objeto raíz solo resuelve /. La propia documentación de CloudFront advierte que no aplica automáticamente index.html a todos los subdirectorios. Para eso necesitamos el siguiente paso.
5. Publicar la función para las URLs limpias
En CloudFront → Functions creo una función, pego el código de reescritura anterior, la pruebo y la publico. Después la asocio al comportamiento por defecto:
Event type: Viewer request
Function association: la función publicadaUna CloudFront Function se ejecuta antes de consultar la caché y el origen, por lo que todas las variantes de una ruta convergen en la misma clave de objeto. Esto encaja con build.format: 'directory' de Astro sin exponer peculiaridades de S3 en las URLs públicas.
En la pestaña Error pages creo además dos respuestas personalizadas:
| Error del origen | Página de respuesta | Código devuelto | TTL de error |
|---|---|---|---|
403 | /404.html | 404 | corto, por ejemplo 10 s |
404 | /404.html | 404 | corto, por ejemplo 10 s |
Con un origen S3 privado, un objeto inexistente puede manifestarse como 403; por eso configuro ambos casos.
6. Apuntar Cloudflare DNS a CloudFront
Cuando la distribución termina de desplegarse, primero pruebo su dominio cloudfront.net. Solo entonces cambio los registros en Cloudflare.
Creo un registro CNAME con el dominio de la distribución como destino:
| Tipo | Nombre | Destino | Proxy |
|---|---|---|---|
CNAME | @ | dxxxxxxxxxxxxx.cloudfront.net | DNS only |
CNAME | www | dxxxxxxxxxxxxx.cloudfront.net | DNS only |
Cloudflare permite usar un CNAME en el dominio raíz gracias a CNAME Flattening, que está activo por defecto para el apex en todos los planes, incluido Free.
Dejo ambos registros en DNS only —la nube gris— porque CloudFront ya realiza las funciones de CDN, caché, TLS y protección del origen. Activar el proxy de Cloudflare introduciría dos CDN consecutivas, dos cachés que invalidar y una segunda terminación TLS. Es una arquitectura posible, pero debería ser una decisión deliberada; para este blog Cloudflare se limita a resolver el dominio. La documentación sobre Proxy status explica la diferencia entre ambos modos.
Los CNAME que entrega ACM para validar el certificado también deben quedar en DNS only. Por último, el dominio alternativo configurado en CloudFront debe coincidir exactamente con el nombre del certificado y del registro DNS.
7. Dar a GitHub permisos mínimos
El job de despliegue necesita dos grupos de permisos:
- En S3: listar el bucket y crear, reemplazar o borrar objetos bajo su prefijo.
- En CloudFront: ejecutar
CreateInvalidationsobre la distribución concreta.
La versión actual del repositorio utiliza AWS_ACCESS_KEY_ID y AWS_SECRET_ACCESS_KEY guardados como secrets de GitHub. De momento no puedo usar OpenID Connect (OIDC) porque la cuenta está en el AWS Free Plan y AWS reserva esta configuración de identidad y permisos granulares para sus Advanced Features. No ha sido una elección de arquitectura: es una limitación del plan gratuito con el que he desplegado el blog.
Activar las Advanced Features ampliaría el acceso a las funciones de la cuenta, pero AWS indica que después no se puede volver al Free Plan. Por ahora mantengo las credenciales estáticas, con permisos mínimos, almacenadas como secrets y sujetas a rotación. La diferencia entre ambos niveles está explicada en las preguntas frecuentes de AWS Free Tier, en el apartado «Advanced Features».
Cuando cambie de plan, quiero migrar a OIDC. GitHub podrá asumir entonces un rol de AWS mediante credenciales temporales y dejaré de almacenar una clave de larga duración. GitHub documenta el proceso en Configuring OpenID Connect in Amazon Web Services.
No hace falta conceder al pipeline permisos para crear buckets, distribuciones o roles. Publicar contenido y modificar infraestructura son responsabilidades diferentes.
8. Automatizar el despliegue con GitHub Actions
La vista de despliegue completa queda así:
Vista de despliegue: un push activa GitHub Actions, que construye con Docker, sincroniza dist con S3 e invalida CloudFront
El workflow real se dispara con cada push a main y también admite ejecución manual. Sus pasos principales son:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: docker/setup-buildx-action@v3
- name: Build Astro artifact with Docker
run: docker build --target artifact --output type=local,dest=dist .
- name: Deploy to S3
run: |
aws s3 sync dist/ "s3://${{ vars.S3_BUCKET }}/" \
--delete \
--only-show-errors
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id "${{ vars.CF_DISTRIBUTION_ID }}" \
--paths "/*"fetch-depth: 0 descarga el historial completo porque el blog obtiene de Git la fecha de actualización de los artículos. Docker genera exactamente el mismo artefacto que en local; s3 sync publica las diferencias y la invalidación fuerza a los puntos de presencia a descartar la versión anterior.
También serializo las publicaciones:
concurrency:
group: production-deploy
cancel-in-progress: falseAsí nunca hay dos procesos modificando el mismo bucket a la vez y un despliegue que ya ha empezado no queda cancelado a mitad.
Las variables necesarias son AWS_REGION, S3_BUCKET y CF_DISTRIBUTION_ID. Con credenciales estáticas se añaden los dos secrets de AWS; con OIDC basta el ARN del rol.
9. Verificar el circuito completo
Antes de darlo por terminado compruebo estas rutas y propiedades:
curl -I https://davidinformatico.com/
curl -I https://davidinformatico.com/apuntes-git
curl -I https://davidinformatico.com/apuntes-git/
curl -I https://davidinformatico.com/no-existeLas tres primeras deben responder correctamente y la última debe mostrar la página personalizada conservando el estado 404. También intento abrir un objeto con la URL REST de S3: debe devolver acceso denegado. Si funciona directamente desde S3, el bucket o los objetos siguen siendo públicos y OAC no está cumpliendo su propósito.
Caché e invalidaciones
CloudFront sirve una copia desde el punto de presencia más próximo. En un cache miss solicita el objeto a S3 mediante OAC, lo devuelve y lo conserva de acuerdo con la política de caché y los encabezados Cache-Control.
El workflow actual invalida /* después de cada publicación. Es una estrategia contundente pero razonable para un blog que se actualiza con poca frecuencia. CloudFront cuenta un patrón con comodín como una sola ruta de invalidación, aunque afecte a miles de objetos; las primeras 1.000 rutas de invalidación al mes son gratuitas.
Si la frecuencia de publicación creciese, aplicaría una política más fina:
- HTML y sitemap con un TTL corto o
no-cache, porque mantienen nombres estables. - CSS, JavaScript e imágenes con hash con
max-agelargo eimmutable. - Invalidación únicamente de HTML, feeds y sitemap.
Astro ya genera nombres versionados para sus recursos, por lo que una nueva versión puede convivir con la anterior sin que el navegador confunda los bundles. AWS explica esta combinación de TTL, versionado e invalidaciones en su guía de control de caché para contenido S3.
La capa gratuita: qué entra realmente
Aquí conviene ser preciso, porque AWS ofrece actualmente dos formas distintas de usar CloudFront sin coste y, además, el programa general para cuentas nuevas.
CloudFront de pago por uso con franquicia gratuita
En el modelo tradicional pay as you go, la capa gratuita de CloudFront incluye cada mes, para todos los clientes:
- 1 TB de transferencia desde CloudFront hacia Internet.
- 10 millones de peticiones HTTP o HTTPS.
- 2 millones de invocaciones de CloudFront Functions.
- Las primeras 1.000 rutas de invalidación sin coste.
La transferencia desde un origen AWS como S3 hacia CloudFront no tiene coste de transferencia. Para un blog personal estas cifras dejan un margen enorme, aunque superar los límites sí genera facturación según las tarifas normales.
El plan fijo CloudFront Free
AWS ofrece además planes de precio fijo para CloudFront. El nivel Free, de 0 dólares al mes, incluye una distribución, CDN, TLS, Route 53 DNS, WAF, protección DDoS, cómputo en el borde y créditos de almacenamiento S3. Su franquicia publicada es de 100 GB y un millón de peticiones al mes, y el plan no factura excesos por picos de tráfico. Aunque Route 53 forme parte del paquete, en esta arquitectura sigo usando Cloudflare para el DNS.
No hay que sumar los límites del plan fijo y los del Free Tier de pago por uso: son modelos diferentes. El primero ofrece una factura más predecible y protecciones agrupadas; el segundo concede una franquicia de tráfico bastante mayor y permite configurar cada servicio por separado. Para una distribución existente hay que comprobar qué modelo tiene asociado antes de hacer números.
S3, certificados y DNS
S3 sigue cobrando por almacenamiento y operaciones. AWS publica para cuentas elegibles una franquicia de 5 GB de S3 Standard, 20.000 GET y 2.000 PUT al mes, mientras que las cuentas nuevas del programa actual pueden recibir hasta 200 dólares en créditos durante el periodo gratuito. La elegibilidad depende de la fecha de alta y del tipo de cuenta, así que la fuente de verdad es la página Billing → Free Tier de cada cuenta.
Un blog como este ocupa muy poco y CloudFront absorbe la mayoría de las lecturas; fuera de promociones, S3 suele representar céntimos, pero no es correcto afirmar que sea siempre gratis.
Los certificados públicos no exportables de ACM usados directamente por CloudFront no tienen coste adicional. El dominio sí se paga en el registrador, pero la zona DNS está alojada en Cloudflare Free, cuyo plan de cero dólares incluye DNS autoritativo. Como no utilizo el proxy, Cloudflare no interviene en la caché ni en el tráfico HTTP del blog.
| Elemento | Puede quedar a cero | Posible coste |
|---|---|---|
| CloudFront | Franquicia mensual o plan Free | Exceso en pago por uso o cambio de plan |
| CloudFront Function | 2 millones de invocaciones en la franquicia tradicional | Invocaciones adicionales |
| Invalidaciones | 1.000 rutas mensuales | Rutas adicionales |
| Transferencia S3 → CloudFront | Sin coste de transferencia | — |
| S3 | Créditos o franquicia si aplica | Almacenamiento y peticiones |
| ACM | Certificado público integrado | Certificados exportables o Private CA |
| Cloudflare DNS | Plan Free | El registro del dominio se paga aparte |
«Capa gratuita» no significa «imposible recibir una factura». En el modelo de pago por uso configuro un AWS Budget, activo las alertas de Free Tier y reviso que el bucket continúe privado. Un presupuesto no corta el servicio automáticamente, pero avisa antes de que un error de permisos o un pico inesperado pase desapercibido.
Límites y mejoras pendientes
La arquitectura tiene pocas piezas y sigue sirviendo la última versión aunque falle el pipeline, pero no ofrece todas las garantías imaginables.
aws s3 sync no es atómico. Modifica objetos uno a uno. Durante unos segundos pueden convivir HTML nuevo y antiguo. Los recursos con hash reducen el riesgo; un sistema con requisitos estrictos publicaría bajo prefijos versionados y conmutaría el origen, o mantendría dos buckets.
La invalidación total sacrifica eficiencia por sencillez. Es suficiente con esta frecuencia de publicación, pero los encabezados de caché por tipo de fichero serían el siguiente ajuste.
La infraestructura se configuró desde la consola. El workflow automatiza el contenido, no la creación de S3, CloudFront, ACM, DNS e IAM. Llevar esas piezas a CloudFormation, CDK u OpenTofu permitiría revisar cambios y reconstruir el entorno sin depender de una lista de pasos.
OIDC queda para cuando el plan lo permita. La versión actual conserva credenciales estáticas porque el AWS Free Plan no incluye las Advanced Features necesarias para esta configuración. Cuando cambie de plan, OIDC reducirá el impacto de una filtración y evitará rotaciones manuales; es la mejora de seguridad más clara del pipeline.
Conclusiones
La migración empezó por un build de Gatsby que se había vuelto demasiado lento. Astro ha aligerado la pipeline de unos 8 minutos a menos de 2, pero la mejora arquitectónica más interesante ha sido separar por completo construcción y entrega:
- GitHub Actions y Docker existen solo mientras se genera una versión.
- S3 conserva el artefacto, pero permanece privado.
- OAC limita la lectura a una única distribución.
- Una función en el borde conserva las URLs limpias.
- CloudFront aporta caché global, HTTPS y la página de error correcta.
- La capa gratuita cubre holgadamente el tráfico normal del blog, sin dejar de vigilar costes.
Astro es la herramienta que genera el directorio, no el centro de la arquitectura. Si mañana volviese a cambiar de generador, mientras produjese el mismo contrato estático podría mantener intactos el bucket, la distribución, el dominio y el pipeline de publicación. Esa independencia es, para mí, la decisión más valiosa de toda la migración.
Un saludo y nos vamos leyendo!