Este sitio es un puñado de archivos HTML. Astro lo construye y lo que sale es una carpeta dist con páginas, algo de CSS y unas pocas imágenes. Nada de eso necesita un servidor. Aun así, durante nueve meses lo serví desde mi cluster de Kubernetes.

Te quiero contar cómo funcionaba eso, por qué paré y qué lo reemplazó.

El montaje del que estaba orgulloso

El pipeline viejo estaba bien hecho, y lo digo en serio. Un Dockerfile multi-stage construía el sitio en un contenedor de Node y copiaba dist dentro de nginx:alpine. La imagen final pesaba unos 40 MB. Corría como el usuario nginx, escuchaba en el 8080 en vez del 80 y exponía /health y /ready para los probes. Pod security standard restringido, Kyverno sin quejas.

Publicar un post era así. Yo escribo en Obsidian. La carpeta src/content/blog del repo era un symlink hacia mi vault, entonces en local Astro leía los posts directo de Obsidian. Para publicar, un script llamado publish.sh cambiaba el symlink por archivos reales, los commiteaba, creaba un tag tipo 1.2.3 y hacía push. Gitea Actions tomaba el tag, construía la imagen, la subía a mi registry Nexus y después abría un commit contra mi repo de Flux actualizando el HelmRelease con el tag nuevo. Flux reconciliaba en unos quince minutos, o de una si yo corría flux reconcile a mano. Al final el script restauraba el symlink.

Cada post del blog era un release. Con número de versión. Para un archivo de texto.

Lo que realmente me costaba

Plata no. Atención.

Cada post pasaba por Docker, un registry, un Helm chart y un loop de reconciliación GitOps. Cuatro sistemas que podían tener un mal día cada uno. En febrero me gasté una tarde consolidando cinco jobs de CI en uno y agregando caché de capas de Docker, porque los builds iban lentos. En abril perseguí un bug donde un --build-arg vacío pisaba el default del Dockerfile para la URL del API de Spotify y el sitio caía en silencio a un endpoint que no existe. Eran problemas reales. También eran problemas que un sitio estático jamás debería tener.

Y estaba el problema callado. Cuando se iba la luz en la casa, o un nodo quedaba en cordon, o yo estaba actualizando Talos, mi sitio personal se caía con el cluster. El sitio que se supone que le dice a la gente que yo sé operar cosas.

Había construido un pipeline de despliegue muy bueno para la carga equivocada. El cluster era el punto, y el sitio estaba ahí para justificarlo. Eso es orgullo, no ingeniería.

La movida

El 7 de julio agregué un segundo workflow, release-to-s3.yaml, y desactivé el trigger por tag del de Docker. Seis días después cambié el workflow nuevo para que corra en cada push a main que toque src/, public/, la config de Astro o el lockfile. Sin tags. Sin números de versión. Push, y en más o menos un minuto el sitio está actualizado.

La infraestructura vive en su propio repo de Terraform. Un bucket S3 privado, una distribución de CloudFront que lee de él por Origin Access Control, un certificado ACM validado por DNS en Cloudflare y una CloudFront Function. Ojo con esa función, importa más de lo que parece. Astro genera URLs tipo directorio, así que /about en disco es realmente /about/index.html. S3 no sabe eso. Una función chiquita en JavaScript, en el edge, reescribe la URI antes de que llegue al bucket. El sitio hermano que le hice a la práctica de mi esposa no la necesitaba, porque ese son archivos .html planos. Este sí.

El deploy en sí son dos comandos de sync:

aws s3 sync dist/ s3://${S3_BUCKET} \
  --delete \
  --exclude "_astro/*" \
  --cache-control "public, max-age=3600" \
  --metadata-directive REPLACE

aws s3 sync dist/_astro/ s3://${S3_BUCKET}/_astro/ \
  --cache-control "public, max-age=31536000, immutable" \
  --metadata-directive REPLACE

El primero maneja el HTML y todo lo que no tiene fingerprint. Una hora de caché, y --delete para que las páginas borradas de verdad desaparezcan. El segundo maneja /_astro/, donde cada archivo lleva un hash del contenido en el nombre. A esos les doy un año e immutable, porque si el contenido cambia, el nombre también. Después de los dos syncs, una invalidación de CloudFront en /* y un mensaje a mi canal de Mattermost con el link del run y cuántos archivos se subieron y se borraron.

Una cosa más que aprendí la primera semana. Mis runners de Gitea están en la casa, y a veces las descargas del registry fallan. El run 3001 del deploy murió por una de esas. Me tocó envolver los pasos de instalación en un loop de tres reintentos y no he vuelto a pensar en eso.

Lo que conservé

El Dockerfile, la config de Nginx y el Helm chart siguen en el repo. El workflow viejo también, pero solo corre si yo lo disparo a mano con un número de versión. Si CloudFront algún día me da un motivo, puedo construir una imagen y apuntar el cluster a ella en pocos minutos. No espero hacerlo. Mantenerlo no cuesta nada.

También conservé la única cosa realmente dinámica de la página. La tarjeta de “Escuchando ahora” llama a una Lambda pequeña detrás de API Gateway que habla con Spotify usando un refresh token. La URL queda quemada en el JavaScript del cliente en tiempo de build, por la variable PUBLIC_SPOTIFY_API_URL. Un sitio estático con una función serverless sigue siendo un sitio estático. Esa parte nunca fue el problema.

Lo que te diría

Si tu sitio son archivos, hospédalo como archivos. Kubernetes es donde corro Gitea, Postgres, Mattermost y una docena de apps que necesitan ser procesos. Mi homepage no es un proceso. Sacarlo del cluster dejó el cluster más simple y el sitio más confiable, y fueron dos commits.

El pipeline viejo todavía funciona, y todavía me gusta. Solo que es para otro tipo de carga.