Un blog que se publica solo

Primer plano de una pantalla de ordenador con un archivo de configuración de despliegue abierto en el editor: se leen las palabras «jobs», «deploy», «docker» y «steps», y las líneas del fondo salen desenfocadas.
Ferenc AlmasiUnsplash License

En Sobre este sitio conté por qué elegí lo que elegí. Esto es el cómo: qué pasa exactamente desde que escribo una frase en mi ordenador hasta que esa frase está en internet.

Por el camino hay un contenedor de Docker, GitHub Actions construyendo el sitio en una máquina virtual que se destruye al terminar, y dos repositorios de GitHub. Ninguna de las tres piezas es obvia y las tres tienen alternativa, así que voy contando por qué está puesta cada una.

Y es el primero de cuatro artículos sobre este sitio: los tres siguientes van del CSS, de los dos idiomas y de la única sección que no se construye con el resto.

El recorrido completo

He organizado cada artículo como un directorio donde se guarda el artículo como index.md, junto con sus imágenes y adjuntos:

src/es/articulos/
├── sobre-este-sitio/
│   ├── index.md
│   └── lapiz-sobre-libreta.jpg
└── blog-que-se-publica-solo/
    └── index.md

La alternativa era un archivo suelto por artículo y un directorio de imágenes común para todo el sitio. Se descartó por cómo quedan los enlaces: con el directorio común hay que escribir rutas absolutas —/img/diagrama.png— y revisarlas cada vez que un artículo se mueve o se renombra; con la imagen al lado, el Markdown dice ![](diagrama.png) y mover el directorio entero no rompe nada. Tampoco obliga a inventar nombres únicos para todo el sitio: los distingue la ruta, así que dos artículos pueden tener cada uno su portada.jpg. El precio es duplicar algún archivo entre la versión española y la inglesa, y se asume a cambio de esa simplicidad.

Que el artículo sea un directorio y no un archivo suelto sí es un requisito, y viene de la forma de las URLs. Por defecto —y así lo documenta 11ty en Permalinks— cada plantilla se escribe como …/sobre-este-sitio/index.html y se sirve con barra final: /es/articulos/sobre-este-sitio/. El navegador resuelve las rutas relativas contra ese directorio, y eso es lo que hace que ![](foto.jpg) encuentre la foto que está al lado del index.md.

El nombre index.md de dentro, en cambio, es convención y no obligación: 11ty trata sobre-este-sitio/sobre-este-sitio.md exactamente igual que sobre-este-sitio/index.md, y las dos acaban en la misma URL. Se usa index.md porque así el nombre del artículo se escribe una sola vez —en el directorio— y renombrarlo es renombrar una cosa en vez de dos.

Los directorios son planos, sin agrupar por año: el año en la ruta cambiaría la URL de todo lo ya publicado. Y el desorden que evitaría agruparlos es el del sistema de archivos, por el que en realidad no se navega — al artículo se llega por el índice del sitio.

Las imágenes tampoco hay que prepararlas antes de meterlas ahí: el plugin de imágenes de 11ty las redimensiona y las convierte de formato durante el build, así que se arrastra la foto tal y como se pone en el directorio del artículo. Qué genera exactamente y qué acaba descargando cada pantalla da para su propio artículo.

Esta combinación —las imágenes junto al artículo y el plugin encargándose del resto— tiene precedente, y el precedente cuenta bien lo que ha cambiado por el camino. Graham F. Scott defendió la misma estructura en Using the Eleventy Image plugin without a central image folder, y en 2022 sostenerla costaba trabajo: el plugin daba por hecho el directorio central, así que tuvo que montarse un shortcode propio para esquivarla. En 2024 le añadió un aviso arriba del todo diciendo que ya no hace falta nada de eso — las rutas relativas funcionan solas y basta con el Markdown de siempre. Ese «ya no hace falta» es la configuración que usa este sitio.

Una vez terminado el artículo, abro un Pull Request contra main y lo fusiono. Ese merge es el único disparador de lo que viene después: a partir de ahí todo el proceso está automatizado.

Aquí GitHub Actions toma el control. El workflow vive en .github/workflows/deploy.yml y se dispara así:

on:
  push:
    branches: [main]
  workflow_dispatch:

