Este blog ya funciona con Hugo: sin WordPress, sin Kubernetes y con HTTP/3 estable

Este blog ya no funciona con WordPress y K3s, sino como sitio estático en AlmaLinux 10. Qué cambió, qué falló en las pruebas y por qué HTTP/3 vuelve a estar activo.

Browser showing apt-upgrade.me, with the WordPress logo in a wastebasket marked decommissioned, a Migration Completed arrow, the Hugo logo and a Live badge

🌐 También en: English · Deutsch · Français

En resumen: este blog ya no funciona con WordPress y Kubernetes. Ahora se compone de archivos terminados que nginx sirve desde un AlmaLinux 10 reforzado. En el experimento con Hugo de hace unos días aún no había nada decidido; el 6 de noviembre de 2026 hice el cambio. Todas las entradas en cuatro idiomas conservan sus direcciones, Grafana y el refuerzo de seguridad se han quedado, PHP, la base de datos y el clúster han desaparecido. Y HTTP/3, que desactivé esta misma semana, vuelve a funcionar.

Esta entrada describe qué ha cambiado, qué falló durante las pruebas, cómo ha evolucionado el rendimiento y qué significa el cambio para mi día a día y mis copias de seguridad.

Índice

🧒 Para quien no es de IT: ¿qué es diferente?

Hasta ahora, un programa montaba cada página a partir de una base de datos cuando alguien la pedía, y una caché guardaba el resultado durante un tiempo. Ahora construyo todas las páginas una vez por adelantado en mi propio ordenador y las subo terminadas. El servidor solo entrega archivos. No se ejecuta ningún programa para los visitantes, no hay inicio de sesión y no hay nada que un atacante pueda ejecutar con una petición manipulada.

Lo que queda: El sitio se ve igual. Lo que ha cambiado es lo que funciona por detrás, y hoy es mucho menos.

🔄 Qué ha cambiado

AntesAhora
Sistema operativoAlmaLinux 9 con K3sAlmaLinux 10 sin Kubernetes
Servidor webingress-nginx en el clústernginx mainline 1.31.8 directamente en el host
ContenidoMariaDB y carpeta de mediosArchivos en un repositorio Git privado
Ejecución para visitantesPHP-FPM, Redis, caché de páginasninguna
Inicio de sesiónAdmin de WordPress con 2FAsolo Grafana, publicación con clave SSH
BúsquedaWordPressPagefind, se ejecuta en el navegador
HTTP/3desactivadoactivado
MonitorizaciónPrometheus, Grafana, Loki, Monitigual, sin las métricas del clúster

También he reorganizado el cortafuegos. El reenvío está totalmente desactivado, porque sin red de pods el host no tiene nada que enrutar, y rp_filter vuelve a ser estricto. Todo lo que existía solo por K3s ha desaparecido: cadenas NodePort, excepciones para pods, reglas de auditoría del clúster. fail2ban ya no tiene regla para el inicio de sesión de WordPress; las peticiones a /wp-login.php cuentan ahora como intento de ataque. nginx ya no puede abrir conexiones salientes, salvo hacia Grafana en loopback.

Lo que queda: Una reconstrucción es la rara ocasión de borrar reglas de verdad. Muchas seguían ahí solo porque alguna vez hicieron falta.

📦 La nueva estructura de repositorios

Un repositorio se ha convertido en tres:

  • www_hugo (público): Ansible para el servidor del blog. Una sola línea de ansible-playbook reconstruye la máquina desde cero.
  • www_hugo_content (privado): entradas, imágenes, tema, scripts de compilación y despliegue.
  • nextcloud_k3s (público): la parte de Nextcloud sigue allí, sin cambios, en K3s.
  • www_k3s: el repositorio antiguo está archivado y queda como referencia.

El contenido es privado porque Git no olvida nada. Una imagen que retirara más adelante por motivos legales o personales seguiría accesible para siempre en el historial de un repositorio público.

En la entrada del experimento advertí contra los roles copiados, porque cada corrección de seguridad hay que hacerla dos veces. Ese sigue siendo el precio de esta solución. Frente a ello está ahora scripts/compare-roles.sh, que muestra las diferencias entre los roles comunes. La separación también ahorra trabajo: el repositorio del blog se ha reducido en unas 34.800 líneas.

Lo que queda: Un repositorio separado no lo resuelve todo. Cambia la regla «cada cambio debe servir para ambas pilas» por la obligación de trasladar las lecciones de forma activa.

✍️ Entradas en un repositorio en lugar de en una base de datos

Una entrada es ahora un archivo Markdown. Mi día a día queda así:

