· 8 min de lectura

Brotli, gzip y un hook de git para servir 12 KB en vez de 60

Cómo precomprimo con brotli y gzip el HTML de mi portfolio en un hook post-commit de git, para que nginx sirva el fichero hecho y no gaste CPU.

Fila de balas de heno redondas en un campo de hierba seca al amanecer, con el cielo anaranjado
Lo mismo de siempre, pero ocupando bastante menos sitio. Foto: «Hay Patch at Sunrise» de Corey Leopold · CC BY 2.0, recortada y redimensionada

El index.html de arnauserver.me pesa 63.212 bytes. Para un portfolio de una sola página me parecía una barbaridad hasta que lo desmonté: unos 34 KB son CSS metido en línea en un <style> (para no pedir otro fichero antes de pintar), unos 6 KB son iconos SVG y el resto es el contenido. La máquina que lo sirve tampoco está para alegrías: es una VM de Azure con 836 MiB de RAM que, con CrowdSec y unos cuantos servicios pequeños, vive con unos 780 MiB metidos en swap.

La opción fácil era poner gzip on en nginx y olvidarme. Funciona, pero comprime en cada petición, y con un nivel moderado porque el trabajo se hace en caliente. Yo quería lo contrario. Que los ficheros comprimidos ya estén en disco, hechos una vez con el nivel máximo, y que nginx solo tenga que elegir cuál manda. Con brotli el HTML se queda en 12.287 bytes, y con gzip en 14.441.

Lo que nginx ya trae

Para gzip no hace falta nada externo. El módulo ngx_http_gzip_static_module hace justo esto: si llega una petición de style.css y el cliente acepta gzip, busca style.css.gz al lado y, si existe, lo sirve tal cual. La documentación avisa de que no se compila por defecto, así que conviene mirar que tu nginx lo lleve:

$ nginx -V 2>&1 | tr ' ' '\n' | grep gzip_static
--with-http_gzip_static_module

El nginx 1.24 de Ubuntu 24.04 lo trae. La directiva acepta on, off y always. Con always manda el .gz a todo el mundo sin mirar si el cliente lo soporta, y eso solo tiene sentido si ni siquiera guardas el original. Yo uso on. Hay un detalle de la doc fácil de pasar por alto: recomiendan que el original y el comprimido tengan la misma fecha de modificación. Tiene lógica, porque Last-Modified y el ETag salen de ahí, y no quieres que el mismo recurso tenga cabeceras distintas según el encoding.

Para brotli hace falta el módulo de Google, ngx_brotli. Tiene su propia brotli_static, con las mismas opciones, y busca ficheros .br. En Ubuntu 24.04 no hay que compilarlo: está empaquetado como libnginx-mod-http-brotli-static, y aparte está libnginx-mod-http-brotli-filter para comprimir al vuelo. Instalé los dos con apt y se cargan solos desde modules-enabled.

Lo tengo a nivel http, en un fichero de conf.d, para que valga para todos los sitios:

gzip_static on;
gzip_vary on;
gzip_comp_level 6;

brotli on;
brotli_comp_level 5;
brotli_static on;

Los _static sirven lo que ya está precomprimido. gzip on (que viene en el nginx.conf de Ubuntu) y brotli on quedan de red para lo que no lo está, como el JSON de estado que se regenera cada minuto, con niveles moderados porque eso sí se hace en cada petición.

Con las dos activas, a un navegador que manda br en Accept-Encoding le llega brotli, y al resto gzip. gzip_static tiene en cuenta gzip_vary, que añade Vary: Accept-Encoding para que ninguna caché intermedia le sirva brotli a un cliente que no lo entiende. El README de ngx_brotli no dice nada de Vary, así que eso tocaba comprobarlo a mano, y más abajo está la prueba: sale también en la respuesta brotli.

El hook

Faltaba generar los .br y .gz sin tener que acordarme. El repositorio de la web vive directamente en el servidor, en /var/www/html, y cada cambio pasa por un commit ahí mismo, así que lo natural era un hook de git. Según la documentación de githooks, post-commit se ejecuta justo después de crear el commit, no recibe parámetros y no puede cambiar el resultado. Esto último me va perfecto: si falla la compresión, el commit ya está hecho y no se queda nada a medias.

Este es el hook tal cual:

#!/bin/bash
# Auto-regenerate gzip + brotli for changed HTML/SVG/CSS/JS/JSON/XML/TXT files.
# Runs on git commit (post-commit) and after merge/pull (post-merge symlinks).
set -eu

cd "$(git rev-parse --show-toplevel)"

# Files changed in the most recent commit. For post-merge, fall back to all tracked.
if [ "${GIT_HOOK:-post-commit}" = "post-merge" ]; then
  CHANGED=$(git ls-files | grep -E '\.(html|svg|css|js|mjs|json|xml|txt|webmanifest)$' || true)