El merge del PR produce un push a main, y ese push lanza el despliegue. workflow_dispatch añade el disparo manual desde la pestaña Actions, para volver a desplegar sin ningún cambio de por medio.

Ahí está el detalle que más peso acaba teniendo: cada ejecución arranca en un runner limpio, que no conserva nada de la vez anterior y se destruye al terminar. No hay estado que se ensucie con el tiempo, y tampoco hay nada que reaprovechar: cada despliegue construye desde cero.

Resumiendo: los pasos son cuatro en mi caso:

  1. actions/checkout clona el repositorio en el runner.
  2. actions/setup-node instala Node.
  3. npm install y npm run buildeleventy a secas— procesan src/, resuelven el Markdown y las plantillas Nunjucks y escriben el HTML en _site/.
  4. El cuarto publica, y merece sección aparte.

Los cinco últimos despliegues han tardado entre 22 y 30 segundos de punta a punta.

Por qué uso dos repositorios

Para construir el sitio y publicarlo tomé la decisión de usar dos repositorios distintos.

  • jestemrique-personal-site, privado. La fuente: el Markdown, las plantillas, el CSS, la configuración de 11ty, la documentación y mis notas. Es donde trabajo.
  • jestemrique.github.io, público. Solo el HTML ya construido, que es lo que GitHub Pages sirve. No se edita a mano nunca.

Hay dos razones para separarlos así.

La URL. GitHub Pages solo te da la dirección en la raíz — jestemrique.github.io— cuando el repositorio se llama exactamente así. Con cualquier otro nombre publicas en un subdirectorio: jestemrique.github.io/mi-blog/. Y eso no es solo más feo: obliga a configurar pathPrefix en 11ty para que todos los enlaces internos y todas las rutas del CSS y de las imágenes lleven ese prefijo delante. Con un repositorio dedicado y bien nombrado, ese problema no llega a existir.

El taller no se enseña. Como lo público es solo el resultado, puedo dejar por escrito lo que quiera en el repositorio de trabajo: borradores a medias, documentos de planificación larguísimos, las notas de por qué descarté cada cosa. Nada de eso sale a internet.

El puente entre los dos es una acción de terceros, peaceiris/actions-gh-pages, que hace exactamente una cosa: coger un directorio y publicarlo como la rama de otro repositorio.

- name: Publicar en jestemrique.github.io
  uses: peaceiris/actions-gh-pages@v4
  with:
    deploy_key: $
    external_repository: jestemrique/jestemrique.github.io
    publish_branch: main
    publish_dir: ./_site

El permiso es el detalle interesante. Por defecto un workflow solo puede tocar su propio repositorio, y este tiene que escribir en otro. La forma fácil sería darle mi token personal de GitHub, que abre toda mi cuenta. La forma correcta es una Deploy Key: un par de claves SSH atado a un único repositorio. La mitad pública va al repositorio de destino con permiso de escritura; la privada se guarda cifrada como secret en el repositorio de origen, y es ese secrets.DEPLOY_KEY de ahí arriba.

La diferencia importa el día que algo salga mal. Si esa clave se filtra, el daño llega hasta donde llega la clave —un repositorio de HTML generado, que se reconstruye entero en 25 segundos— y se revoca borrándola, sin tocar nada más de mi cuenta. Es el principio de mínimo privilegio, y es de las pocas prácticas de seguridad que cuestan lo mismo hacerlas bien que mal.

Un último detalle de esa acción, que también conviene recordar para luego: publica con keep_files: false, o sea que borra del repositorio público todo lo que no venga en el build nuevo. Suena agresivo pero es justo lo que necesito.

El sitio no vive en mi ordenador

Para escribir necesito ver el artículo como va a quedar, no como texto plano. 11ty trae un servidor de desarrollo que reconstruye y recarga el navegador cada vez que guardo. La pregunta es dónde corre eso.

Lo normal es instalarlo en mi ordenador: Node, npm, las dependencias del proyecto. Funciona, y tiene dos pegas. La primera, que te llena el ordenador de cosas que solo usa un proyecto. La segunda es peor: la versión de Node pasa a ser una propiedad de tu máquina, no del proyecto. El día que actualices Node por cualquier otro motivo, o que quieras arrancar esto en otro ordenador, no hay nada que garantice que siga funcionando.