# Escribir la entrada, verla en local y luego:
tools/build.sh                          # construye todas las páginas y el índice de búsqueda
tools/deploy.sh <host>                  # subida y cambio atómico
tools/deploy.sh <host> 10022 rollback   # si algo no está bien

Un nuevo despliegue tardó 18 segundos en las pruebas. El cambio en sí es solo un enlace simbólico que se mueve, y las últimas cinco versiones se quedan en el servidor. La clave solo puede subir, activar y revertir. El servidor rechaza todo lo demás, y lo he comprobado.

También hay desventajas reales, y no quiero maquillarlas:

  • Ya no se puede escribir en el navegador ni en el móvil. Las entradas nacen en mi ordenador.
  • No hay biblioteca de medios ni optimización automática de imágenes con un plugin. Las imágenes pasan por el repositorio y la compilación.
  • Las entradas programadas no aparecen solas; para eso haría falta un temporizador propio.
  • Sin avisos de Yoast. La plantilla genera ahora las etiquetas para buscadores en cada compilación, pero nadie me avisa si falta una descripción.

Lo que queda: Cambio comodidad al escribir por sencillez de operación. Para un blog de una sola persona encaja; para una redacción sería la decisión equivocada.

🚀 Rendimiento: lo antiguo frente a lo nuevo

Medido con Chrome, mediana de siete pasadas cada vez. Los valores antiguos proceden del blog en producción, los nuevos de la máquina de pruebas, sin visitantes y con un certificado autofirmado. Así que aún no es una comparación final justa.

MediciónWordPressHugo
Móvil, entrada: primer contenido visible2,54 s0,71 s
Móvil, portada: primer contenido visible2,54 s0,59 s
Escritorio, entrada: carga completa451 ms140 ms
Entrada: peticiones4213
Entrada: datos455 KB235 KB

Justo después del cambio medí en el servidor real, con certificado real y desde fuera:

Medición en el servidor realWordPressHugo, HTTP/2Hugo, HTTP/3
Móvil, entrada: primer contenido visible2,54 s0,80 s0,75 s
Móvil, portada: primer contenido visible2,54 s0,64 s0,58 s
Escritorio, entrada: carga completa451 ms386 ms315 ms
Escritorio, entrada: datos455 KB178 KB178 KB

El móvil gana con claridad; en escritorio la diferencia es menor que en la máquina de pruebas. Todavía no es un valor duradero: el servidor estaba recién instalado y sin carga normal.

La primera medición también destapó un fallo de mi nuevo tema: el carrusel cargaba cinco imágenes a tamaño completo a la vez, incluso en el móvil, donde está oculto, y la imagen realmente visible llegaba la última. Tras la corrección, el elemento más grande de la portada aparece en el móvil a los 1,29 segundos en lugar de 2,25, y los datos bajan de 545 a 292 KB en el móvil y de 1,22 MB a 606 KB en escritorio. La tabla de arriba muestra el estado posterior.

Y la vista desde otros continentes: tiempo total de la misma entrada por HTTP/2, mediana de cinco sondas de Globalping en cada caso, una sola pasada. No hay un valor de antes, porque nunca medí así con WordPress. Por eso no puedo decir si allí ha mejorado.

RegiónTiempo total
Europa0,26 s
Norteamérica0,56 s
Asia1,02 s
Sudamérica1,06 s
África1,15 s
Oceanía1,72 s

El servidor está en Alemania, y los archivos estáticos no cambian la distancia. Ir más allá requeriría un CDN, que a propósito no uso.

Lo que queda: La mayor ganancia no viene de un hardware más rápido, sino de necesitar menos archivos y ninguna ejecución de programa.

🎢 HTTP/3: apagado esta semana, encendido ahora

Hace unos días expliqué en una entrada larga por qué HTTP/3 seguía apagado aquí: tras una recarga de nginx, un worker mantenido vivo por una sola conexión larga conserva los sockets UDP compartidos, y aproximadamente una de cada ocho conexiones QUIC se queda colgada. La solución de upstream se llamaba quic_bpf, y yo daba el asunto por cerrado, incluso con cierta dignidad.

Luego llegó la mudanza y, sin controlador de ingress, por primera vez pude elegir libremente la versión de nginx. Así que un intento más, empezando por prudencia con la versión tranquila y probada:

nginx con quic_bpfConexiones colgadas durante recargas
1.26sin mejora
1.30.640 de 1.000
1.31.8 (mainline)0 de 2.370

