Desplegar con un git push: repositorio desnudo, un cron y un contenedor que construye
Esta web se publica sola cuando llega un commit al servidor. Sin CI de pago, sin GitHub Actions y sin dejar Node instalado en producción. El montaje completo, y la fuga que nos enseñó a mirar qué subimos.
enlocal.ai es un sitio estático: Markdown en un repositorio y un build que genera HTML. Para
publicar un cambio hacemos un git push a un remoto que vive en Olmillos Server y, en
menos de dos minutos, está en la web. No hay GitHub Actions, no hay Netlify y en el servidor
no hay ni Node instalado.
La cadena completa
portátil ── git push produccion HEAD:master ──► /srv/git/web.git (repositorio desnudo)
│ hook post-receive
▼
/srv/git/web-work (copia de trabajo, solo sincroniza)
│
cron cada minuto ── deploy-watch.sh ──────┤ ¿master ≠ último desplegado?
▼
docker run node:22-alpine npm ci && npm run build
│
▼ rsync
/www/wwwroot/tudominio (lo que sirve Nginx)
Tres piezas pequeñas, cada una con una sola responsabilidad.
Pieza 1 · Un repositorio desnudo en el servidor
sudo mkdir -p /srv/git && sudo chown $USER /srv/git
git init --bare /srv/git/web.git
En tu máquina:
git remote add produccion tu_usuario@servidor:/srv/git/web.git
git push produccion HEAD:master
Llamamos al remoto produccion a propósito, no origin. Así un git push distraído sin
nombre de remoto no despliega nada por accidente.
Pieza 2 · El hook solo sincroniza, no construye
/srv/git/web.git/hooks/post-receive:
#!/bin/sh
GIT_WORK_TREE=/srv/git/web-work git checkout -f master
¿Por qué no construir aquí? Porque el hook corre mientras tu git push espera. Un build de
un minuto y medio deja tu terminal colgada y, si falla, el push parece fallido aunque el
commit ya esté dentro. Separar «recibir» de «construir» hace las dos cosas más simples.
Pieza 3 · Un vigilante por cron que construye en un contenedor
/usr/local/bin/deploy-watch.sh, lanzado cada minuto (versión simplificada del nuestro, sin
bloqueo ni notificaciones):
#!/bin/sh
set -eu
REPO=/srv/git/web.git
WORK=/srv/git/web-work
WEBROOT=/www/wwwroot/tudominio
STATE=/var/lib/deploy/last-deployed
head=$(git --git-dir="$REPO" rev-parse master)
[ "$head" = "$(cat "$STATE" 2>/dev/null)" ] && exit 0
docker run --rm -v "$WORK":/app -w /app node:22-alpine \
sh -c 'npm ci --no-audit --no-fund && npm run build'
rsync -a --delete "$WORK/dist/" "$WEBROOT/"
echo "$head" > "$STATE"
# /etc/crontab
* * * * * deploy /usr/local/bin/deploy-watch.sh >> /var/log/deploy-watch.log 2>&1
Ventajas de construir en node:22-alpine:
- el servidor no necesita Node, ni la versión correcta de Node, ni sus actualizaciones;
- el build es el mismo en tu portátil y en producción (
npm cirespeta el lockfile); - si el build falla, no se hace el rsync y la web sigue sirviendo la versión anterior.
Nota de la casa: teníamos también una unidad de systemd pensada para esto, y durante semanas
creímos que era ella la que desplegaba. Estaba inactive. Quien trabajaba era el cron.
Comprueba qué despliega de verdad antes de tocar nada.
La fuga: mira qué hay en lo que publicas
Este montaje tuvo un fallo serio que merece contarse. Nuestro panel de edición guarda sus datos en una carpeta del repositorio, y durante un tiempo esa carpeta acababa dentro del build: cualquiera que adivinara la ruta podía descargar borradores que no eran públicos.
Nadie la enlazaba y nadie la leía desde el sitio. Simplemente estaba ahí, porque se copiaba todo. Lo arreglamos en dos sitios:
- el build excluye explícitamente esa carpeta;
- después de cada despliegue comprobamos que la ruta devuelve 404 contra el dominio real, no contra el entorno de pruebas.
La lección general: haz un ls -R del directorio que sirves al menos una vez. Te vas a
sorprender.
Un detalle de horas que parecen averías
Los contenedores van en UTC y el servidor en hora de Madrid. Si comparas el log del build con el del cron, hay dos horas de diferencia que parecen un despliegue fantasma (P-088). Pon la zona horaria en los logs o trabaja todo en UTC.
¿Y si quiero publicar contenido programado?
Un sitio estático solo «publica» cuando se construye. Si programas un post para mañana a las 9:00, no aparecerá hasta el siguiente build. La solución es trivial: una segunda línea en el cron que fuerce un build diario aunque no haya commits.
5 7 * * * deploy rm -f /var/lib/deploy/last-deployed
A las 7:05 borra la marca y, un minuto después, el vigilante reconstruye con la fecha nueva.
Comprobación
git push produccion HEAD:master
ssh servidor 'tail -f /var/log/deploy-watch.log'
curl -sI https://tudominio.es/ | head -1
curl -s -o /dev/null -w '%{http_code}\n' https://tudominio.es/carpeta-privada/ # debe dar 404
Siguiente: Claude Code siempre encendido en tu servidor, para retomar el trabajo desde el móvil.