Con Docker, el entorno viaja dentro del proyecto. Son cuatro líneas:

FROM node:22-alpine
WORKDIR /site
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]

Eso describe una máquina entera: parte de una imagen oficial con Node 22 sobre Alpine —una distribución de Linux minúscula, pensada justo para esto—, instala las dependencias dentro y arranca el servidor. Ejecuto un script, se levanta el contenedor, y el sitio queda sirviéndose en localhost:8080. Cuando termino lo apago y no queda rastro.

El docker-compose.yml añade la pieza que hace que esto sirva para escribir:

volumes:
  - ./src:/site/src

src/ no se copia dentro del contenedor: se monta. El directorio de mi disco y el que ve el contenedor son el mismo. Escribo en mi editor, el servidor de dentro lo ve al instante y recarga el navegador. Sin eso habría que reconstruir la imagen a cada frase y esto sería inservible.

Lo que gano:

  • La versión de Node es del proyecto. Está escrita en el Dockerfile, viaja en el repositorio, y no depende de lo que yo tenga instalado hoy.
  • Y es la misma que corre en GitHub Actions: node:22-alpine aquí, node-version: 22 en el workflow. Lo que veo en local está construido con lo mismo que la versión publicada.
  • Trabajar en otro ordenador no rompe nada. Se clona el repositorio, se ejecuta el script, y ya está.
  • Mi ordenador queda limpio. Ni Node, ni dependencias, ni versiones peleándose entre proyectos.

Escribir sin publicar

Necesito poder dejar un artículo empezado sin que salga a la calle. Aquí eso se resuelve trabajando con borradores en el front matter: una línea en la cabecera del archivo.

---
title: Un blog que se publica solo
draft: true
---

Lo que hace esa línea es más fino de lo que parece: el borrador se ve en local y no se ve en producción. No tengo que quitar y poner la marca para mirarlo. Mientras escribo lo veo con su tipografía, su maquetación y su menú, exactamente como quedará; y cuando GitHub Actions construye la versión pública, ese artículo sencillamente no existe. Son dos líneas en .eleventy.js:

eleventyConfig.addPreprocessor("borradores", "md", (datos) => {
  if (!esProduccion) return;
  if (datos.borradores?.retenidos?.[datos.transKey]) return false;
});

esProduccion sale de comparar la variable ELEVENTY_RUN_MODE con "build": 11ty la fija sola según si estás sirviendo en local o construyendo para publicar, sin que haya que tocar nada a mano. Más detalle en la documentación de 11ty sobre variables de entorno.

Un preprocessor se ejecuta sobre cada archivo antes de renderizarlo, y devolver false significa «esta página no existe». En desarrollo sale por la primera línea sin filtrar nada.

Pero la regla de verdad no cabe dentro del archivo. El sitio es bilingüe, y un artículo no está publicado hasta que lo están sus dos versiones: sacar solo el español deja al lector inglés delante de un enlace roto. Así que la unidad mínima no es el archivo, es la pareja.

Y ahí es donde esto deja de ser trivial. 11ty procesa las plantillas de una en una, sin que ninguna sepa de las demás: cuando le llega el turno al español, no hay a quién preguntarle si el inglés existe. Hace falta mirar todos los artículos antes de empezar a construir.

Eso lo resuelve un archivo de datos global, src/_data/borradores.js, que se ejecuta una sola vez al principio del build y cuyo resultado queda disponible para todas las páginas. Recorre los dos directorios de artículos, lee la cabecera de cada index.md y devuelve la lista de los que no salen, con el motivo:

[borradores] 1 artículo(s) NO se publican:
             · blog-que-se-publica-solo — falta la versión en: en

Ese archivo lee el disco directamente, en vez de preguntarle a las colecciones de 11ty, y es a propósito: las colecciones se construyen después de decidir qué páginas existen, así que preguntarles aquí sería circular — necesitaría la respuesta para poder calcular la pregunta.