El camino probado era, pues, el equivocado. La solución necesitaba mainline, además de las capacidades CAP_BPF, CAP_PERFMON y CAP_NET_ADMIN para un servicio que por lo demás restrinjo todo lo posible, y un módulo SELinux propio para que el programa BPF pudiera siquiera cargarse. A eso se suma worker_shutdown_timeout 30s, para que un worker antiguo no se quede para siempre.

Al final quise verlo con un Chrome de verdad. Resultado de la primera pasada: 300 de 300 páginas fallidas, cada una en una fracción de segundo. Un breve susto y luego la explicación: para QUIC, Chrome no acepta un certificado autofirmado ni con la opción general de ignorar, así que el fallo estaba en mi montaje de pruebas. Con el hash de la clave configurado y bajo fuego continuo, una recarga cada cinco segundos más una descarga abierta, 298 de 300 páginas llegaron por HTTP/3, con una mediana de 236 ms y 1,3 s la más lenta. Sin recargas fueron 300 de 300. Ninguna página se colgó en ninguna de las dos pasadas. Los dos cortes vienen probablemente de la recarga, y eso es raro en el día a día: un despliegue no provoca ninguna.

Lo que queda: Un hallazgo es una afirmación sobre una versión. «No funciona» era cierto esta semana y ya no lo es. Y cuando una prueba falla el 100 % de las veces, primero hay que revisar la prueba.

🔧 Qué encontró la prueba desde cero

Antes de borrar el servidor real, hice reinstalar la máquina de pruebas y ejecuté el playbook desde cero. Mereció la pena de inmediato, porque estos errores me habrían pillado durante la migración real:

  • El reinicio que no lo fue: la primera pasada reinicia el servidor para que el puerto SSH y el cortafuegos se activen a la vez. El comando llevaba async: 1 y empezaba con sleep 2. El actual ansible-core 2.21 termina la tarea tras un segundo y aun así informa changed. El playbook esperó cinco minutos un puerto que nunca se abrió.
  • El archivo PID vacío: la comprobación nginx -t crea un /run/nginx.pid vacío cuando nginx aún no funciona, con el tipo SELinux var_run_t. Después, el servicio en httpd_t no puede abrir el archivo y no arranca. Ocurre en cada primera instalación.
  • Un fail2ban que aún no existía: el handler del cortafuegos reinicia fail2ban, pero en una máquina nueva todavía no está instalado en ese momento. Solo se vio en la segunda pasada real desde cero; antes, el cortafuegos ya estaba y lo ocultaba.
  • Lo que WordPress hacía de paso: la comparación con el blog en producción mostró que faltaban robots.txt, las antiguas direcciones del sitemap y twitter:card. Todo eso lo aportaba Yoast.
  • Bloqueado por mi propia seguridad: el dominio está en la lista de precarga HSTS. Por eso mi Firefox no me dejaba abrir el sitio de pruebas autofirmado, ni siquiera tras borrar todos los datos. La regla viene integrada en el navegador.
  • La máquina de pruebas misteriosamente lenta: con la misma configuración que el servidor del blog, al principio iba muchas veces más lenta. Tras la siguiente reinstalación la diferencia desapareció, y la pasada desde cero tardó unos 12 minutos en lugar de más de 30. No conozco la causa; sospecho de la máquina anfitriona. No fue el playbook, porque medí cada minuto durante la pasada.

Tras las correcciones, la pasada de control mostró changed=0.

Lo que queda: Una pasada en una máquina existente demuestra poco. La pasada desde cero es la verdadera prueba, y debe hacerse antes del momento real.

🚧 Qué falló durante la migración real

A pesar de todas las pruebas, el momento real no fue limpio, y eso debe constar aquí. El blog no estuvo disponible el 6 de noviembre desde las 18:10 hasta las 19:52 aproximadamente, mucho más que la media hora prevista.

  • El directorio que faltaba: Let’s Encrypt no se podía probar en la máquina de pruebas, así que ese paso se ejecutó por primera vez en el servidor real. El directorio del hook solo aparece con la primera ejecución de certbot; el playbook quiso escribir antes y se detuvo. Se corrigió rápido. La pasada continuó después en el puerto 10022 y obtuvo enseguida un certificado válido.
  • Bloqueado por mí mismo: quería conservar las antiguas claves de host SSH para que otras máquinas siguieran reconociendo el servidor. Tenían el modo 640 con el grupo ssh_keys, y el grupo se perdió en la copia. sshd rechaza esas claves y no arrancó. Había reiniciado sin sshd -t, y mi único acceso desapareció. La salvación fue un chmod 600 desde la consola del proveedor. La máquina de pruebas no lo mostró porque sus claves ya tenían 600.
  • Gráfico de Pressure vacío: psi=1 solo surte efecto tras un reinicio, y en AlmaLinux 10 /proc/pressure tiene su propio tipo SELinux proc_psi_t, que mi módulo para node_exporter no conocía.
  • Sin swap: simplemente no lo tuve en cuenta durante la reconstrucción. Sin K3s nada lo impide, y ahora hay un pequeño archivo de swap.

