1. 项目概述用 Git 钩子在 VPS 上实现零手动干预的代码发布你有没有过这样的经历改完一行 CSS本地测试 OK然后打开终端ssh 连上服务器cd 到网站目录git pull再 reload nginx —— 整套流程 45 秒但每天重复 12 次一个月就是 9 小时。更糟的是某天凌晨三点紧急修复线上 bug手抖输错分支名回车后页面白屏而你正困得睁不开眼。这不是效率问题是运维风险。我做独立开发者和小团队技术顾问这十多年见过太多人把“自动部署”想得太重要学 Docker、要配 Jenkins、要搞 CI/CD 流水线……其实对绝大多数静态站点、PHP 博客、Node.js 小工具、Python Flask 后端来说一套纯 Git 原生机制就能扛起全部发布任务且全程不依赖任何第三方服务、不装额外软件、不改系统配置只要你会用 git clone 和 ssh。核心就一句话把你的 VPS 当成一个“裸仓库”利用 Git 自带的post-receive钩子在代码推送到服务器的瞬间自动执行拉取、构建、重启三步操作。它不神秘没黑箱所有命令都是你日常敲过的它极轻量整个部署逻辑写在 12 行 shell 脚本里它极可靠没有网络超时、服务崩溃、权限错乱这些 CI 工具常见的“玄学故障”。关键词Git、VPS、automatic deployment、post-receive、git hooks全部落在实处——不是概念堆砌而是你今晚就能照着操作、明早就能上线的完整路径。适合所有用 VPS 托管个人项目、博客、API 服务、内部工具的同学无论你是刚学会git add .的新手还是天天写 Ansible Playbook 的老手这套方案都比你当前用的“手动 scp”或“临时写个 bash 脚本”更干净、更可追溯、更少出错。2. 整体设计思路与方案选型逻辑2.1 为什么放弃 Jenkins/GitLab CI/ GitHub Actions—— 成本、可控性与场景匹配度很多人一提自动部署第一反应就是上 CI/CD 平台。我试过 Jenkins搭好基础环境花了两天调通 Node.js 构建环境又卡了三天最后发现 80% 的构建步骤只是npm install npm run build而真正耗时的是等 Jenkins Agent 从休眠中唤醒、下载 Docker 镜像、解压依赖包。GitLab CI 更麻烦私有 GitLab 实例要自己维护 Runner公有 GitLab 又受限于免费版的分钟数一次构建超时就得重来。GitHub Actions 对开源项目友好但如果你的代码在私有 Git 服务器比如 Gitea 或自建 Gitolite它根本连不上你的仓库。更重要的是CI/CD 的本质是“把构建过程从目标服务器剥离出来”而这对很多 VPS 场景是反直觉的。你的 VPS 就是生产环境它有 Nginx 配置、SSL 证书、数据库连接、本地缓存目录——这些资源 CI 服务器根本访问不到你最终还得写一堆scp或rsync把产物传回去中间多了一层网络传输、权限校验、路径映射故障点反而增加了。我帮一个客户迁移时发现他们用 GitHub Actions 构建 Vue 项目产物打包完再rsync到 VPS结果因 VPS 磁盘空间不足导致同步失败错误日志却藏在 GitHub 的构建日志里排查花了 40 分钟。而用post-receive所有操作都在 VPS 本地发生df -h一眼看穿磁盘ls -l直接查权限tail -f .git/hooks/post-receive实时看脚本执行流没有中间商赚差价。2.2 为什么选post-receive而非pre-receive或update—— 安全边界与执行时机Git 钩子有三类pre-receive推送前触发、update每个 ref 更新前触发、post-receive所有 ref 推送完成后触发。初学者常误以为pre-receive更“安全”因为能提前拦截非法推送。但实际部署中pre-receive是最不适合的。原因有二第一它运行在“接收数据流”的过程中此时 Git 仓库的索引和对象库尚未写入完成你无法安全地git checkout或git reset --hard强行操作极易导致仓库损坏第二它的返回值决定整个推送是否成功一旦你的钩子脚本里有个curl超时或npm install失败用户git push就会报错但代码其实已经部分上传状态不一致后续git push --force都可能救不回来。update钩子虽在每个分支更新前执行但它只接收refname oldrev newrev三个参数无法获取推送的完整 commit 列表也不方便做跨分支的构建策略比如main分支推送到/var/www/htmlstaging分支推送到/var/www/staging。post-receive是唯一满足所有条件的它在所有数据落盘、所有 ref 更新完毕后才执行此时仓库处于完全一致状态它接收完整的 stdin 输入每行一个refname oldrev newrev可轻松解析推送了哪些分支、哪些 commit它不阻塞推送流程即使钩子脚本执行失败git push依然成功你只需查服务器日志不影响开发同学的提交节奏。我在线上跑了三年post-receive的稳定性远超预期——它甚至能在 VPS 内存只剩 50MB 时正常工作因为它的启动开销几乎为零。2.3 为什么坚持“裸仓库 工作树分离”—— 避免 Git 冲突与权限混乱常见误区是直接在网站根目录如/var/www/html初始化一个普通 Git 仓库然后git push过去。这看似简单但埋下巨大隐患。Git 普通仓库包含.git目录和工作树即你看到的文件当你git push到一个已检出的工作树时Git 会拒绝报错refusing to update checked out branch。有人会加receive.denyCurrentBranch updateInstead配置绕过但这会导致工作树文件与 Git 索引不同步比如你git push一个新文件.git/index记录了它但工作树文件可能因权限问题没写入下次git status就显示modified而你根本不知道它被谁改了。更危险的是如果网站程序如 PHP正在读取某个文件而 Git 正在checkout它可能出现“文件被占用”或“读到半截内容”。正确解法是裸仓库bare repository 工作树working tree分离。裸仓库只有.git目录没有工作树纯粹用于接收推送绝对安全工作树单独放在另一个目录如/var/www/html由post-receive钩子在推送后git --work-tree/var/www/html --git-dir/var/repo/site.git checkout -f命令强制覆盖。这样接收和部署完全解耦裸仓库永远只做“收件箱”工作树永远只做“展示厅”两者之间通过原子性checkout操作桥接不存在中间态。我曾帮一个电商客户修复过类似问题他们用普通仓库部署某次git push后网站图片加载失败查了半天发现是.git/index里记录的图片大小是 120KB但工作树里的文件只有 80KB写入被中断而 Nginx 缓存了这个残缺文件清缓存都不管用最后只能手动cp完整文件过去。用裸仓库后这种问题彻底消失。2.4 为什么不用git pull而用git checkout—— 原子性与环境纯净度另一个高频误区是在 VPS 上建一个普通仓库然后post-receive里cd /var/www/html git pull origin main。这看起来很自然但存在两个硬伤。第一git pull本质是git fetch git merge而merge操作会生成新的 merge commit污染你的提交历史。你本地git log显示的是清晰的线性提交但服务器上却多出一堆Merge branch main of ...不仅难看还可能导致后续git revert出错。第二pull依赖远程跟踪分支如origin/main而裸仓库默认不创建这些分支需要额外git remote add origin ...配置增加复杂度。git checkout -f则完全不同它直接从裸仓库的HEAD指向的 commit强制覆盖工作树所有文件不产生任何新 commit不修改任何分支指针是纯粹的“快照还原”。它保证每次部署都是 100% 确定的状态——你git push的是什么 commit服务器上就是什么 commit不多不少不增不减。我在部署一个实时聊天 API 时必须确保所有服务器节点代码完全一致否则 WebSocket 协议版本错乱会导致客户端断连。用checkout -f后我写了个简单校验脚本ssh uservps cd /var/www/api git rev-parse HEAD对比本地git rev-parse HEAD毫秒级确认一致性。换成pull这个校验就失效了因为你永远不知道它到底merge了什么。3. 核心细节解析与实操要点3.1 VPS 环境准备最小化依赖与权限隔离自动部署的基石是干净、可控的 VPS 环境。我坚持“最小化安装”原则不装任何非必要软件不改默认 SSH 配置不碰系统级 Git 设置。第一步确认 Git 已安装且版本 ≥ 2.15git --version这是--work-tree和--git-dir参数稳定支持的最低版本。如果版本太低如 CentOS 7 默认的 1.8.x不要急着yum update git可能破坏系统依赖而是用源码编译安装到/usr/local/bin/git然后export PATH/usr/local/bin:$PATH加入用户 profile。第二步创建专用部署用户绝不使用 root 或已有业务用户。执行sudo adduser deploy --disabled-password --gecos 然后sudo usermod -aG www-data deployUbuntu/Debian或sudo usermod -aG nginx deployCentOS/RHEL让deploy用户能写入 Web 服务器目录。第三步生成 SSH 密钥对ssh-keygen -t ed25519 -C deployyour-vps将公钥id_ed25519.pub内容追加到~deploy/.ssh/authorized_keys并设置严格权限chmod 700 ~/.ssh chmod 600 ~/.ssh/authorized_keys。关键点在于authorized_keys的权限——如果设成 644SSH 会忽略该文件导致密钥登录失败而错误提示极其隐蔽Permission denied (publickey)新手常在此卡住数小时。第四步禁用密码登录编辑/etc/ssh/sshd_config确认PasswordAuthentication no和PubkeyAuthentication yes然后sudo systemctl restart sshd。这一步看似与部署无关实则至关重要它确保所有对 VPS 的代码推送都经过密钥认证杜绝暴力破解也避免了在post-receive脚本里硬编码密码的高危操作。3.2 裸仓库创建与钩子脚本编写12 行搞定核心逻辑裸仓库的创建必须精准。登录deploy用户执行mkdir -p /var/repo cd /var/repo git init --bare site.git。注意路径/var/repo是约定俗成的裸仓库根目录site.git是仓库名可自定义但必须以.git结尾这是 Git 识别裸仓库的标志。此时ls /var/repo/site.git应显示branches/ config description HEAD hooks/ info/ objects/ refs/绝不能有index、HEAD非符号链接或任何源码文件。接下来是灵魂——post-receive钩子脚本。进入/var/repo/site.git/hooks/用nano post-receive创建文件写入以下内容#!/bin/bash # 3.1 获取推送的分支名假设只部署 main 分支 while read oldrev newrev refname; do branch$(git rev-parse --symbolic --abbrev-ref $refname) if [ $branch main ]; then # 3.2 定义工作树路径网站根目录 WORK_TREE/var/www/html GIT_DIR/var/repo/site.git # 3.3 强制检出覆盖工作树 git --work-tree$WORK_TREE --git-dir$GIT_DIR checkout -f main # 3.4 执行构建命令如 npm build、composer install if [ -f $WORK_TREE/package.json ]; then cd $WORK_TREE npm ci --onlyproduction npm run build elif [ -f $WORK_TREE/composer.json ]; then cd $WORK_TREE composer install --no-dev --optimize-autoloader fi # 3.5 重启 Web 服务根据实际服务名调整 sudo systemctl reload nginx echo Deployed branch $branch to $WORK_TREE fi done保存后chmod x post-receive赋予执行权限。这段脚本的精妙之处在于第一它用while read循环处理 stdin能同时响应多个分支推送如main和develop第二git rev-parse --symbolic --abbrev-ref是解析分支名的最可靠方法比echo $refname | cut -d/ -f3更健壮第三构建命令用if-elif判断项目类型避免在非 Node.js 项目里执行npm报错第四npm ci --onlyproduction比npm install更快更干净它跳过devDependencies且校验package-lock.json一致性。我曾在一个 Laravel 项目里漏写了--no-dev结果vendor/目录里多了phpunit等测试工具被扫描器误判为漏洞入口安全审计时被打了低分。3.3 本地仓库配置一键推送与分支映射本地配置决定了推送的便捷性。进入你的本地项目根目录执行git remote add production deployyour-vps-ip:/var/repo/site.git。这里production是远程别名可自定义deployyour-vps-ip是 VPS 的部署用户和 IP/var/repo/site.git是裸仓库路径。验证是否生效git remote -v应显示production deployyour-vps-ip:/var/repo/site.git (fetch)和(push)。关键一步是设置推送默认分支映射。执行git config remote.production.push refs/heads/main:refs/heads/main这告诉 Git每次git push production时自动把本地main分支推送到远程main分支。如果不设首次推送需git push production main后续才能省略分支名。更进一步可以设git config remote.production.mirror true启用镜像模式但一般不需要。测试推送git push production main。如果一切顺利你应该看到类似Writing objects: 100% (3/3), 256 bytes | 256.00 KiB/s, done.的输出然后 VPS 上post-receive脚本自动执行/var/www/html目录被更新。如果报错fatal: /var/repo/site.git does not appear to be a git repository检查两点一是路径是否拼写错误site.git不是site二是deploy用户对该路径是否有读写权限ls -ld /var/repo应显示drwxr-xr-x 3 deploy deploy。3.4 权限与 SELinux 细节那些让你抓狂的“Permission denied”权限问题是最常见的拦路虎尤其在 CentOS/RHEL 系统上。即使deploy用户属于nginx组/var/www/html目录仍可能因 SELinux 策略被阻止写入。先确认 SELinux 状态sestatus。如果current mode是enforcing执行sudo setsebool -P httpd_can_network_connect 1允许 Web 服务联网如 Composer 下载包再执行sudo semanage fcontext -a -t httpd_sys_rw_content_t /var/www/html(/.*)?然后sudo restorecon -Rv /var/www/html。这三步是标准操作缺一不可。对于文件权限我采用“组继承”策略sudo chgrp -R www-data /var/www/htmlUbuntu或sudo chgrp -R nginx /var/www/htmlCentOS然后sudo chmod -R grws /var/www/html。gssetgid是关键——它确保新创建的文件和目录自动继承父目录的组避免git checkout后文件组变成deployNginx 无法读取。另外post-receive脚本里sudo systemctl reload nginx会失败因为deploy用户默认无sudo权限。解决方法是编辑/etc/sudoerssudo visudo添加deploy ALL(ALL) NOPASSWD: /bin/systemctl reload nginx。注意必须用visudo它会语法检查直接编辑/etc/sudoers文件损坏会导致所有sudo失效系统瘫痪。我曾因手误多打一个空格sudo报错syntax error连sudo su都进不去最后靠 VPS 控制台重置密码才救回。4. 实操过程与核心环节实现4.1 从零开始的完整部署流程手把手带你走一遍现在我们把前面所有知识点串起来模拟一次真实部署。假设你有一个简单的 HTML 博客本地路径是~/my-blogVPS IP 是192.168.1.100部署用户是deploy。第一步VPS 初始化# 登录 VPS用 root 或有 sudo 权限的用户 ssh root192.168.1.100 # 创建 deploy 用户 sudo adduser deploy --disabled-password --gecos # 添加到 www-data 组Ubuntu sudo usermod -aG www-data deploy # 切换到 deploy 用户 sudo su - deploy # 创建裸仓库目录 mkdir -p /var/repo cd /var/repo git init --bare my-blog.git # 创建网站目录 sudo mkdir -p /var/www/html sudo chown -R deploy:www-data /var/www/html sudo chmod -R grws /var/www/html # 退出 deploy回到 root exit # 配置 sudo 权限允许 deploy 重启 nginx echo deploy ALL(ALL) NOPASSWD: /bin/systemctl reload nginx | sudo tee /etc/sudoers.d/deploy # 如果是 CentOS替换 www-data 为 nginx并执行 # sudo setsebool -P httpd_can_network_connect 1 # sudo semanage fcontext -a -t httpd_sys_rw_content_t /var/www/html(/.*)? # sudo restorecon -Rv /var/www/html第二步编写并激活钩子脚本# 切换到 deploy 用户 sudo su - deploy # 编辑钩子 nano /var/repo/my-blog.git/hooks/post-receive # 粘贴前面的 12 行脚本修改 WORK_TREE 为 /var/www/htmlGIT_DIR 为 /var/repo/my-blog.git # 保存后赋权 chmod x /var/repo/my-blog.git/hooks/post-receive第三步本地配置与首次推送# 在本地终端进入博客目录 cd ~/my-blog # 初始化本地 Git如果还没做 git init git add . git commit -m Initial commit # 添加远程仓库 git remote add production deploy192.168.1.100:/var/repo/my-blog.git # 推送 main 分支 git push production main第四步验证与调试推送后立即登录 VPS 查看效果# 查看钩子执行日志如果有 echo 输出 # 检查网站目录是否更新 ls -la /var/www/html # 查看最新 commit cd /var/www/html git rev-parse HEAD # 检查 Nginx 是否正常 curl -I http://192.168.1.100 # 如果 502 错误检查 Nginx 错误日志 sudo tail -f /var/log/nginx/error.log首次推送成功后/var/www/html里应该有你本地的所有文件且git status显示On branch main, nothing to commit, working tree clean。这意味着工作树与裸仓库HEAD完全一致部署完成。4.2 多环境部署main/staging/develop 分支的差异化处理单一分支部署满足不了真实需求。比如main推送到生产/var/www/htmlstaging推送到预发/var/www/stagingdevelop推送到开发/var/www/develop。只需扩展post-receive脚本的if-elif逻辑while read oldrev newrev refname; do branch$(git rev-parse --symbolic --abbrev-ref $refname) case $branch in main) WORK_TREE/var/www/html SERVICEnginx ;; staging) WORK_TREE/var/www/staging SERVICEnginx ;; develop) WORK_TREE/var/www/develop SERVICEnginx ;; *) echo Ignoring branch $branch continue ;; esac git --work-tree$WORK_TREE --git-dir/var/repo/my-blog.git checkout -f $branch # 构建命令同上... sudo systemctl reload $SERVICE echo Deployed $branch to $WORK_TREE done本地推送时指定分支即可git push production staging。注意staging和develop分支在裸仓库里是“虚拟”的——它们只存在于refs/heads/下不会自动创建首次推送需git push production refs/heads/staging:refs/heads/staging。为简化可在本地git config中为每个环境设置不同远程git remote add staging deploy192.168.1.100:/var/repo/my-blog.git git remote add develop deploy192.168.1.100:/var/repo/my-blog.git然后git push staging staging和git push develop develop。这样不同环境完全隔离互不影响main的 bug 不会污染staging的测试。4.3 构建阶段深度定制PHP、Node.js、Python 的实战适配post-receive的强大在于它是一段可编程的 shell 脚本能无缝集成各种构建工具。以下是三个主流场景的实操模板PHPLaravel# 在钩子脚本的构建段加入 if [ -f $WORK_TREE/.env ]; then cd $WORK_TREE # 安装依赖跳过 dev composer install --no-dev --optimize-autoloader # 生成优化文件 php artisan config:clear php artisan cache:clear php artisan view:clear # 生成 autoload composer dump-autoload --optimize fi关键点--no-dev防止phpunit等被装到生产环境config:clear确保.env修改立即生效dump-autoload --optimize提升类加载速度。我曾因漏掉config:clear导致.env里改了数据库密码但 Laravel 还在用缓存的旧配置连不上 DB查了两小时。Node.jsExpressif [ -f $WORK_TREE/package.json ]; then cd $WORK_TREE # 清理 node_modules可选确保干净 rm -rf node_modules # 使用 ci 安装比 install 更快更准 npm ci --onlyproduction # 构建前端如果有的话 if [ -d $WORK_TREE/client ]; then cd client npm ci npm run build cd .. fi # 重启 PM2 进程假设用 PM2 管理 pm2 reload app.js --update-env finpm ci是核心它不读package.json只按package-lock.json安装100% 复现本地环境pm2 reload是热重载不中断服务比pm2 restart更平滑。PythonFlaskif [ -f $WORK_TREE/requirements.txt ]; then cd $WORK_TREE # 创建虚拟环境避免污染系统 Python python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install -r requirements.txt # 收集静态文件Django 类似 if [ -f $WORK_TREE/manage.py ]; then python manage.py collectstatic --noinput fi # 重启 Gunicorn假设用 systemd 管理 sudo systemctl restart my-flask-app fi虚拟环境venv是必须的它隔离依赖避免pip install影响系统全局包collectstatic是 Django 项目的关键步骤把所有 CSS/JS 打包到STATIC_ROOT供 Nginx 直接服务。4.4 安全加固与审计追踪让部署可监控、可回滚自动部署不是“黑盒”必须有审计能力。第一记录每次部署日志。在post-receive开头添加LOG_FILE/var/log/deploy.log echo [$(date)] Push received: $oldrev - $newrev on $refname by $(whoami) $LOG_FILE第二保留历史版本。在钩子脚本末尾加入# 创建时间戳备份保留最近 5 个 BACKUP_DIR/var/backups/my-blog mkdir -p $BACKUP_DIR tar -czf $BACKUP_DIR/backup-$(date %Y%m%d-%H%M%S).tar.gz -C /var/www html # 清理旧备份 ls -t $BACKUP_DIR/backup-*.tar.gz | tail -n 6 | xargs -r rm这样任何时候都能tar -xzf backup-20231001-120000.tar.gz回滚到任意时刻。第三限制推送来源。虽然 SSH 密钥已认证但可进一步在post-receive里加 IP 白名单CLIENT_IP$(echo $SSH_CONNECTION | awk {print $1}) ALLOWED_IPS192.168.1.50 203.0.113.25 if ! echo $ALLOWED_IPS | grep -q $CLIENT_IP; then echo Rejected: IP $CLIENT_IP not allowed exit 1 fi第四防止重复部署。有时网络抖动导致git push重试钩子被多次触发。加个锁文件LOCK_FILE/tmp/deploy.lock if [ -f $LOCK_FILE ]; then echo Deploy already running exit 0 fi touch $LOCK_FILE # ... 部署逻辑 ... rm -f $LOCK_FILE这些不是“锦上添花”而是生产环境的底线。我管理的一个金融类后台就因没做备份一次误操作git push --force覆盖了main所有客户配置丢失靠手动从日志里恢复花了 8 小时。5. 常见问题与排查技巧实录5.1 “fatal: not a git repository (or any of the parent directories): .git” —— 路径与上下文陷阱这是新手遇到的第一道墙90% 的原因是post-receive脚本里git命令的执行路径错了。post-receive钩子在/var/repo/my-blog.git/hooks/目录下运行但git checkout需要知道--git-dir裸仓库位置和--work-tree工作树位置。如果写成git checkout -fGit 会尝试在当前目录即hooks/找.git当然找不到。必须显式指定--git-dir和--work-tree。另一个常见原因是WORK_TREE路径不存在或权限不足。比如你设WORK_TREE/var/www/html但/var/www/html目录还没mkdir或者deploy用户没写入权限。排查步骤登录 VPS手动执行钩子里的命令sudo su - deploy cd /var/repo/my-blog.git/hooks/ # 模拟推送输入 echo 0000000000000000000000000000000000000000 abc1234567890123456789012345678901234567 refs/heads/main | ./post-receive观察错误输出它会精确告诉你哪一行git命令失败。如果是fatal: Not a git repository立刻检查--git-dir路径是否拼写正确ls /var/repo/my-blog.git是否存在。5.2 “Permission denied (publickey)” —— SSH 密钥的 7 个致命细节密钥登录失败是第二大痛点原因极其琐碎。我整理了 7 个必查点公钥格式id_rsa.pub或id_ed25519.pub的内容必须是单行以ssh-rsa AAAA...或ssh-ed25519 AAAA...开头结尾是邮箱中间不能换行。复制时别把换行符带进去。authorized_keys 权限~deploy/.ssh/authorized_keys必须是600-rw-------~deploy/.ssh必须是700drwx------。644或755都会被 SSH 忽略。用户主目录权限~deploy目录不能是777必须是755或700。SSH 要求主目录不能被组或其他人写入。SELinux 上下文在 CentOS/RHEL 上~deploy/.ssh目录的 SELinux type 必须是ssh_home_t。用ls -Z ~deploy/.ssh查看如果不是执行sudo semanage fcontext -a -t ssh_home_t /home/deploy/.ssh(/.*)? sudo restorecon -Rv /home/deploy/.ssh。sshd_config 配置确认/etc/ssh/sshd_config里PubkeyAuthentication yes、AuthorizedKeysFile .ssh/authorized_keys、PasswordAuthentication no如果禁用密码都正确且没有被Include的其他文件覆盖。密钥类型兼容性较老的 OpenSSH 版本 7.0不支持ed25519如果 VPS 是旧系统改用ssh-keygen -t rsa -b 4096生成 RSA 密钥。代理转发干扰如果你本地用了ssh -A代理转发可能影响密钥选择。临时去掉-A参数测试。5.3 钩子脚本不执行—— Git 钩子的静默失败机制post-receive最坑的一点是它静默失败。即使脚本里echo hello你也看不到输出即使git checkout报错git push依然显示success。这是因为钩子的 stdout/stderr 被 Git 丢弃了。解决方案是强制重定向日志# 在 post-receive 第一行加 exec /var/log/post-receive.log 21 echo [$(date)] Script started # ... 其余脚本 ... echo [$(date)] Script finished然后tail -f /var/log/post-receive.log实时查看。另一个常见原因是脚本开头没写#!/bin/bash或者写了#!/usr/bin/env bash但 VPS 没装bash极少见但 Alpine Linux 默认只有ash。用file /var/repo/my-blog.git/hooks/post-receive查看脚本类型确保是shell script。如果脚本里用了source命令记得source是bash特
Git post-receive 钩子实现 VPS 自动部署
发布时间:2026/6/21 20:42:48
1. 项目概述用 Git 钩子在 VPS 上实现零手动干预的代码发布你有没有过这样的经历改完一行 CSS本地测试 OK然后打开终端ssh 连上服务器cd 到网站目录git pull再 reload nginx —— 整套流程 45 秒但每天重复 12 次一个月就是 9 小时。更糟的是某天凌晨三点紧急修复线上 bug手抖输错分支名回车后页面白屏而你正困得睁不开眼。这不是效率问题是运维风险。我做独立开发者和小团队技术顾问这十多年见过太多人把“自动部署”想得太重要学 Docker、要配 Jenkins、要搞 CI/CD 流水线……其实对绝大多数静态站点、PHP 博客、Node.js 小工具、Python Flask 后端来说一套纯 Git 原生机制就能扛起全部发布任务且全程不依赖任何第三方服务、不装额外软件、不改系统配置只要你会用 git clone 和 ssh。核心就一句话把你的 VPS 当成一个“裸仓库”利用 Git 自带的post-receive钩子在代码推送到服务器的瞬间自动执行拉取、构建、重启三步操作。它不神秘没黑箱所有命令都是你日常敲过的它极轻量整个部署逻辑写在 12 行 shell 脚本里它极可靠没有网络超时、服务崩溃、权限错乱这些 CI 工具常见的“玄学故障”。关键词Git、VPS、automatic deployment、post-receive、git hooks全部落在实处——不是概念堆砌而是你今晚就能照着操作、明早就能上线的完整路径。适合所有用 VPS 托管个人项目、博客、API 服务、内部工具的同学无论你是刚学会git add .的新手还是天天写 Ansible Playbook 的老手这套方案都比你当前用的“手动 scp”或“临时写个 bash 脚本”更干净、更可追溯、更少出错。2. 整体设计思路与方案选型逻辑2.1 为什么放弃 Jenkins/GitLab CI/ GitHub Actions—— 成本、可控性与场景匹配度很多人一提自动部署第一反应就是上 CI/CD 平台。我试过 Jenkins搭好基础环境花了两天调通 Node.js 构建环境又卡了三天最后发现 80% 的构建步骤只是npm install npm run build而真正耗时的是等 Jenkins Agent 从休眠中唤醒、下载 Docker 镜像、解压依赖包。GitLab CI 更麻烦私有 GitLab 实例要自己维护 Runner公有 GitLab 又受限于免费版的分钟数一次构建超时就得重来。GitHub Actions 对开源项目友好但如果你的代码在私有 Git 服务器比如 Gitea 或自建 Gitolite它根本连不上你的仓库。更重要的是CI/CD 的本质是“把构建过程从目标服务器剥离出来”而这对很多 VPS 场景是反直觉的。你的 VPS 就是生产环境它有 Nginx 配置、SSL 证书、数据库连接、本地缓存目录——这些资源 CI 服务器根本访问不到你最终还得写一堆scp或rsync把产物传回去中间多了一层网络传输、权限校验、路径映射故障点反而增加了。我帮一个客户迁移时发现他们用 GitHub Actions 构建 Vue 项目产物打包完再rsync到 VPS结果因 VPS 磁盘空间不足导致同步失败错误日志却藏在 GitHub 的构建日志里排查花了 40 分钟。而用post-receive所有操作都在 VPS 本地发生df -h一眼看穿磁盘ls -l直接查权限tail -f .git/hooks/post-receive实时看脚本执行流没有中间商赚差价。2.2 为什么选post-receive而非pre-receive或update—— 安全边界与执行时机Git 钩子有三类pre-receive推送前触发、update每个 ref 更新前触发、post-receive所有 ref 推送完成后触发。初学者常误以为pre-receive更“安全”因为能提前拦截非法推送。但实际部署中pre-receive是最不适合的。原因有二第一它运行在“接收数据流”的过程中此时 Git 仓库的索引和对象库尚未写入完成你无法安全地git checkout或git reset --hard强行操作极易导致仓库损坏第二它的返回值决定整个推送是否成功一旦你的钩子脚本里有个curl超时或npm install失败用户git push就会报错但代码其实已经部分上传状态不一致后续git push --force都可能救不回来。update钩子虽在每个分支更新前执行但它只接收refname oldrev newrev三个参数无法获取推送的完整 commit 列表也不方便做跨分支的构建策略比如main分支推送到/var/www/htmlstaging分支推送到/var/www/staging。post-receive是唯一满足所有条件的它在所有数据落盘、所有 ref 更新完毕后才执行此时仓库处于完全一致状态它接收完整的 stdin 输入每行一个refname oldrev newrev可轻松解析推送了哪些分支、哪些 commit它不阻塞推送流程即使钩子脚本执行失败git push依然成功你只需查服务器日志不影响开发同学的提交节奏。我在线上跑了三年post-receive的稳定性远超预期——它甚至能在 VPS 内存只剩 50MB 时正常工作因为它的启动开销几乎为零。2.3 为什么坚持“裸仓库 工作树分离”—— 避免 Git 冲突与权限混乱常见误区是直接在网站根目录如/var/www/html初始化一个普通 Git 仓库然后git push过去。这看似简单但埋下巨大隐患。Git 普通仓库包含.git目录和工作树即你看到的文件当你git push到一个已检出的工作树时Git 会拒绝报错refusing to update checked out branch。有人会加receive.denyCurrentBranch updateInstead配置绕过但这会导致工作树文件与 Git 索引不同步比如你git push一个新文件.git/index记录了它但工作树文件可能因权限问题没写入下次git status就显示modified而你根本不知道它被谁改了。更危险的是如果网站程序如 PHP正在读取某个文件而 Git 正在checkout它可能出现“文件被占用”或“读到半截内容”。正确解法是裸仓库bare repository 工作树working tree分离。裸仓库只有.git目录没有工作树纯粹用于接收推送绝对安全工作树单独放在另一个目录如/var/www/html由post-receive钩子在推送后git --work-tree/var/www/html --git-dir/var/repo/site.git checkout -f命令强制覆盖。这样接收和部署完全解耦裸仓库永远只做“收件箱”工作树永远只做“展示厅”两者之间通过原子性checkout操作桥接不存在中间态。我曾帮一个电商客户修复过类似问题他们用普通仓库部署某次git push后网站图片加载失败查了半天发现是.git/index里记录的图片大小是 120KB但工作树里的文件只有 80KB写入被中断而 Nginx 缓存了这个残缺文件清缓存都不管用最后只能手动cp完整文件过去。用裸仓库后这种问题彻底消失。2.4 为什么不用git pull而用git checkout—— 原子性与环境纯净度另一个高频误区是在 VPS 上建一个普通仓库然后post-receive里cd /var/www/html git pull origin main。这看起来很自然但存在两个硬伤。第一git pull本质是git fetch git merge而merge操作会生成新的 merge commit污染你的提交历史。你本地git log显示的是清晰的线性提交但服务器上却多出一堆Merge branch main of ...不仅难看还可能导致后续git revert出错。第二pull依赖远程跟踪分支如origin/main而裸仓库默认不创建这些分支需要额外git remote add origin ...配置增加复杂度。git checkout -f则完全不同它直接从裸仓库的HEAD指向的 commit强制覆盖工作树所有文件不产生任何新 commit不修改任何分支指针是纯粹的“快照还原”。它保证每次部署都是 100% 确定的状态——你git push的是什么 commit服务器上就是什么 commit不多不少不增不减。我在部署一个实时聊天 API 时必须确保所有服务器节点代码完全一致否则 WebSocket 协议版本错乱会导致客户端断连。用checkout -f后我写了个简单校验脚本ssh uservps cd /var/www/api git rev-parse HEAD对比本地git rev-parse HEAD毫秒级确认一致性。换成pull这个校验就失效了因为你永远不知道它到底merge了什么。3. 核心细节解析与实操要点3.1 VPS 环境准备最小化依赖与权限隔离自动部署的基石是干净、可控的 VPS 环境。我坚持“最小化安装”原则不装任何非必要软件不改默认 SSH 配置不碰系统级 Git 设置。第一步确认 Git 已安装且版本 ≥ 2.15git --version这是--work-tree和--git-dir参数稳定支持的最低版本。如果版本太低如 CentOS 7 默认的 1.8.x不要急着yum update git可能破坏系统依赖而是用源码编译安装到/usr/local/bin/git然后export PATH/usr/local/bin:$PATH加入用户 profile。第二步创建专用部署用户绝不使用 root 或已有业务用户。执行sudo adduser deploy --disabled-password --gecos 然后sudo usermod -aG www-data deployUbuntu/Debian或sudo usermod -aG nginx deployCentOS/RHEL让deploy用户能写入 Web 服务器目录。第三步生成 SSH 密钥对ssh-keygen -t ed25519 -C deployyour-vps将公钥id_ed25519.pub内容追加到~deploy/.ssh/authorized_keys并设置严格权限chmod 700 ~/.ssh chmod 600 ~/.ssh/authorized_keys。关键点在于authorized_keys的权限——如果设成 644SSH 会忽略该文件导致密钥登录失败而错误提示极其隐蔽Permission denied (publickey)新手常在此卡住数小时。第四步禁用密码登录编辑/etc/ssh/sshd_config确认PasswordAuthentication no和PubkeyAuthentication yes然后sudo systemctl restart sshd。这一步看似与部署无关实则至关重要它确保所有对 VPS 的代码推送都经过密钥认证杜绝暴力破解也避免了在post-receive脚本里硬编码密码的高危操作。3.2 裸仓库创建与钩子脚本编写12 行搞定核心逻辑裸仓库的创建必须精准。登录deploy用户执行mkdir -p /var/repo cd /var/repo git init --bare site.git。注意路径/var/repo是约定俗成的裸仓库根目录site.git是仓库名可自定义但必须以.git结尾这是 Git 识别裸仓库的标志。此时ls /var/repo/site.git应显示branches/ config description HEAD hooks/ info/ objects/ refs/绝不能有index、HEAD非符号链接或任何源码文件。接下来是灵魂——post-receive钩子脚本。进入/var/repo/site.git/hooks/用nano post-receive创建文件写入以下内容#!/bin/bash # 3.1 获取推送的分支名假设只部署 main 分支 while read oldrev newrev refname; do branch$(git rev-parse --symbolic --abbrev-ref $refname) if [ $branch main ]; then # 3.2 定义工作树路径网站根目录 WORK_TREE/var/www/html GIT_DIR/var/repo/site.git # 3.3 强制检出覆盖工作树 git --work-tree$WORK_TREE --git-dir$GIT_DIR checkout -f main # 3.4 执行构建命令如 npm build、composer install if [ -f $WORK_TREE/package.json ]; then cd $WORK_TREE npm ci --onlyproduction npm run build elif [ -f $WORK_TREE/composer.json ]; then cd $WORK_TREE composer install --no-dev --optimize-autoloader fi # 3.5 重启 Web 服务根据实际服务名调整 sudo systemctl reload nginx echo Deployed branch $branch to $WORK_TREE fi done保存后chmod x post-receive赋予执行权限。这段脚本的精妙之处在于第一它用while read循环处理 stdin能同时响应多个分支推送如main和develop第二git rev-parse --symbolic --abbrev-ref是解析分支名的最可靠方法比echo $refname | cut -d/ -f3更健壮第三构建命令用if-elif判断项目类型避免在非 Node.js 项目里执行npm报错第四npm ci --onlyproduction比npm install更快更干净它跳过devDependencies且校验package-lock.json一致性。我曾在一个 Laravel 项目里漏写了--no-dev结果vendor/目录里多了phpunit等测试工具被扫描器误判为漏洞入口安全审计时被打了低分。3.3 本地仓库配置一键推送与分支映射本地配置决定了推送的便捷性。进入你的本地项目根目录执行git remote add production deployyour-vps-ip:/var/repo/site.git。这里production是远程别名可自定义deployyour-vps-ip是 VPS 的部署用户和 IP/var/repo/site.git是裸仓库路径。验证是否生效git remote -v应显示production deployyour-vps-ip:/var/repo/site.git (fetch)和(push)。关键一步是设置推送默认分支映射。执行git config remote.production.push refs/heads/main:refs/heads/main这告诉 Git每次git push production时自动把本地main分支推送到远程main分支。如果不设首次推送需git push production main后续才能省略分支名。更进一步可以设git config remote.production.mirror true启用镜像模式但一般不需要。测试推送git push production main。如果一切顺利你应该看到类似Writing objects: 100% (3/3), 256 bytes | 256.00 KiB/s, done.的输出然后 VPS 上post-receive脚本自动执行/var/www/html目录被更新。如果报错fatal: /var/repo/site.git does not appear to be a git repository检查两点一是路径是否拼写错误site.git不是site二是deploy用户对该路径是否有读写权限ls -ld /var/repo应显示drwxr-xr-x 3 deploy deploy。3.4 权限与 SELinux 细节那些让你抓狂的“Permission denied”权限问题是最常见的拦路虎尤其在 CentOS/RHEL 系统上。即使deploy用户属于nginx组/var/www/html目录仍可能因 SELinux 策略被阻止写入。先确认 SELinux 状态sestatus。如果current mode是enforcing执行sudo setsebool -P httpd_can_network_connect 1允许 Web 服务联网如 Composer 下载包再执行sudo semanage fcontext -a -t httpd_sys_rw_content_t /var/www/html(/.*)?然后sudo restorecon -Rv /var/www/html。这三步是标准操作缺一不可。对于文件权限我采用“组继承”策略sudo chgrp -R www-data /var/www/htmlUbuntu或sudo chgrp -R nginx /var/www/htmlCentOS然后sudo chmod -R grws /var/www/html。gssetgid是关键——它确保新创建的文件和目录自动继承父目录的组避免git checkout后文件组变成deployNginx 无法读取。另外post-receive脚本里sudo systemctl reload nginx会失败因为deploy用户默认无sudo权限。解决方法是编辑/etc/sudoerssudo visudo添加deploy ALL(ALL) NOPASSWD: /bin/systemctl reload nginx。注意必须用visudo它会语法检查直接编辑/etc/sudoers文件损坏会导致所有sudo失效系统瘫痪。我曾因手误多打一个空格sudo报错syntax error连sudo su都进不去最后靠 VPS 控制台重置密码才救回。4. 实操过程与核心环节实现4.1 从零开始的完整部署流程手把手带你走一遍现在我们把前面所有知识点串起来模拟一次真实部署。假设你有一个简单的 HTML 博客本地路径是~/my-blogVPS IP 是192.168.1.100部署用户是deploy。第一步VPS 初始化# 登录 VPS用 root 或有 sudo 权限的用户 ssh root192.168.1.100 # 创建 deploy 用户 sudo adduser deploy --disabled-password --gecos # 添加到 www-data 组Ubuntu sudo usermod -aG www-data deploy # 切换到 deploy 用户 sudo su - deploy # 创建裸仓库目录 mkdir -p /var/repo cd /var/repo git init --bare my-blog.git # 创建网站目录 sudo mkdir -p /var/www/html sudo chown -R deploy:www-data /var/www/html sudo chmod -R grws /var/www/html # 退出 deploy回到 root exit # 配置 sudo 权限允许 deploy 重启 nginx echo deploy ALL(ALL) NOPASSWD: /bin/systemctl reload nginx | sudo tee /etc/sudoers.d/deploy # 如果是 CentOS替换 www-data 为 nginx并执行 # sudo setsebool -P httpd_can_network_connect 1 # sudo semanage fcontext -a -t httpd_sys_rw_content_t /var/www/html(/.*)? # sudo restorecon -Rv /var/www/html第二步编写并激活钩子脚本# 切换到 deploy 用户 sudo su - deploy # 编辑钩子 nano /var/repo/my-blog.git/hooks/post-receive # 粘贴前面的 12 行脚本修改 WORK_TREE 为 /var/www/htmlGIT_DIR 为 /var/repo/my-blog.git # 保存后赋权 chmod x /var/repo/my-blog.git/hooks/post-receive第三步本地配置与首次推送# 在本地终端进入博客目录 cd ~/my-blog # 初始化本地 Git如果还没做 git init git add . git commit -m Initial commit # 添加远程仓库 git remote add production deploy192.168.1.100:/var/repo/my-blog.git # 推送 main 分支 git push production main第四步验证与调试推送后立即登录 VPS 查看效果# 查看钩子执行日志如果有 echo 输出 # 检查网站目录是否更新 ls -la /var/www/html # 查看最新 commit cd /var/www/html git rev-parse HEAD # 检查 Nginx 是否正常 curl -I http://192.168.1.100 # 如果 502 错误检查 Nginx 错误日志 sudo tail -f /var/log/nginx/error.log首次推送成功后/var/www/html里应该有你本地的所有文件且git status显示On branch main, nothing to commit, working tree clean。这意味着工作树与裸仓库HEAD完全一致部署完成。4.2 多环境部署main/staging/develop 分支的差异化处理单一分支部署满足不了真实需求。比如main推送到生产/var/www/htmlstaging推送到预发/var/www/stagingdevelop推送到开发/var/www/develop。只需扩展post-receive脚本的if-elif逻辑while read oldrev newrev refname; do branch$(git rev-parse --symbolic --abbrev-ref $refname) case $branch in main) WORK_TREE/var/www/html SERVICEnginx ;; staging) WORK_TREE/var/www/staging SERVICEnginx ;; develop) WORK_TREE/var/www/develop SERVICEnginx ;; *) echo Ignoring branch $branch continue ;; esac git --work-tree$WORK_TREE --git-dir/var/repo/my-blog.git checkout -f $branch # 构建命令同上... sudo systemctl reload $SERVICE echo Deployed $branch to $WORK_TREE done本地推送时指定分支即可git push production staging。注意staging和develop分支在裸仓库里是“虚拟”的——它们只存在于refs/heads/下不会自动创建首次推送需git push production refs/heads/staging:refs/heads/staging。为简化可在本地git config中为每个环境设置不同远程git remote add staging deploy192.168.1.100:/var/repo/my-blog.git git remote add develop deploy192.168.1.100:/var/repo/my-blog.git然后git push staging staging和git push develop develop。这样不同环境完全隔离互不影响main的 bug 不会污染staging的测试。4.3 构建阶段深度定制PHP、Node.js、Python 的实战适配post-receive的强大在于它是一段可编程的 shell 脚本能无缝集成各种构建工具。以下是三个主流场景的实操模板PHPLaravel# 在钩子脚本的构建段加入 if [ -f $WORK_TREE/.env ]; then cd $WORK_TREE # 安装依赖跳过 dev composer install --no-dev --optimize-autoloader # 生成优化文件 php artisan config:clear php artisan cache:clear php artisan view:clear # 生成 autoload composer dump-autoload --optimize fi关键点--no-dev防止phpunit等被装到生产环境config:clear确保.env修改立即生效dump-autoload --optimize提升类加载速度。我曾因漏掉config:clear导致.env里改了数据库密码但 Laravel 还在用缓存的旧配置连不上 DB查了两小时。Node.jsExpressif [ -f $WORK_TREE/package.json ]; then cd $WORK_TREE # 清理 node_modules可选确保干净 rm -rf node_modules # 使用 ci 安装比 install 更快更准 npm ci --onlyproduction # 构建前端如果有的话 if [ -d $WORK_TREE/client ]; then cd client npm ci npm run build cd .. fi # 重启 PM2 进程假设用 PM2 管理 pm2 reload app.js --update-env finpm ci是核心它不读package.json只按package-lock.json安装100% 复现本地环境pm2 reload是热重载不中断服务比pm2 restart更平滑。PythonFlaskif [ -f $WORK_TREE/requirements.txt ]; then cd $WORK_TREE # 创建虚拟环境避免污染系统 Python python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install -r requirements.txt # 收集静态文件Django 类似 if [ -f $WORK_TREE/manage.py ]; then python manage.py collectstatic --noinput fi # 重启 Gunicorn假设用 systemd 管理 sudo systemctl restart my-flask-app fi虚拟环境venv是必须的它隔离依赖避免pip install影响系统全局包collectstatic是 Django 项目的关键步骤把所有 CSS/JS 打包到STATIC_ROOT供 Nginx 直接服务。4.4 安全加固与审计追踪让部署可监控、可回滚自动部署不是“黑盒”必须有审计能力。第一记录每次部署日志。在post-receive开头添加LOG_FILE/var/log/deploy.log echo [$(date)] Push received: $oldrev - $newrev on $refname by $(whoami) $LOG_FILE第二保留历史版本。在钩子脚本末尾加入# 创建时间戳备份保留最近 5 个 BACKUP_DIR/var/backups/my-blog mkdir -p $BACKUP_DIR tar -czf $BACKUP_DIR/backup-$(date %Y%m%d-%H%M%S).tar.gz -C /var/www html # 清理旧备份 ls -t $BACKUP_DIR/backup-*.tar.gz | tail -n 6 | xargs -r rm这样任何时候都能tar -xzf backup-20231001-120000.tar.gz回滚到任意时刻。第三限制推送来源。虽然 SSH 密钥已认证但可进一步在post-receive里加 IP 白名单CLIENT_IP$(echo $SSH_CONNECTION | awk {print $1}) ALLOWED_IPS192.168.1.50 203.0.113.25 if ! echo $ALLOWED_IPS | grep -q $CLIENT_IP; then echo Rejected: IP $CLIENT_IP not allowed exit 1 fi第四防止重复部署。有时网络抖动导致git push重试钩子被多次触发。加个锁文件LOCK_FILE/tmp/deploy.lock if [ -f $LOCK_FILE ]; then echo Deploy already running exit 0 fi touch $LOCK_FILE # ... 部署逻辑 ... rm -f $LOCK_FILE这些不是“锦上添花”而是生产环境的底线。我管理的一个金融类后台就因没做备份一次误操作git push --force覆盖了main所有客户配置丢失靠手动从日志里恢复花了 8 小时。5. 常见问题与排查技巧实录5.1 “fatal: not a git repository (or any of the parent directories): .git” —— 路径与上下文陷阱这是新手遇到的第一道墙90% 的原因是post-receive脚本里git命令的执行路径错了。post-receive钩子在/var/repo/my-blog.git/hooks/目录下运行但git checkout需要知道--git-dir裸仓库位置和--work-tree工作树位置。如果写成git checkout -fGit 会尝试在当前目录即hooks/找.git当然找不到。必须显式指定--git-dir和--work-tree。另一个常见原因是WORK_TREE路径不存在或权限不足。比如你设WORK_TREE/var/www/html但/var/www/html目录还没mkdir或者deploy用户没写入权限。排查步骤登录 VPS手动执行钩子里的命令sudo su - deploy cd /var/repo/my-blog.git/hooks/ # 模拟推送输入 echo 0000000000000000000000000000000000000000 abc1234567890123456789012345678901234567 refs/heads/main | ./post-receive观察错误输出它会精确告诉你哪一行git命令失败。如果是fatal: Not a git repository立刻检查--git-dir路径是否拼写正确ls /var/repo/my-blog.git是否存在。5.2 “Permission denied (publickey)” —— SSH 密钥的 7 个致命细节密钥登录失败是第二大痛点原因极其琐碎。我整理了 7 个必查点公钥格式id_rsa.pub或id_ed25519.pub的内容必须是单行以ssh-rsa AAAA...或ssh-ed25519 AAAA...开头结尾是邮箱中间不能换行。复制时别把换行符带进去。authorized_keys 权限~deploy/.ssh/authorized_keys必须是600-rw-------~deploy/.ssh必须是700drwx------。644或755都会被 SSH 忽略。用户主目录权限~deploy目录不能是777必须是755或700。SSH 要求主目录不能被组或其他人写入。SELinux 上下文在 CentOS/RHEL 上~deploy/.ssh目录的 SELinux type 必须是ssh_home_t。用ls -Z ~deploy/.ssh查看如果不是执行sudo semanage fcontext -a -t ssh_home_t /home/deploy/.ssh(/.*)? sudo restorecon -Rv /home/deploy/.ssh。sshd_config 配置确认/etc/ssh/sshd_config里PubkeyAuthentication yes、AuthorizedKeysFile .ssh/authorized_keys、PasswordAuthentication no如果禁用密码都正确且没有被Include的其他文件覆盖。密钥类型兼容性较老的 OpenSSH 版本 7.0不支持ed25519如果 VPS 是旧系统改用ssh-keygen -t rsa -b 4096生成 RSA 密钥。代理转发干扰如果你本地用了ssh -A代理转发可能影响密钥选择。临时去掉-A参数测试。5.3 钩子脚本不执行—— Git 钩子的静默失败机制post-receive最坑的一点是它静默失败。即使脚本里echo hello你也看不到输出即使git checkout报错git push依然显示success。这是因为钩子的 stdout/stderr 被 Git 丢弃了。解决方案是强制重定向日志# 在 post-receive 第一行加 exec /var/log/post-receive.log 21 echo [$(date)] Script started # ... 其余脚本 ... echo [$(date)] Script finished然后tail -f /var/log/post-receive.log实时查看。另一个常见原因是脚本开头没写#!/bin/bash或者写了#!/usr/bin/env bash但 VPS 没装bash极少见但 Alpine Linux 默认只有ash。用file /var/repo/my-blog.git/hooks/post-receive查看脚本类型确保是shell script。如果脚本里用了source命令记得source是bash特