Queda un efecto secundario incómodo. Un artículo terminado, fusionado y esperando a su traducción es invisible: no está en el sitio, no molesta, y por eso es facilísimo olvidarlo. La solución es tonta y funciona: el script que arranca el sitio me escribe esa lista en la pantalla cada vez. Lo que está esperando aparece solo, sin tener que acordarse de ir a buscarlo.

La primera versión pescaba el aviso de los logs de Docker con un grep, y me chirriaba: estaba buscando un texto. Cambiar el rótulo del mensaje, o la sangría, o el prefijo que Docker le pone a cada línea, y el script deja de encontrarlo. Sin error: deja de avisar, y yo sigo fiándome. Lo probé pasándole logs falsos y algunos cambios se llevaban la lista entera, pero otros dejaban media, que es peor todavía — parece que ha funcionado.

Ahora no lee los logs: ejecuta borradores.js y le pregunta. Puede hacerlo porque ese archivo no necesita a 11ty para nada, así que contesta igual dentro del build que fuera. Y si la consulta falla, lo dice.

Las decisiones, escritas una sola vez

En Sobre este sitio conté qué pinta tiene Claude Code en esto. Falta la parte mecánica: cómo entra el trabajo en el repositorio.

Cada tarea vive en su propia rama, y el prefijo dice de qué tipo es: feature/ cambia lo que el sitio hace, fix/ repara algo roto y articulo/ es contenido. La distinción no es cosmética, porque los riesgos no se parecen: romper el CSS rompe el sitio entero; equivocarse en un artículo deja una errata. Al terminar, PR contra main y merge — el disparador del despliegue de más arriba. Esas reglas no las repito cada vez: están escritas en un archivo CLAUDE.md en la raíz del proyecto, que se lee al empezar. Digo «vamos con la parte 2» y la rama se crea con el prefijo que toca. Esto no es de este sitio: es la forma general en la que trabajo con Claude en todos mis proyectos, y seguramente escriba sobre esto en un artículo aparte.

El grado siguiente es un comando propio. /articulo-nuevo "Un blog que se publica solo" comprueba que main está limpio, crea la rama articulo/blog-que-se-publica-solo, escribe src/es/articulos/blog-que-se-publica-solo/index.md con el front-matter puesto, levanta el contenedor y me da la URL.

Crear un directorio y un archivo no es el trabajo. Lo que ahorra el comando es volver a decidir: lleva escrito dentro que los ids de tema son exactamente tres y van en inglés, que transKey es siempre el slug español aunque el inglés sea distinto, que draft: true no oculta nada en local, y que la portada va con ruta absoluta en los dos idiomas —con una relativa en un lado y una absoluta en el otro, eleventy-img genera dos juegos idénticos de variantes—. Son decisiones ya tomadas que, si no estuvieran escritas, habría que volver a tomar con cada artículo nuevo. Es documentación que además se ejecuta.

Le queda un cabo suelto sin resolver: .gitignore excluye el directorio .claude/ entero —configuración local, no parte del sitio—, así que el comando no viaja en el repositorio. Lo más específico que tiene este proyecto es justo lo único que no está respaldado.

Marcado nuevo con estilos viejos

Todo lo anterior suena muy ordenado. Vamos con la parte en la que no lo fue.

Al principio de todo esto, por ejemplo, desplegué el selector de tema claro/oscuro. En local estaba perfecto. En el sitio publicado estaba roto: iconos gigantes y el selector sin plegar, con estilos de dos versiones distintas mezclados.

Mi primera sospecha fue el build: algo habría hecho mal GitHub Actions al construir. Estaba equivocado, y la forma de descubrirlo fue dejar de suponer y medir. Me traje el CSS que estaba sirviendo el sitio público, lo medí y lo comparé con el que yo tenía construido: exactamente el mismo tamaño. El servidor estaba mandando lo correcto, luego el problema estaba en el otro extremo: en mi navegador.

Era la caché. GitHub Pages sirve los archivos con cache-control: max-age=600 —diez minutos— y no deja tocar las cabeceras: no hay ninguna opción para cambiar ese número. Mi hoja de estilos se llamaba siempre /css/main.css, así que el navegador pedía el HTML nuevo, veía que el CSS ya lo tenía guardado y reutilizaba el anterior. Marcado nuevo con estilos viejos, hasta diez minutos.

