Cómo usar Git Hooks para despliegue automático en tu VPS (post-receive, seguridad y troubleshooting)

En un artículo anterior montamos un servidor Git minimalista usando solo SSH: usuario dedicado, repositorios bare, claves restringidas y cero dependencias externas.
Ahora vamos a darle el siguiente nivel de utilidad: despliegue automático cada vez que hagas push.

Con un solo hook vas a lograr que cada git push actualice directamente tu aplicación en producción. Sin GitHub Actions, sin GitLab CI, sin Docker Compose interminable, sin YAML de 400 líneas. Solo Bash, Git y tu VPS.

El hook post-receive: qué es y por qué es útil

Git tiene varios puntos donde puedes «colocar» scripts personalizados (los famosos hooks). El que nos interesa hoy es post-receive, que se ejecuta en el servidor, justo después de que el push haya sido aceptado con éxito.

Ubicación típica en nuestro setup anterior:

/home/git/repositories/personal/mi-proyecto.git/hooks/post-receive

Ventajas principales de usar este hook:

  • No bloquea al desarrollador: el push ya terminó cuando el script empieza a correr
  • Lógica separada: perfecto para builds, migraciones, reinicios o lo que necesites
  • Cero dependencias externas: es Bash puro (o el lenguaje que prefieras)

Importante sobre los ejemplos que vienen a continuación

Los casos A, B y C son versiones muy simplificadas solo para que entiendas la mecánica. En producción casi siempre querrás filtrar por rama (desplegar solo main / master / lo que uses). Para eso mira el script completo recomendado al final del artículo.

Casos de uso típicos (y su script básico)

Caso A – Sitio web estático (HTML + CSS + JS)

Despliegue directo con checkout forzado.

#!/usr/bin/env bash
unset $(git rev-parse --local-env-vars)

REPO="/home/git/repositories/personal/mi-web.git"
WEBROOT="/var/www/mi-web"

git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main
echo "Desplegado en $WEBROOT → $(date)"

Caso B – Frontend con build (Node, React, Vite, etc.)

Checkout –> instalación limpia –> build –> rsync atómico.

#!/usr/bin/env bash
unset $(git rev-parse --local-env-vars)

REPO="/home/git/repositories/personal/mi-app.git"
WORKDIR="/srv/builds/mi-app"
WEBROOT="/var/www/mi-app"

mkdir -p "$WORKDIR"

git --work-tree="$WORKDIR" --git-dir="$REPO" checkout -f main

cd "$WORKDIR"
npm ci --prefer-offline --no-audit --progress=false
npm run build

rsync -a --delete "$WORKDIR/dist/" "$WEBROOT/"
echo "Build y rsync completados → $(date)"

Caso C – Backend (Python, PHP, Go, etc.)

Actualización de código + dependencias + reinicio de servicio.

#!/usr/bin/env bash
unset $(git rev-parse --local-env-vars)

REPO="/home/git/repositories/personal/mi-api.git"
APPDIR="/srv/mi-api"

git --work-tree="$APPDIR" --git-dir="$REPO" checkout -f main

cd "$APPDIR"
pip install -r requirements.txt --quiet --no-cache-dir

# Requiere configuración en sudoers (ver más abajo)
sudo systemctl restart mi-api.service

echo "API actualizada y servicio reiniciado → $(date)"

Seguridad y buenas prácticas (no te saltes esta parte)

1. Permisos elevados con sudoers (mínimos y concretos)

El usuario git normalmente no puede reiniciar servicios ni tocar ciertas carpetas. La forma correcta es crear un archivo en /etc/sudoers.d/:

# /etc/sudoers.d/git-deploy  (editar con visudo -f)
git ALL=(root) NOPASSWD: /bin/systemctl restart mi-api.service
git ALL=(root) NOPASSWD: /bin/systemctl restart nginx

Importante: Nunca pongas NOPASSWD: ALL ni git ALL=(ALL) ALL. Limita al comando exacto que necesitas.

2. Secretos y variables de entorno

No dejes .env dentro del repositorio. Cárgalos desde un archivo seguro:

# Al inicio del hook
source /etc/git-deploy/mi-proyecto.env   # 600, owner git

3. Logging (imprescindible para no volverte loco)

Redirige toda la salida a un log dedicado:

exec >> /var/log/git-deploy/mi-proyecto.log 2>&1
echo "=== Inicio deploy $(date '+%Y-%m-%d %H:%M:%S') ==="
# … resto del script …
echo "=== Fin deploy ==="

Crea la carpeta y dale permisos:

sudo mkdir -p /var/log/git-deploy
sudo chown git:git /var/log/git-deploy
sudo chmod 750 /var/log/git-deploy

Problemas comunes y cómo solucionarlos

Entorno Git «fantasma» –> variables raras que rompen todo

Solución: siempre poner al inicio

unset $(git rev-parse --local-env-vars)

Comandos no encontrados (npm, pip, node, etc.)

El hook no carga .bashrc ni .profile. Define PATH explícitamente:

export PATH="/usr/local/bin:/usr/bin:/bin:/home/git/.nvm/versions/node/v20.x.x/bin"

Permisos en el directorio de destino

Asegúrate de que la carpeta destino (/var/www/…, /srv/…) tenga como grupo git o permisos adecuados.

El script que realmente deberías usar (multirama + logging + seguridad)

#!/usr/bin/env bash
set -euo pipefail

# ────────────────────────────────────────────────
# Configuración básica
# ────────────────────────────────────────────────
export PATH="/usr/local/bin:/usr/bin:/bin"
unset $(git rev-parse --local-env-vars) 2>/dev/null || true

REPO="/home/git/repositories/personal/mi-proyecto.git"
WEBROOT="/var/www/mi-proyecto"           # o /srv/mi-app, etc.
LOGFILE="/var/log/git-deploy/mi-proyecto.log"

exec >> "$LOGFILE" 2>&1
echo "=== Deploy started $(date '+%Y-%m-%d %H:%M:%S') ==="

# post-receive recibe por stdin: oldrev newrev refname
while read -r oldrev newrev refname; do
    # Ignoramos deletes y tags por simplicidad
    [[ "$newrev" = "0000000000000000000000000000000000000000" ]] && continue

    BRANCH=$(git rev-parse --symbolic --abbrev-ref "$refname" 2>/dev/null || echo "")

    if [[ "$BRANCH" == "main" ]]; then
        echo "→ Push a main detectado. Desplegando..."
        
        git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main
        
        # Aquí puedes poner build, migraciones, npm ci, etc.
        # cd "$WEBROOT" && npm ci && npm run build
        # cd "$WEBROOT" && pip install -r requirements.txt --quiet
        # sudo systemctl restart mi-proyecto.service
        
        echo "→ Despliegue finalizado OK"
    else
        echo "→ Rama '$BRANCH' ignorada (solo main despliega)"
    fi
done

echo "=== Deploy finished $(date '+%Y-%m-%d %H:%M:%S') ==="

Para terminar

Un hook post-receive bien hecho es de las cosas más satisfactorias que puedes implementar en un servidor personal: liviano, predecible, bajo costo mensual y 100% bajo tu control.

Claro, cuando el proyecto crece y hay varios desarrolladores o entornos de staging/producción/review-apps, probablemente termines migrando a GitHub Actions, Woodpecker, Drone o similares. Pero para 80-90% de los proyectos que corren en un VPS barato (blogs, herramientas internas, MVPs, side projects), Git puro + un hook inteligente sigue siendo imbatible en simplicidad y velocidad.

Pruébalo en un proyecto pequeño primero. Una vez que lo tengas funcionando vas a preguntarte por qué no lo hiciste antes.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.