Saltar al contenido

Notas de ingeniería

Cómo está hecha esta web

fransn.dev es una web personal que gestiono como una pequeña plataforma en producción. El código es privado, así que esta página cuenta qué la hace funcionar, por qué la construí así y qué ha fallado.

Las cifras de tráfico en directo están en la página de métricas.

Datos comprobados contra el repositorio el 11 de octubre de 2026.

Arquitectura

Todo corre en un único servidor virtual de Hetzner con K3s, una distribución ligera de Kubernetes. Cloudflare solo responde al DNS, así que el tráfico va directo al servidor.

Arquitectura de fransn.devLas peticiones van del visitante a ingress-nginx, en un servidor K3s de un solo nodo, que las reparte entre cuatro servicios. Los despliegues van de GitHub Actions a GitHub Container Registry y por SSH al servidor. La lista numerada bajo el diagrama describe el mismo flujo en texto.Servidor Hetzner · K3s de un nodoconsultaHTTPSchatSSH + kubectlVisitantenavegadorCloudflare DNSsolo DNSingress-nginxTLS con cert-managerfsn-webNext.js 16 · páginasfsn-apiFastAPI · /apifsn-ingestFastAPI · /ingestfsn-aiFastAPI · /aiPostgreSQL 16posts, comentariosVolumen compartidoficheros ParquetAPI de OpenAIGitHub ActionsCI y CDGHCRimágenes
En azul, el camino de una petición. En gris, el despliegue y los flujos de datos.

El mismo diagrama en palabras

  1. Tu navegador pregunta a Cloudflare DNS por fransn.dev. Cloudflare devuelve la dirección del servidor y no interviene en el tráfico.

    Fuentes: [1]

  2. ingress-nginx, en el servidor, termina TLS con un certificado de Let's Encrypt que cert-manager renueva.

    Fuentes: [1][3]

  3. Reparte por ruta: las páginas van a fsn-web (Next.js), /api a fsn-api, /ingest a fsn-ingest y /ai a fsn-ai. Estos tres últimos son servicios FastAPI.

    Fuentes: [3]

  4. fsn-web genera las páginas en el servidor y lee los posts del blog y el perfil de fsn-api. Los posts, comentarios y suscriptores se guardan en PostgreSQL.

    Fuentes: [3]

  5. Si aceptas la analítica, tu navegador envía las visitas a fsn-ingest, que escribe ficheros Parquet en un volumen compartido. fsn-api los lee con DuckDB para la página de métricas.

    Fuentes: [8]

  6. La burbuja de chat habla con fsn-ai, que llama a la API de OpenAI con una persona construida a partir de los ficheros de mi perfil.

    Fuentes: [9]

  7. GitHub Actions construye las imágenes, las sube a GitHub Container Registry y despliega por SSH con kubectl. Después el servidor descarga las imágenes nuevas.

    Fuentes: [5]

Camino a producción

Todos los cambios siguen la misma ruta. Fusionar en main es desplegar, así que las comprobaciones van antes de la fusión.

  1. El trabajo empieza en una rama, normalmente a partir de una especificación o un plan escrito, y se sube como pull request.

    Fuentes: [12]

  2. La CI pasa el linter y los tests de los tres servicios Python. Para la app Next.js ejecuta ESLint, los tests unitarios y un build de producción, y después rastrea ese build como Googlebot para revisar las etiquetas SEO. También construye las cuatro imágenes Docker.

    Fuentes: [4]

  3. Con la CI en verde, la pull request se fusiona con squash, así que cada cambio es un único commit en main.

    Fuentes: [5]

  4. El CD reconstruye solo los servicios cuyos ficheros cambiaron, etiqueta cada imagen con el SHA del commit y la sube a GHCR. El despliegue solo empieza si todos los builds han pasado.

    Fuentes: [5]

  5. Por SSH aplica los manifiestos de Kubernetes y actualiza los servicios de uno en uno. Cada rollout arranca el pod nuevo antes de parar el antiguo y espera a que esté listo.

    Fuentes: [5][3]

  6. Un smoke test pide las páginas públicas y el endpoint de salud de cada servicio en fransn.dev. Si algo falla, el job muestra el estado de los pods y los últimos eventos del clúster.

    Fuentes: [6][5]

  7. Cuando el contenido de las páginas puede haber cambiado, el CD envía las URLs del sitemap a IndexNow para que Bing y otros buscadores las vuelvan a rastrear.

    Fuentes: [6][11]

