{"id":213,"date":"2026-03-22T11:21:14","date_gmt":"2026-03-22T15:21:14","guid":{"rendered":"https:\/\/juredev.com\/blog\/?p=213"},"modified":"2026-04-05T14:28:45","modified_gmt":"2026-04-05T18:28:45","slug":"usar-git-hooks-despliegue-automatico-vps-post-receive-seguridad-troubleshooting","status":"publish","type":"post","link":"https:\/\/juredev.com\/blog\/2026\/03\/usar-git-hooks-despliegue-automatico-vps-post-receive-seguridad-troubleshooting\/","title":{"rendered":"C\u00f3mo usar Git Hooks para despliegue autom\u00e1tico en tu VPS (post-receive, seguridad y troubleshooting)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">En un art\u00edculo anterior montamos un <a href=\"https:\/\/juredev.com\/blog\/2026\/01\/como-crear-tu-propio-servidor-git-en-un-vps-usando-solo-ssh\/\">servidor Git minimalista usando solo SSH<\/a>: usuario dedicado, repositorios bare, claves restringidas y cero dependencias externas.<br>Ahora vamos a darle el siguiente nivel de utilidad: <strong>despliegue autom\u00e1tico cada vez que hagas push<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Con un solo <a href=\"https:\/\/git-scm.com\/docs\/githooks\">hook<\/a> vas a lograr que cada <code>git push<\/code> actualice directamente tu aplicaci\u00f3n en producci\u00f3n. Sin GitHub Actions, sin GitLab CI, sin Docker Compose interminable, sin YAML de 400 l\u00edneas. Solo Bash, Git y tu VPS.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El hook post-receive: qu\u00e9 es y por qu\u00e9 es \u00fatil<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Git tiene varios puntos donde puedes \u00abcolocar\u00bb scripts personalizados (los famosos hooks). El que nos interesa hoy es post-receive, que se ejecuta en el servidor, justo despu\u00e9s de que el push haya sido aceptado con \u00e9xito.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ubicaci\u00f3n t\u00edpica en nuestro setup anterior:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/home\/git\/repositories\/personal\/mi-proyecto.git\/hooks\/post-receive<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Ventajas principales de usar este hook:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>No bloquea al desarrollador<\/strong>: el push ya termin\u00f3 cuando el script empieza a correr<\/li>\n\n\n\n<li><strong>L\u00f3gica separada<\/strong>: perfecto para builds, migraciones, reinicios o lo que necesites<\/li>\n\n\n\n<li><strong>Cero dependencias externas<\/strong>: es Bash puro (o el lenguaje que prefieras)<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Importante sobre los ejemplos que vienen a continuaci\u00f3n<\/strong><\/h3>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">Los casos A, B y C son versiones muy simplificadas solo para que entiendas la mec\u00e1nica. En producci\u00f3n casi siempre querr\u00e1s filtrar por rama (desplegar solo main \/ master \/ lo que uses). Para eso mira el script completo recomendado al final del art\u00edculo.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">Casos de uso t\u00edpicos (y su script b\u00e1sico)<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Caso A \u2013 Sitio web est\u00e1tico (HTML + CSS + JS)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Despliegue directo con checkout forzado.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\nunset $(git rev-parse --local-env-vars)\n\nREPO=\"\/home\/git\/repositories\/personal\/mi-web.git\"\nWEBROOT=\"\/var\/www\/mi-web\"\n\ngit --work-tree=\"$WEBROOT\" --git-dir=\"$REPO\" checkout -f main\necho \"Desplegado en $WEBROOT \u2192 $(date)\"<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Caso B \u2013 Frontend con build (Node, React, Vite, etc.)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Checkout &#8211;> instalaci\u00f3n limpia &#8211;> build &#8211;> rsync at\u00f3mico.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\nunset $(git rev-parse --local-env-vars)\n\nREPO=\"\/home\/git\/repositories\/personal\/mi-app.git\"\nWORKDIR=\"\/srv\/builds\/mi-app\"\nWEBROOT=\"\/var\/www\/mi-app\"\n\nmkdir -p \"$WORKDIR\"\n\ngit --work-tree=\"$WORKDIR\" --git-dir=\"$REPO\" checkout -f main\n\ncd \"$WORKDIR\"\nnpm ci --prefer-offline --no-audit --progress=false\nnpm run build\n\nrsync -a --delete \"$WORKDIR\/dist\/\" \"$WEBROOT\/\"\necho \"Build y rsync completados \u2192 $(date)\"<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Caso C \u2013 Backend (Python, PHP, Go, etc.)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Actualizaci\u00f3n de c\u00f3digo + dependencias + reinicio de servicio.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\nunset $(git rev-parse --local-env-vars)\n\nREPO=\"\/home\/git\/repositories\/personal\/mi-api.git\"\nAPPDIR=\"\/srv\/mi-api\"\n\ngit --work-tree=\"$APPDIR\" --git-dir=\"$REPO\" checkout -f main\n\ncd \"$APPDIR\"\npip install -r requirements.txt --quiet --no-cache-dir\n\n# Requiere configuraci\u00f3n en sudoers (ver m\u00e1s abajo)\nsudo systemctl restart mi-api.service\n\necho \"API actualizada y servicio reiniciado \u2192 $(date)\"<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Seguridad y buenas pr\u00e1cticas (no te saltes esta parte)<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">1. Permisos elevados con sudoers (m\u00ednimos y concretos)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">El usuario <code>git<\/code> normalmente no puede reiniciar servicios ni tocar ciertas carpetas. La forma correcta es crear un archivo en <code>\/etc\/sudoers.d\/<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># \/etc\/sudoers.d\/git-deploy  (editar con visudo -f)\ngit ALL=(root) NOPASSWD: \/bin\/systemctl restart mi-api.service\ngit ALL=(root) NOPASSWD: \/bin\/systemctl restart nginx<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Importante<\/strong>: Nunca pongas <code>NOPASSWD: ALL<\/code> ni <code>git ALL=(ALL) ALL<\/code>. Limita al comando exacto que necesitas.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Secretos y variables de entorno<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No dejes <code>.env<\/code> dentro del repositorio. C\u00e1rgalos desde un archivo seguro:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Al inicio del hook\nsource \/etc\/git-deploy\/mi-proyecto.env   # 600, owner git<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">3. Logging (imprescindible para no volverte loco)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Redirige toda la salida a un log dedicado:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>exec >> \/var\/log\/git-deploy\/mi-proyecto.log 2>&amp;1\necho \"=== Inicio deploy $(date '+%Y-%m-%d %H:%M:%S') ===\"\n# \u2026 resto del script \u2026\necho \"=== Fin deploy ===\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Crea la carpeta y dale permisos:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo mkdir -p \/var\/log\/git-deploy\nsudo chown git:git \/var\/log\/git-deploy\nsudo chmod 750 \/var\/log\/git-deploy<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Problemas comunes y c\u00f3mo solucionarlos<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Entorno Git \u00abfantasma\u00bb<\/strong> &#8211;> variables raras que rompen todo<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Soluci\u00f3n: siempre poner al inicio<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>unset $(git rev-parse --local-env-vars)<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Comandos no encontrados (npm, pip, node, etc.)<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El hook no carga <code>.bashrc<\/code> ni <code>.profile<\/code>. Define PATH expl\u00edcitamente:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export PATH=\"\/usr\/local\/bin:\/usr\/bin:\/bin:\/home\/git\/.nvm\/versions\/node\/v20.x.x\/bin\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Permisos en el directorio de destino<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Aseg\u00farate de que la carpeta destino (<code>\/var\/www\/\u2026, \/srv\/\u2026<\/code>) tenga como grupo <code>git<\/code> o permisos adecuados.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El script que realmente deber\u00edas usar (multirama + logging + seguridad)<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\nset -euo pipefail\n\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Configuraci\u00f3n b\u00e1sica\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport PATH=\"\/usr\/local\/bin:\/usr\/bin:\/bin\"\nunset $(git rev-parse --local-env-vars) 2>\/dev\/null || true\n\nREPO=\"\/home\/git\/repositories\/personal\/mi-proyecto.git\"\nWEBROOT=\"\/var\/www\/mi-proyecto\"           # o \/srv\/mi-app, etc.\nLOGFILE=\"\/var\/log\/git-deploy\/mi-proyecto.log\"\n\nexec >> \"$LOGFILE\" 2>&amp;1\necho \"=== Deploy started $(date '+%Y-%m-%d %H:%M:%S') ===\"\n\n# post-receive recibe por stdin: oldrev newrev refname\nwhile read -r oldrev newrev refname; do\n    # Ignoramos deletes y tags por simplicidad\n    &#91;&#91; \"$newrev\" = \"0000000000000000000000000000000000000000\" ]] &amp;&amp; continue\n\n    BRANCH=$(git rev-parse --symbolic --abbrev-ref \"$refname\" 2>\/dev\/null || echo \"\")\n\n    if &#91;&#91; \"$BRANCH\" == \"main\" ]]; then\n        echo \"\u2192 Push a main detectado. Desplegando...\"\n        \n        git --work-tree=\"$WEBROOT\" --git-dir=\"$REPO\" checkout -f main\n        \n        # Aqu\u00ed puedes poner build, migraciones, npm ci, etc.\n        # cd \"$WEBROOT\" &amp;&amp; npm ci &amp;&amp; npm run build\n        # cd \"$WEBROOT\" &amp;&amp; pip install -r requirements.txt --quiet\n        # sudo systemctl restart mi-proyecto.service\n        \n        echo \"\u2192 Despliegue finalizado OK\"\n    else\n        echo \"\u2192 Rama '$BRANCH' ignorada (solo main despliega)\"\n    fi\ndone\n\necho \"=== Deploy finished $(date '+%Y-%m-%d %H:%M:%S') ===\"<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Para terminar<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Un hook <code>post-receive<\/code> bien hecho es de las cosas m\u00e1s satisfactorias que puedes implementar en un servidor personal: liviano, predecible, bajo costo mensual y 100% bajo tu control.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Claro, cuando el proyecto crece y hay varios desarrolladores o entornos de staging\/producci\u00f3n\/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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pru\u00e9balo en un proyecto peque\u00f1o primero. Una vez que lo tengas funcionando vas a preguntarte por qu\u00e9 no lo hiciste antes. <\/p>\n","protected":false},"excerpt":{"rendered":"<p>En un art\u00edculo 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\u00e1tico cada vez que hagas push. Con un solo hook vas a lograr que cada git push actualice directamente tu aplicaci\u00f3n en producci\u00f3n. Sin [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[18],"tags":[16,24,28],"class_list":["post-213","post","type-post","status-publish","format-standard","hentry","category-guia","tag-git","tag-linux","tag-seguridad"],"_links":{"self":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/213","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/comments?post=213"}],"version-history":[{"count":0,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/213\/revisions"}],"wp:attachment":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/media?parent=213"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/categories?post=213"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/tags?post=213"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}