改一行代码上线要几步?
打包、登录宝塔、找到站点目录、上传覆盖、清缓存——周末改个错别字也得走一遍。更惨的是某次改完急着走,本地推了,服务器没拉,线上跑着三天前的版本,用户反馈的 bug 你在这边怎么都复现不出来。
这篇把这个环节彻底砍掉:本地 git push 之后,服务器自己把代码拉下来,全程你不用再登面板。

01 先搞清它在干什么
原理一句话:代码仓库被 push 时,会往你留的一个网址发一条通知(HTTP 请求),宝塔收到通知就去执行一句脚本,脚本做的事就是 git pull。
三个角色:
| 角色 | 干什么 |
|---|---|
| Gitee / GitHub / GitLab | 事件源:你 push 了,它就发通知 |
| 宝塔 WebHook 插件 | 接收器:守着一个网址,收到请求就跑脚本 |
| 同步脚本(shell) | 执行者:git pull + 修权限 + 写日志 |
所以整套东西没有任何黑科技,难的全在细节:脚本放哪、谁来跑、权限怎么给、失败了去哪看日志。
顺带说个安全提醒:宝塔给的 WebHook 地址里带一串密钥(access_key),谁拿到谁就能让你的服务器拉代码。别把它贴到公开仓库、群聊、截图里;服务器上有防火墙的,建议只放行码云/GitHub 的回调 IP。
02 服务器上先把 Git 和钥匙准备好
进宝塔 → 终端,先看有没有 git(宝塔机器一般自带):
git --version

没有就装:
# CentOS / 宝塔常见
yum install -y git
# Ubuntu / Debian
apt-get install -y git
生成 SSH 钥匙(一路回车三下,不要设密码,否则脚本拉代码会卡住):
ssh-keygen -t rsa -C "bt-deploy"
cat ~/.ssh/id_rsa.pub
把 cat 出来的那一整串复制下来(以 ssh-rsa 开头、以你的主机名结尾),粘到 Gitee → 设置 → SSH 公钥,标题会自动生成。