Decisiones y compromisos

Cada decisión tiene un coste. Estas son las que explicaría primero en una revisión de diseño.

Un servidor en lugar de un clúster gestionado

Por qué
En febrero de 2026 la web pasó de Azure AKS a un único servidor de Hetzner con K3s. Según las notas de costes de febrero de 2026, sale por unos 4 € al mes frente a unos 56 € antes, y sigue siendo Kubernetes de verdad, con manifiestos, overlays de kustomize y network policies.
Coste
No hay alta disponibilidad. Si el servidor cae, cae toda la web.

Fuentes: [2][3]

Cloudflare solo para DNS

Por qué
La web estaba detrás del proxy de Cloudflare. Algunas redes móviles no llegaban al rango IPv6 que Cloudflare asignó a este dominio, y el plan gratuito no permite desactivar IPv6, así que pasé a un registro DNS normal.
Coste
Sin caché de CDN y sin la protección de Cloudflare delante del servidor.

Fuentes: [1]

Despliegues por SSH, sin controlador GitOps

Por qué
Argo CD o Flux correrían dentro del mismo servidor pequeño. Un workflow que ejecuta kubectl por SSH no necesita nada más en el clúster.
Coste
Nada vigila si el clúster se desvía del repositorio, y no hay rollback automático.

Fuentes: [2][5]

Migraciones en SQL plano en lugar de Alembic

Por qué
fsn-api ejecuta sentencias SQL idempotentes (CREATE TABLE IF NOT EXISTS y similares) en cada arranque. Para unas pocas tablas es fácil de leer y de revisar.
Coste
No hay migraciones hacia atrás ni historial de versiones. Durante un rollout el pod antiguo y el nuevo comparten base de datos, así que cada cambio de esquema tiene que funcionar con los dos.

Fuentes: [7]

Analítica propia, solo con consentimiento

Por qué
No hay rastreadores de terceros. Si aceptas, las visitas llegan a fsn-ingest como ficheros y fsn-api las consulta con DuckDB. Un job nocturno borra los datos de más de 90 días.
Coste
Quien rechaza no cuenta, así que la página de métricas se queda corta a propósito.

Fuentes: [8]

Un agente de chat con tope de gasto diario

Por qué
fsn-ai usa gpt-4o-mini con una persona construida a partir de los ficheros de mi perfil y suma lo que cuesta cada respuesta. Al llegar al tope diario, responde con un aviso amable hasta el día siguiente.
Coste
El contador de gasto vive en memoria, así que un reinicio lo pone a cero. Con un tope pequeño es aceptable.

Fuentes: [9]

Una Content Security Policy estricta con nonces

Por qué
Cada respuesta lleva un nonce nuevo y solo se ejecutan los scripts que lo llevan. Los informes de violaciones llegan a un endpoint pequeño de la app Next.js.
Coste
Las páginas se generan en cada petición, así que no hay caché de HTML estático. Los estilos siguen permitiendo CSS inline.

Fuentes: [10]

SEO como código, comprobado en la CI

Por qué
El sitemap, los enlaces canónicos, hreflang, las etiquetas Open Graph y el JSON-LD salen del código. La CI arranca la app compilada contra una API simulada, rastrea cada URL del sitemap como Googlebot y falla si falta una etiqueta o está mal.
Coste
El rastreo alarga cada ejecución de la CI y solo conoce las reglas que he escrito.

Fuentes: [11][4]

Especificaciones y agentes de IA, con revisión

Por qué
Las funcionalidades empiezan como una especificación o un plan escrito en el repositorio, con GitHub Spec Kit. Buena parte del código lo han escrito agentes de IA, primero GitHub Copilot y ahora Claude Code. Cada cambio pasa igualmente por una revisión aparte del diff, la CI y el smoke test tras desplegar.
Coste
La revisión puede dejar pasar lo que ningún test cubre. Para eso están el rastreo de la CI y el smoke test tras desplegar.

Fuentes: [12][4][6]

Cosas que se rompieron

Notas breves sobre problemas reales, qué los causó y qué cambió después.

La página de privacidad dio error durante siete meses

