WordPress sigue siendo, para redactar, la interfaz más cómoda que existe: editor visual, revisiones, programación de publicaciones, subida de imágenes con un arrastre. Pero servir ese mismo WordPress en producción implica PHP, una base de datos, caché, parches de seguridad constantes y una superficie de ataque que crece con cada plugin. Hugo no tiene nada de eso: compila a HTML plano y lo sirve una CDN, sin runtime que explotar. El problema de siempre es que Hugo es incómodo para redactar en Markdown desde el celular a las 23 h.
La arquitectura que armé resuelve esa tensión sin resignar ninguno de los dos lados: WordPress queda como panel de redacción puro, nunca sirve tráfico público, y Hugo genera el sitio real que la gente visita. Lo que sigue es cómo queda conectado el circuito.
El flujo, de punta a punta
- WordPress en la VPS hace de gestor de contenidos. No es el sitio público, es solo el backend de escritura.
- Un script en Python lee las entradas (vía API REST de WP o directo contra la base) y las convierte a archivos
.mdcon el front matter que Hugo espera. - Git versiona esos cambios y los empuja a un repositorio remoto.
- Cloudflare Pages detecta el push a
main, correhugo --gc --minifyy distribuye el resultado por su CDN.
Ninguna pieza es exótica por separado. Lo que hay que cuidar es la unión entre ellas, porque ahí es donde aparecen los problemas reales: hooks que disparan dos veces, permisos de www-data, y una variable de entorno de Cloudflare que a veces se comporta de forma menos predecible de lo que documenta.
Paso 1: WordPress → Markdown
El script scripts/sync_wp.py corre en la propia VPS y arma, para cada entrada, un archivo con esta forma:
---
title: "Título de la entrada"
date: 2026-08-25T00:00:00Z
draft: false
---
Contenido convertido a Markdown...
Nada nuevo acá salvo un detalle que conviene resolver bien desde el arranque: el HTML que devuelve WordPress (con <p>, listas, imágenes con <figure>) no es Markdown, y una conversión ingenua con expresiones regulares se rompe apenas aparece un embed o una tabla. Conviene usar una librería de conversión HTML→Markdown real (Pandoc o algo equivalente en Python) en vez de reinventar el parser.
Paso 2: disparar la sincronización desde WordPress
Para no depender de entrar por SSH después de cada publicación, el gatillo vive en el functions.php del tema activo:
add_action('publish_post', 'disparar_sincronizacion_hugo', 10, 2);
add_action('post_updated', 'disparar_sincronizacion_hugo', 10, 2);
add_action('trashed_post', 'disparar_sincronizacion_hugo');
add_action('before_delete_post', 'disparar_sincronizacion_hugo');
function disparar_sincronizacion_hugo($post_id, $post = null) {
if (wp_is_post_revision($post_id)) {
return;
}
$ruta_hugo = '/var/www/diario.dagorret.com.ar';
$cmd = 'export GIT_SSH_COMMAND="ssh -i ' . $ruta_hugo . '/.ssh/id_ed25519 -o StrictHostKeyChecking=no"; ';
$cmd .= 'cd ' . $ruta_hugo . ' && ';
$cmd .= 'python3 scripts/sync_wp.py && ';
$cmd .= 'git add . && ';
$cmd .= 'git commit -m "auto-sync desde WP [id: ' . $post_id . ']" && ';
$cmd .= 'git push origin main';
exec($cmd . ' > /dev/null 2>&1 &');
}
Hay un problema concreto con este par de hooks que conviene anticipar: post_updated no se limita a las ediciones posteriores a la publicación, también se dispara en la misma transición en la que se dispara publish_post, porque WordPress trata la primera publicación como una actualización del post que ya existía en estado borrador. En la práctica esto significa que al publicar una entrada nueva el script corre dos veces seguidas, y el segundo git push no tiene nada nuevo que subir y simplemente falla en silencio dentro del &&. No rompe nada, pero es ruido innecesario y un exec() de más corriendo en background. La documentación oficial de hooks de WordPress lo deja bastante claro al describir cuándo se dispara cada acción (WordPress Developer Resources); si el volumen de publicaciones lo justifica, conviene reemplazar este par por un único hook save_post con un chequeo del post_status, en vez de sumar acciones para cada transición.
Dos cuidados más, estos de infraestructura y no de WordPress:
StrictHostKeyChecking=noevita que el primerpushse cuelgue esperando confirmación del fingerprint del host, pero también elimina la verificación de que el repositorio remoto es efectivamente el que creés que es. Si el entorno lo permite, es preferible precargar elknown_hostsuna vez de forma manual y sacar esa flag.exec()con interpolación de$post_ides en general seguro acá porque WordPress entrega ese valor como entero desde el propio core, no desde input de usuario sin sanitizar. Aun así, si en algún momento se toca esta función para aceptar cualquier otro dato externo, hay que sanitizarlo antes de que llegue a unexec()— es la puerta más directa a una inyección de comandos que existe en PHP.
Paso 3: Cloudflare Pages
La configuración del lado de Cloudflare es la parte más simple, con una salvedad:
- Comando de build:
hugo --gc --minify - Directorio de salida:
public - Variable de entorno:
HUGO_VERSION, fijada a la versión exacta con la que se probó el sitio localmente (por ejemplo0.128.0).
Esa última variable es más importante de lo que parece. Cloudflare Pages, si no se fija explícitamente, instala una versión de Hugo bastante vieja por defecto, lo que produce el clásico “funciona en mi máquina, falla en el build” en cuanto el tema usa alguna función más reciente (documentación oficial de Cloudflare Pages para Hugo). Y hay reportes recurrentes en la comunidad de que la variable definida desde el dashboard a veces no se aplica de forma consistente entre builds de producción y de preview, sobre todo si se pegó el valor con algún carácter invisible de por medio (Cloudflare Community). La alternativa más confiable, si el dashboard da problemas, es fijar HUGO_VERSION dentro de un wrangler.toml versionado en el propio repositorio, que varios devs reportan como más estable que la UI.
Consideraciones que conviene resolver antes, no después de un incidente
Permisos y llaves SSH. El usuario www-data necesita lectura y escritura sobre el repositorio y ser dueño de las llaves con las que hace push. Eso también significa que si alguna vez se compromete el proceso de WordPress —una vulnerabilidad de plugin, por ejemplo— quien ataque hereda la capacidad de escribir en el repositorio del sitio. Vale la pena que esa llave SSH tenga permisos acotados del lado de GitHub (deploy key restringida a ese único repo, sin acceso de escritura a otros) en vez de una llave de cuenta con alcance general.
Temas de Hugo sin submódulos. Si el tema se resuelve como submódulo de Git y el entorno de build no lo inicializa correctamente, el build falla o, peor, se cuelga hasta el timeout. Vender el tema como carpeta de archivos planos dentro de themes/ saca esa variable de la ecuación y hace el build reproducible sin pasos extra de git submodule update --init.
Condición de carrera entre publicaciones seguidas. El exec() corre en background y no espera a que termine el push anterior. Si alguien publica dos entradas con segundos de diferencia, es perfectamente posible que dos instancias del script corran en simultáneo sobre el mismo working directory y que uno de los dos git add . capture un estado a medio escribir del otro. Para un blog personal con cadencia baja de publicación el riesgo es bajo, pero si el volumen crece conviene poner una cola simple (un lock file, o directamente un cron que procese cambios pendientes cada un par de minutos en lugar de disparar por cada hook) en vez de confiar en que las publicaciones nunca se solapen.
El resultado
Con esto en marcha, redactar sigue siendo tan simple como siempre lo fue en WordPress, pero lo que el visitante recibe es HTML estático servido desde el borde de Cloudflare, sin PHP ni base de datos expuestos al tráfico público. El costo es la complejidad adicional del pipeline — y, como con cualquier automatización que corre sola en background, la necesidad de loguear lo suficiente como para poder reconstruir qué pasó cuando algo, eventualmente, no sincronice.