Anatomía de esta web: Astro, S3 y CloudFront por casi cero euros
· 7 min de lectura
Esta web vivió años en un hosting PHP compartido, subida por FTP. Funcionaba, pero tenía todo lo que no quiero en un sistema: un servidor que mantener, una base de datos para servir texto que casi nunca cambia, y despliegues a mano.
Al rehacerla partí de una idea: una web de contenido no necesita servidor. La arquitectura más barata y más resiliente es la que no tiene nada que se pueda caer. Este post es el diagrama completo de cómo quedó, por qué cada pieza está ahí y cuánto cuesta.
El diagrama
┌──────────────┐
urimarti.com ──DNS──▶│ Route53 │──▶ alias A/AAAA
└──────────────┘ │
▼
Navegador ──HTTPS──▶ ┌────────────────────────────────────┐
│ CloudFront │
│ ├─ Certificado ACM (us-east-1) │
│ ├─ CloudFront Function (router) │
│ └─ Caché en el edge │
└────────────────────────────────────┘
│ OAC (firmado)
▼
┌────────────────────────────────────┐
│ S3 privado (eu-west-1) │
│ HTML + assets generados por Astro │
└────────────────────────────────────┘
git push main ──▶ GitHub Actions ──▶ astro build ──▶ cdk deploy
Seis piezas, cada una con un solo trabajo:
| Pieza | Trabajo |
|---|---|
| Astro | Generar HTML estático en el build. |
| S3 | Guardar los archivos. Nada más. |
| CloudFront | Servirlos con HTTPS, caché y desde cerca del usuario. |
| CloudFront Function | Reescribir y redirigir URLs en el edge. |
| Route53 + ACM | DNS y certificado. |
| CDK + GitHub Actions | Que todo lo anterior sea código y se despliegue solo. |
Astro: cero JavaScript por defecto
Para una web de contenido, Astro tiene la ventaja que más me importa: por defecto no envía JavaScript al navegador. Cada página es HTML y CSS. Lo poco interactivo que hay (el cambio de tema, el banner de cookies) son scripts pequeños e independientes.
Lo descartado fue Next.js. Lo uso a diario y es muy buena herramienta, pero aquí resolvería problemas que no tengo: renderizado en servidor, rutas de API, caché incremental. Con output: export también genera estáticos, pero arrastra un runtime de React que esta web no necesita.
El contenido son archivos Markdown en el repo con un esquema validado en el build. Si a un post le falta la descripción, el build falla. Los idiomas se resuelven también en el build: cada página es un único archivo que genera /about/ y /en/about/.
S3: un disco, no un servidor web
S3 tiene un modo “static website hosting”, pero obliga a hacer el bucket público y no sirve HTTPS. Lo descarté.
El bucket es privado: bloqueo de acceso público activado, cifrado y solo TLS. La única forma de leerlo es a través de CloudFront, que firma cada petición con Origin Access Control (OAC). Si alguien descubre el nombre del bucket, no puede hacer nada con él.
Un detalle de este diseño: con OAC, cuando un archivo no existe, S3 no responde 404 sino 403, porque desde fuera no permite distinguir “no existe” de “no tienes permiso”. CloudFront transforma tanto el 403 como el 404 en la página 404.html con estado 404.
CloudFront: donde pasa todo
CloudFront es el CDN de AWS: termina el HTTPS, cachea en sus servidores de todo el mundo y es la única puerta hacia S3. Fuerza HTTPS y usa la política de caché gestionada CachingOptimized.
La parte interesante es la caché, que tiene dos políticas según el tipo de archivo:
| Archivos | Cache-Control |
Por qué |
|---|---|---|
/_astro/* (CSS, JS, fuentes) |
max-age=31536000, immutable |
El nombre lleva un hash del contenido: si cambia, cambia el nombre. Se pueden cachear un año. |
| HTML y el resto | max-age=0, s-maxage=3600, must-revalidate |
El navegador siempre revalida; CloudFront lo guarda una hora. |
En cada deploy se invalida /*, así que el HTML nuevo se ve en segundos sin esperar a esa hora.
El router: una CloudFront Function de 15 líneas
Una web estática en S3 tiene un problema: /about/ no es un archivo, /about/index.html sí. Algo tiene que traducir una URL en la otra. Ese algo es una CloudFront Function, JavaScript que se ejecuta en el edge antes de mirar la caché:
function handler(event) {
var req = event.request;
var host = req.headers.host.value;
var uri = req.uri;
if (host !== 'urimarti.com') {
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: 'https://urimarti.com' + uri } } };
}
if (uri.endsWith('/')) {
req.uri = uri + 'index.html';
} else if (uri.split('/').pop().indexOf('.') === -1) {
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: uri + '/' } } };
}
return req;
}
Hace tres cosas: redirige www al dominio sin www, añade la barra final a las URLs que no la llevan, y reescribe /ruta/ a /ruta/index.html. Así cada página tiene una única URL canónica, lo que también importa para el SEO.
La alternativa era Lambda@Edge, que puede hacer mucho más (llamadas de red, leer el cuerpo de la petición). Pero tarda más en arrancar, se despliega más despacio y cuesta más. Para manipular cabeceras y URLs, las CloudFront Functions bastan y sobran.
Route53 y ACM: el dominio
El dominio sigue registrado donde estaba; solo sus nameservers apuntan a Route53. Los registros A y AAAA son alias a la distribución de CloudFront: no hay IPs que mantener, y el alias en la raíz del dominio funciona, cosa que un CNAME no permite.
El certificado HTTPS es de ACM, gratuito y con renovación automática. Hay una peculiaridad: CloudFront solo acepta certificados de us-east-1, aunque el resto de la infraestructura esté en Irlanda. Por eso el certificado va en su propio stack, en otra región. Se valida solo, porque CDK escribe el registro de validación en la zona de Route53.
Como el dominio no envía correo, la zona publica también un null MX, un SPF -all y un DMARC p=reject. Son tres registros que impiden que nadie envíe spam suplantando @urimarti.com.
Todo es código: CDK y GitHub Actions
Ningún recurso se ha creado a mano en la consola, salvo la zona DNS. Todo está en un único archivo de AWS CDK con tres stacks:
- Dns: registros de la zona.
- Cert: el certificado, en us-east-1.
- Site: bucket, CloudFront, la función, la subida de archivos y los alias DNS.
El deploy lo hace GitHub Actions en cada push a main: astro build y después cdk deploy. CDK sube los archivos al bucket, borra los que ya no existen e invalida la caché de CloudFront.
Las credenciales del pipeline son de un usuario IAM que solo puede asumir los roles que crea cdk bootstrap. No tiene permisos directos sobre nada. Si la clave se filtrara, el daño posible quedaría acotado a lo que CDK puede desplegar.
Cuánto cuesta
| Concepto | Coste mensual |
|---|---|
| Zona de Route53 | 0,50 $ |
| CloudFront | 0 $ (capa gratuita: 1 TB y 10 M de peticiones al mes) |
| CloudFront Functions | 0 $ (2 M de invocaciones gratis al mes) |
| S3 | Céntimos (unos pocos MB) |
| Certificado ACM | 0 $ |
| Invalidaciones | 0 $ (las primeras 1.000 rutas al mes son gratis; /* cuenta como una) |
| Total | < 1 $/mes |
Menos de lo que costaba el hosting PHP, y con un CDN global delante.
Lo que haría distinto a escala
Esta arquitectura es la adecuada para una web personal. Si fuera un producto con equipo y tráfico serio, cambiaría algunas cosas:
- OIDC en lugar de claves de acceso. GitHub Actions puede asumir un rol de AWS con tokens temporales sin guardar ninguna clave. Para un proyecto personal las claves acotadas son suficientes; en una empresa, no.
- Entornos de preview. Un stack por pull request para revisar cambios antes de que lleguen a producción.
- Cabeceras de seguridad (CSP, HSTS,
X-Content-Type-Options) con una response headers policy de CloudFront. Es lo siguiente que añadiré aquí. - Separar la infraestructura del contenido. Hoy cada push vuelve a evaluar toda la infra, aunque solo cambie un post. CDK no toca lo que no ha cambiado, pero el pipeline tarda más de lo necesario.
- Observabilidad. Logs de CloudFront y alarmas sobre la tasa de errores 4xx/5xx.
Lo importante no es la lista de servicios, sino la idea de fondo: cada pieza tiene un solo trabajo, y ninguna es un servidor que mantener. Cuando el contenido cambia poco, generarlo una vez y servirlo desde una caché no es una optimización: es la arquitectura correcta.