Qué pasó
Desde finales de febrero de 2026, /privacy fallaba con un error de servidor en los dos idiomas.
Causa
La página pasaba su enlace como etiqueta de texto enriquecido, pero la traducción seguía esperando un valor simple. La librería de i18n lanzaba una excepción al renderizar.
Arreglo
Lo encontró el nuevo rastreo SEO de la CI (PR #50). La traducción ya usa la etiqueta y un test unitario renderiza esa línea.
Qué cambió
El smoke test solo cubría algunas páginas. Ahora la CI rastrea todas las del sitemap.

Fuentes: [11]

El sitemap nunca listaba los posts

Qué pasó
Los buscadores veían las páginas estáticas, pero ningún post.
Causa
El sitemap se prerenderizaba durante el build de Docker, donde la API no es accesible, así que la lista de posts salía vacía siempre.
Arreglo
Ahora se genera en cada petición, con la lista de posts en caché cinco minutos (PR #50).
Qué cambió
Lo que depende de datos en vivo no debe quedar fijado en la imagen.

Fuentes: [11]

El nonce de la CSP llegaba vacío

Qué pasó
El script que aplica el tema de color se servía con un nonce vacío, así que el navegador lo bloqueaba en todas las páginas y el modo oscuro parpadeaba en claro al cargar. Tampoco llegaban los informes de violaciones de la CSP.
Causa
El middleware envolvía la respuesta del middleware de i18n en otra, que sobrescribía la lista de cabeceras reenviadas, y el nonce nunca llegaba a la página. Además, el endpoint de informes estaba bajo /api, que el ingress envía a fsn-api.
Arreglo
Ahora el nonce se pone en la petición antes de que corra el middleware de i18n, y los informes van a /csp-report (PR #49). Un test unitario comprueba que el nonce llega a la página.
Qué cambió
Una cabecera de seguridad puede fallar sin ningún error visible. Necesita un test y una mirada a la página en vivo tras desplegar.

Fuentes: [10]

El login de administración se rompió cuando GitHub cambió su respuesta OAuth

Qué pasó
Entrar al panel de administración con GitHub fallaba con un error de respuesta no válida.
Causa
GitHub empezó a añadir un parámetro iss al callback de OAuth (RFC 9207). La librería de autenticación lo compara con el emisor del proveedor, y el configurado no coincidía.
Arreglo
Un workflow manual que lee los errores de Auth.js en los logs de producción encontró la causa (PR #52). El arreglo declara el emisor real de GitHub (PR #53).
Qué cambió
El login queda fuera del smoke test y los proveedores de identidad cambian sus respuestas. Leer los errores de autenticación en producción ya está a una ejecución de workflow.

Fuentes: [13]

Límites conocidos

Cosas por las que he decidido no pagar todavía, o que aún no he arreglado.

  • Un servidor y sin alta disponibilidad. El mantenimiento de ese servidor deja la web sin servicio.

    Fuentes: [2]

  • La base de datos se copia automáticamente, pero las copias se quedan en el mismo servidor.

    Fuentes: [3]

  • El CD solo compara el último commit para decidir qué reconstruir. Los merges con squash lo hacen seguro, pero un push con varios commits podría saltarse un servicio.

    Fuentes: [5]

  • El chequeo de tipos de los servicios Python y las auditorías de dependencias de la CI avisan de los problemas sin hacer fallar el build.

    Fuentes: [4]

¿Quieres los detalles?

El repositorio es privado. Te enseño encantado el código, el pipeline o cualquiera de estas decisiones. Escríbeme.

Fuentes

Cada afirmación de esta página corresponde a un fichero o pull request del repositorio privado.

  1. docs/architecture/network.md
  2. docs/lessons-learned-k3s-hetzner-cloudflare.md (sections 1, 14 and 16)
  3. infra/k8s/base and infra/k8s/overlays/production
  4. .github/workflows/ci.yaml
  5. .github/workflows/cd.yaml, PR #40
  6. scripts/smoke-prod.sh, scripts/indexnow-submit.sh
  7. services/fsn-api/src/core/migrations.py
  8. services/fsn-ingest, services/fsn-api/src/services/metrics_summary.py, services/fsn-web/src/lib/telemetry.ts, infra/k8s/base/cleanup, PR #43
  9. services/fsn-ai/src/core/config.py, services/fsn-ai/src/services/spend_tracker.py
  10. services/fsn-web/src/lib/csp.ts, services/fsn-web/middleware.ts, PR #34, PR #49
  11. scripts/seo-check.mjs, services/fsn-web/src/app/sitemap.ts, PR #50
  12. specs/ (21 spec folders), .specify/, commit co-author trailers
  13. PR #52 (auth log workflow), PR #53 (RFC 9207 issuer fix)