Lo positivo: las 592 direcciones antiguas de entradas y páginas siguen respondiendo, la suma de comprobación del sitio coincide, y una trampa del sitemap apareció a tiempo: contenía páginas de etiquetas con noindex.

Lo que queda: una prueba solo demuestra lo que toca. Antes de reiniciar un servicio del que depende tu propio acceso, ejecuta siempre su comprobación de configuración.

🛡️ ¿Es esto inofensivo?

La superficie de ataque se ha reducido mucho. Ya no hay PHP, plugins, base de datos ni inicio de sesión de WordPress. En el servidor solo queda el inicio de sesión de Grafana, protegido por un filtro de países, una autenticación previa y fail2ban. Desaparecen las actualizaciones de seguridad periódicas de WordPress, plugins y clúster. Pero el riesgo no se ha esfumado, se ha desplazado:

  • Cuenta de GitHub: ahora guarda todo el contenido. Solo sustituye a la 2FA de WordPress si está bien protegida.
  • Máquina de compilación: quien la controle puede cambiar el sitio con la clave de despliegue, pero no el servidor.
  • Certificado: por la precarga HSTS, ningún visitante puede saltarse un aviso de certificado. Si la renovación falla, el blog deja de estar accesible al instante. La monitorización debe avisar con tiempo.
  • HTTP/3 en mainline: uso a sabiendas una línea de desarrollo con permisos ampliados. A cambio, una sola variable vuelve a apagar HTTP/3.
  • La propia mudanza: la única vuelta atrás era la copia completa. Por eso la comprobé antes y guardé los certificados por separado.

Lo que queda: Los sitios estáticos no son seguros por sí solos. El riesgo pasa del servidor a las cuentas, las claves y los certificados.

💾 Copias de seguridad: menos datos, otras preguntas

Antes tenía que guardar sobre todo la base de datos y los medios, o el blog se perdía. Ahora el sitio web es un derivado: se puede reconstruir en cualquier momento desde el repositorio de contenido. Lo mismo vale para el servidor, que construye el playbook. Lo importante es lo que no se puede recrear:

  • Repositorio de contenido: la fuente real. Está en GitHub y además en la copia del homelab.
  • Certificados: para que una reconstrucción no dependa de la próxima solicitud correcta.
  • Claves de host SSH: si no, cada conexión da la alarma tras una restauración.
  • Estadísticas de visitas: no se pueden regenerar a partir de nada más.
  • Contraseña del vault: sin ella el playbook sirve de poco en una reconstrucción, así que hay que guardarla aparte.

Aquí la prueba también mereció la pena. Sin rsync -H, mi nueva copia habría multiplicado las versiones unidas por enlaces duros, y rdiff-backup también habría versionado copias interrumpidas, registrando archivos como borrados. Ambas cosas están corregidas. En una máquina de pruebas recién instalada, la restauración tardó después 16 segundos, la suma de comprobación de los 18.837 archivos coincidió con la compilación y las claves de host SSH volvieron a ser las antiguas. La antigua copia de WordPress con el volcado de la base de datos se conserva por separado.

Lo que queda: Una copia solo cuenta cuando la restauración se ha ejecutado de verdad. Que casi todo se pueda reconstruir la hace más sencilla, no innecesaria.

🎯 Las lecciones

  1. Primero medir, luego creer. Vale para «Hugo aporta poco a los lectores» tanto como para «HTTP/3 no funciona».
  2. La prueba desde cero encuentra lo que oculta cualquier pasada en una máquina con historia.
  3. Un plugin como Yoast hace más de lo que se nota. Antes de apagarlo hay que comparar su salida línea por línea.
  4. Quien retira un componente debe retirar también sus excepciones: cortafuegos, reglas de auditoría, alertas.
  5. La seguridad se desplaza, no desaparece. Tras la reconstrucción, lo que cuenta es quién tiene la clave y la cuenta.
  6. Siempre sshd -t antes de reiniciar sshd, y tener listo un segundo acceso.

Añadiré tras unos días de operación cómo funciona en el día a día y con carga normal.

Parte de los textos e imágenes de este sitio está asistida o generada por IA. Detalles