验证服务器和码云是否握手成功(这一步不能跳过):
ssh -T git@gitee.com
# 看到 Hi XXX! You've successfully authenticated 就成了
这一步的意义:让服务器有了"免密拉代码"的身份。后面脚本用 SSH 地址拉代码,就不会在深夜因为要输密码而静默失败。
03 站点目录:先把仓库 clone 下来
进 /www/wwwroot(宝塔站点默认都在这),用 SSH 地址 clone:
cd /www/wwwroot
git clone git@gitee.com:你的用户名/你的仓库名.git my-site
⚠️ 目录名记住它——它就是后面 WebHook 地址里要传的参数。仓库名和站点目录名保持一致,整套配置最省事。
clone 完务必把归属改回 www(宝塔站点的运行用户,不改的话 Web 访问可能 403):
chown -R www:www /www/wwwroot/my-site
再放行一次 git 的目录安全检查(避免后面脚本报错 detected dubious ownership):
git config --global --add safe.directory /www/wwwroot/my-site
04 装宝塔 WebHook,填脚本
宝塔面板 → 软件商店 → 搜 WebHook → 安装。装完打开它,添加 Hook,名称随便写(比如 sync-site),执行脚本贴下面这份:
#!/bin/bash
##############################################################################
# 橙码酱 · 宝塔 WebHook 自动同步脚本
#
# v2 相比网上流传的版本修了三件事,都是真踩过的坑:
# 1. 同一秒被触发多次 → 加并发锁,只跑一个,其余打印 [SKIP] 直接退出
# 2. SSH 认证失败时 git 会卡在"等你输密码",脚本永远不返回 → BatchMode,失败立刻报错
# 3. git 的报错走 stderr,不进宝塔日志 → exec 2>&1,报错一并可见
##############################################################################
exec 2>&1 # 让 git 的报错也进宝塔日志,否则你只能看到一半
# ====================== 只需改这三行 ======================
GIT_USER="你的码云用户名" # gitee.com 后面的用户名
BRANCH="master" # 要同步的分支(main / master / dev …)
WWW_ROOT="/www/wwwroot" # 站点根目录
# =========================================================
LOG_DIR="/www/wwwlogs/deploy" # 部署日志目录,排错全靠它
# 防卡死:SSH 不弹密码框、连不上就 10 秒超时报错;https 地址也不弹凭据框
export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=10"
export GIT_TERMINAL_PROMPT=0
echo ""
echo "===== $(date "+%Y-%m-%d %H:%M:%S") Start ====="
# ---------- 0. 取参数:param 传的就是站点目录名 ----------
if [ -z "$1" ]; then
echo "[ERROR] param 为空:WebHook 地址请带 ¶m=站点目录名"
echo "===== End ====="
exit 1
fi
SITE="$1"
gitPath="$WWW_ROOT/$SITE"
gitHttp="git@gitee.com:${GIT_USER}/${SITE}.git" # SSH 地址;注释用 #,不能用 //
echo "[STEP1] 参数 OK:站点目录 = $gitPath"
# ---------- 1. 并发锁:同一秒被触发多次时只跑一个 ----------
LOCK="/tmp/bt_sync_${SITE}.lock"
if ! mkdir "$LOCK" 2>/dev/null; then
echo "[SKIP] 上一次同步还没跑完,本次跳过(避免 git 抢锁卡死)"
echo "===== End ====="
exit 0
fi
trap 'rm -rf "$LOCK"' EXIT
echo "[STEP2] 拿到锁,开始同步"
# ---------- 2. 目录不存在就创建并克隆 ----------
if [ ! -d "$gitPath" ]; then
echo "[STEP3] 目录不存在,创建并克隆"
mkdir -p "$gitPath"
cd "$gitPath" || exit 1
git clone -b "$BRANCH" "$gitHttp" tmp_clone
mv tmp_clone/.git ./
rm -rf tmp_clone
fi
cd "$gitPath" || exit 1
# ---------- 3. 放行 git 安全目录检查(避免 dubious ownership 报错)----------
git config --global --add safe.directory "$gitPath"
echo "[STEP4] 进入站点目录,开始拉取"
# ---------- 4. 强制对齐远端 ----------
# 部署目录不该有人手改,所以用 reset --hard 而不是 pull:
# pull 在本地有改动/换行符差异时会因冲突失败,reset 则是"和仓库一模一样"
git fetch --all
git reset --hard "origin/$BRANCH"
echo "[STEP5] 代码已对齐"
# ---------- 5. 归属还给 www(宝塔站点运行用户)----------
chown -R www:www "$gitPath"
# ---------- 6. 记一笔日志,方便事后查 ----------
mkdir -p "$LOG_DIR"
{
echo "----- $(date "+%Y-%m-%d %H:%M:%S") -----"
echo "站点目录:$gitPath"
echo "当前版本:$(git rev-parse --short HEAD)"
git log -1 --pretty=format:"%h %an %ad %s" --date=format:"%Y-%m-%d %H:%M"
echo ""
} >> "$LOG_DIR/${SITE}.log" 2>&1
echo "[STEP6] 已更新到:$(git rev-parse --short HEAD)"
echo "===== End ====="
exit 0

保存之后,再点一次「编辑」,确认脚本原样还在。宝塔有时候会过滤掉部分字符,脚本残缺时它照样返回 200,但服务器什么都没做——这个坑能让你排查一晚上。
05 拿到地址,回填到仓库