Y Ctrl+Shift+R no es la solución: arregla al que lo pulsa, no al visitante.

Si no puedes cambiar las cabeceras, lo único que queda es cambiar la URL cuando cambia el contenido. En producción el archivo pasa a llamarse main.<huella>.css, donde la huella son los ocho primeros dígitos de un SHA-256:

const huella = crypto.createHash("sha256").update(codigo).digest("hex").slice(0, 8);

Tres detalles de esa línea, que son los que costaron pensar:

  • El hash se calcula sobre el CSS ya construido, no sobre los archivos fuente. Es la salida lo que cachea el navegador. Si algún día cambio de minificador y el resultado sale idéntico, nadie tendrá que descargarse nada otra vez.
  • Es un hash del contenido, no la fecha del despliegue. Con la fecha, cada despliegue estrenaría URL aunque el CSS no hubiera cambiado, y tiraría a la basura una caché perfectamente válida. Retocar un artículo no debería obligar a nadie a rebajarse la hoja de estilos.
  • Solo en producción. En desarrollo el servidor escribe a disco en cada recarga y no borra lo anterior, así que con huella una tarde de retoques dejaría el directorio sembrado de main.<huella>.css muertos.

Parecía el build y al final era la caché.

Una página borrada que se seguía sirviendo

Unas semanas más tarde cambié la lista de temas del blog. Los que tenía —desarrollo, aprendizaje y herramientas— eran los de la fase de pruebas, no los definitivos. Entró «personal» y salió «herramientas», y lo hice justo entonces porque era barato: solo lo usaban dos artículos.

Los artículos se quedaban. Lo que desaparecía era el tema, y con él sus dos páginas: /es/tema/herramientas/ y /en/topic/tools/. Ese era el coste conocido y aceptado.

Comprobé que habían desaparecido. No habían desaparecido. /es/tema/herramientas/ seguía respondiendo en mi servidor local, con su título y su listado, como si no hubiera tocado nada.

El motivo es de esas cosas que solo se aprenden tropezando: 11ty escribe en _site/ lo que construye, pero no borra lo que ha dejado de construir. Yo estaba mirando un archivo sobrante de una construcción anterior, servido tan tranquilamente como cualquier otro. Un 200 de manual apuntando a un fantasma. Esto ya me había costado algún dolor de cabeza en otras ocasiones.

Y aquí vuelven los dos detalles que dije que había que recordar. En producción esto no pasa, por partida doble: GitHub Actions construye en una máquina virtual recién hecha, donde no hay ningún _site/ viejo del que queden restos, y la acción que publica borra del repositorio público lo que no venga en el build nuevo. El despliegue limpia; mi ordenador, no. Que es exactamente al revés de lo que uno esperaría.

El mismo cambio destapó un segundo fallo silencioso, este dentro de las plantillas. Al pintar los temas de un artículo, post.njk se saltaba sin decir nada los identificadores que no existieran. Está bien que una errata no tumbe el sitio, pero significaba que un artículo podía seguir nombrando un tema retirado y la etiqueta simplemente no salir, sin que nada avisara. Ahora el build lo canta, y se estrenó en esta misma retirada:

[temas] ⚠ tema desconocido: "herramientas" — en es/articulos/...

Lo que aprendí

Los dos fallos son el mismo, visto desde los dos lados.

En el primero, el servidor mandaba el CSS correcto y yo veía el anterior. En el segundo, el servidor contestaba 200 en una página que ya había borrado. En los dos casos pedí una dirección, obtuve una respuesta perfectamente correcta, y la respuesta correcta no era la prueba que yo creía que era.

Un 200 dice «he encontrado algo que devolverte». No dice de cuándo es, ni quién lo puso ahí, ni si sigue habiendo un motivo para que exista.

Y la forma de salir de la duda fue la misma las dos veces: dejar de razonar sobre lo que debería estar pasando y medir lo que está pasando. En el primer fallo eso fue un curl y un contador de bytes, y bastó para mover la sospecha del sitio equivocado —GitHub Actions— al sitio correcto —mi navegador— en un minuto, después de un buen rato mirando el workflow sin ver nada.

← Volver a los artículos

Jestemrique

Desarrollo, proyectos, aprendizaje…

Tema