else
  CHANGED=$(git diff-tree --no-commit-id --name-only -r HEAD | grep -E '\.(html|svg|css|js|mjs|json|xml|txt|webmanifest)$' || true)
fi

[ -z "$CHANGED" ] && exit 0

count=0
while IFS= read -r f; do
  [ -f "$f" ] || continue
  case "$f" in
    *.br|*.gz) continue ;;
  esac
  gzip -9 -k -f -- "$f" 2>/dev/null && \
  brotli -q 11 -k -f -- "$f" 2>/dev/null && \
  count=$((count + 1))
done <<< "$CHANGED"

echo "[post-commit] Pre-compressed $count file(s)."

En vez de recorrer toda la carpeta, git diff-tree da solo los ficheros que han cambiado en el último commit, que suelen ser uno o dos. post-merge es un enlace simbólico al mismo script, para que un git pull también regenere.

Un par de cosas no son obvias. Sin -k, gzip borra el original, y nginx con gzip_static on (no always) lo necesita para los clientes que no aceptan compresión. En brotli, -q 11 es la calidad máxima (man brotli). Al vuelo sería mala idea porque es lenta, pero aquí se paga una vez por commit y me da igual que tarde.

Lo de las fechas que pedía la doc de nginx me lo encontré hecho. Tanto gzip como brotli copian por defecto la fecha de modificación del original al comprimido:

$ ls --full-time index.html* | awk '{print $7, $9}'
00:01:59.787712019 index.html
00:01:59.787712019 index.html.br
00:01:59.787712019 index.html.gz

Los comprimidos no se versionan. Al .gitignore:

# Pre-compressed build artifacts (regenerated automatically)
*.br
*.gz

Las cifras de las tres páginas que tengo:

Fichero Original gzip -9 brotli -q 11
index.html 63.212 14.441 12.287
cv.html 20.795 7.180 6.144
uses.html 11.908 4.292 3.654

Brotli le saca a gzip entre un 14 % y un 15 % en las tres. Frente al original, el index se queda en menos de una quinta parte.

Comprobarlo sin engañarse

Ver content-encoding: br en las herramientas de desarrollo y darlo por bueno es tentador. Pero eso solo demuestra que llega algo en brotli. No te dice si es el fichero precomprimido o nginx comprimiendo al vuelo, y como yo tengo los dos activos, se vería igual. Con curl se distingue mejor: el .br estático lleva content-length y uno comprimido al vuelo no.

$ for enc in br gzip identity; do
    echo "== $enc"
    curl -sI -H "Accept-Encoding: $enc" https://arnauserver.me/ \
      | grep -iE 'content-(encoding|length)|vary|last-modified'
  done
== br
content-length: 12287
last-modified: Fri, 13 Nov 2026 00:01:59 GMT
vary: Accept-Encoding
content-encoding: br
== gzip
content-length: 14441
last-modified: Fri, 13 Nov 2026 00:01:59 GMT
vary: Accept-Encoding
content-encoding: gzip
== identity
content-length: 63212
last-modified: Fri, 13 Nov 2026 00:01:59 GMT
vary: Accept-Encoding

Las cabeceras salen en minúsculas porque va por HTTP/2. Los content-length coinciden byte a byte con los ficheros del disco, last-modified es el mismo en las tres y el Vary está también en la respuesta brotli.

Un aviso por si alguien repite esto en su servidor. Si lanzas el bucle varias veces seguidas desde la misma IP, acabas chocando con tu propio limit_req. El mío está a 30 peticiones por minuto con algo de margen, y nginx responde por defecto con un 503, que se confunde fácil con un fallo de verdad.

Pegas

El hook vive en .git/hooks, y esa carpeta no se versiona. Si mañana clono el repo en otro sitio, el hook no viene con él y nadie me avisa. La web seguiría funcionando, solo que sin .br ni .gz, y no me daría cuenta hasta mirar las cabeceras. git permite apuntar core.hooksPath a una carpeta dentro del propio repo, y es lo que debería haber hecho desde el principio.

El otro problema es más feo. Si edito un HTML en el servidor y no hago commit, el original cambia pero el .br no, y nginx sigue sirviendo la versión antigua a todos los navegadores modernos. Los que no aceptan compresión (básicamente curl sin cabeceras) ven la nueva. Es el tipo de bug que te hace dudar de tu propia cordura un buen rato. Con el hook la regla es sencilla: todo cambio pasa por commit. Pero depende de que yo me acuerde, que es justo lo que quería evitar.

Sobre si se nota en la carga, no tengo forma honesta de decirlo. Tengo un colector de Web Vitals propio, pero con las pocas visitas humanas que recibe un portfolio las muestras son tan escasas que no me atrevo a atribuirle nada a la compresión.

Lo siguiente es mover el hook a core.hooksPath dentro del repo. También quiero probar si brotli -q 11 frente a -q 9 marca alguna diferencia real en ficheros de este tamaño o si solo me hace sentir mejor.