保存后,WebHook 界面会给你一条地址,长这样:
http://你的服务器IP:8888/hook?access_key=一串密钥¶m=aaa
把最后的 aaa 改成你的站点目录名(也就是 03 步 clone 出来的那个名字),然后整条复制。
打开 Gitee 仓库 → 管理 → WebHooks → 添加,把地址粘进去,事件勾 Push(有需要再勾 Tag Push),保存。
改完面板记得重启一次宝塔,让权限和环境变量彻底生效:
/etc/init.d/bt restart
GitHub / GitLab 同款操作:都是把这条 URL 填到仓库的 Webhooks → Payload URL 里,Content type 选默认的即可——宝塔那边不解析请求体,只看 URL 里的 param。
06 验证:别急着改代码
最快的验证不需要 push——直接浏览器访问那条 WebHook 地址(或用 curl):
curl "http://你的服务器IP:8888/hook?access_key=一串密钥¶m=my-site"
回来看三样东西:
| 看哪 | 期望结果 |
|---|---|
| Gitee → WebHooks → 查看更多 | 状态码 200 |
| 宝塔 WebHook → 日志 | 出现你写的时间戳、Reset 结果、最新 commit 号 |
| 站点目录文件 | 修改时间和刚才一致 |
三样都对上了,再在本地改一行、commit、push,亲眼看着服务器目录自己变。那一刻的爽感,值这一篇的收藏。
07 日志只有 Start,永远等不到 End
这是我自己在服务器上真遇到的一幕——日志长这样,整整九行 Start,一行 End 都没有:
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:13 Start =====
===== 2026-10-04 21:45:19 Start =====
===== 2026-10-04 22:02:12 Start =====
===== 2026-10-04 22:03:15 Start =====
===== 2026-10-04 22:31:15 Start =====
先读这段日志:同一个「21:45:13」触发了五次。这本身就是最大的线索。
Start 是脚本第一行打印的,它出现了,说明「宝塔收到请求 → 拉起脚本 → 脚本开始跑」这条路是通的,问题不在 WebHook 配置,而在脚本中途卡住/静默死掉。卡住的位置按概率排:
| 排名 | 卡在哪 | 为什么会卡 |
|---|---|---|
| 1 | git fetch(等你输密码) |
用了 HTTPS 地址,或 SSH key 没配对。git 想问你要账号密码,可 WebHook 没有终端,它就一直等——等到天荒地老 |
| 2 | git fetch(抢 .git/index.lock) |
五条同一秒触发,五个进程同时摸同一个仓库,git 互相等锁 |
| 3 | 报错静默吞掉 | git 的报错走 stderr,宝塔日志只接 stdout,你看到的就是「啥也没有」 |
三条里前两条有一个共同点:脚本一直在往 End 走,但永远走不到。
所以加固版做了三件事(就是上面脚本里那三行看着"多余"的配置):
export GIT_TERMINAL_PROMPT=0+GIT_SSH_COMMAND="ssh -o BatchMode=yes"→ 认证失败立刻报错返回,绝不弹框等待mkdir "$LOCK"原子并发锁 → 同一秒触发 N 次,只跑一个,其余打印[SKIP]直接End退出exec 2>&1→ git 的报错并进日志,再也不会「一片空白」
外加 [STEP1]~[STEP6] 六步面包屑:日志停在 STEP 几,就是卡在第几步,不用再猜。
真排查时,别先改业务代码,先跑一遍探针脚本(bt-webhook-probe.sh,打印 [P0]~[P6] 的环境信息,跑到 Probe End 说明执行链路没问题)。另外记得清掉已经卡住的僵尸进程:
ps -ef | grep -E "git|hook" | grep -v grep
kill -9 上面查出来的进程号
rm -f /www/wwwroot/你的站点目录/.git/index.lock
排错清单:报错基本就这 6 种
| 现象 | 原因 | 一句解决 |
|---|---|---|
WebHook 日志只有 [ERROR] param 为空 |
地址里没带 param,或拼错了参数名 |
在 URL 末尾补 ¶m=站点目录名 |
Permission denied (publickey) |
SSH 公钥没配对,或用了 HTTPS 地址 | 回到 02 步 ssh -T git@gitee.com 验一次 |
detected dubious ownership |
仓库属主和执行者不是一个人 | git config --global --add safe.directory 目录 |
sudo: command not found / sudo 需要 tty |
网上教程习惯性加 sudo,面板里本来就是 root | 把 sudo 全删掉 |
| 返回 200 但代码没变 | 脚本被面板过滤,或者改的不是你拉的分支 | 重新编辑脚本确认;检查 BRANCH 变量 |
只有 Start 没有 End |
git 在等你输密码、或多个进程抢 .git/index.lock |
换加固版脚本(BatchMode + 并发锁),再清 index.lock |
速查表
| 环节 | 关键命令 / 位置 |
|---|---|
| 生成钥匙 | ssh-keygen -t rsa → cat ~/.ssh/id_rsa.pub |
| 配公钥 | Gitee → 设置 → SSH 公钥 |
| 握手验证 | ssh -T git@gitee.com |
| 首次 clone | /www/wwwroot 下 git clone git@… |
| 权限 | chown -R www:www 目录 |
| 放行安全目录 | git config --global --add safe.directory 目录 |
| WebHook 地址 | http://IP:8888/hook?access_key=…¶m=站点目录名 |
| 触发方式 | Gitee/GitHub 的 Push 事件,或手动 curl 那条地址 |
| 看日志 | 宝塔 → WebHook → 日志 |
一句话总结:公钥打通身份,WebHook 打通通知,脚本负责最后那一下 reset --hard。 三样都通了,push 就是上线。
橙码酱,分享更有用的技术知识,助你成为更优秀的开发者。