← Tu laboratorio en cualquier parteSerie Remoto · paso 4 de 11

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.

Dificultadmedia
Tiempo1 hora
Ingredientesgit · cron · Docker · rsync · Astro (o cualquier generador estático)

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 ci respeta 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:

  1. el build excluye explícitamente esa carpeta;
  2. 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.