为什么你需要一套可回滚的部署流程
个人站长最常见的上线方式是这样的:用 FTP 或者 SFTP 客户端连上服务器,把修改好的文件拖上去,覆盖旧的,然后刷新浏览器看看有没有报错。这套流程在小站、单人、低频更新的阶段确实够用。但它有一个致命缺陷——没有回滚能力。
当你在半夜改了一个 CSS 或者一个 PHP 文件,把首页改崩了,你能做什么?凭记忆把文件改回来?如果你的编辑器没有本地备份呢?如果你同时改了七个文件、记不清改了哪几个呢?
这篇文章讲的是个人站长也能轻松落地的方案:用 Git 管理代码 + Git Hook 实现 push 即上线,并且每次发布都自动留一个可一键回滚的快照。整套方案不依赖任何 CI 服务、不需要服务器之外的账号,服务器上只要装了 git 就能跑。
整体架构
核心思路是「裸仓库 + 检出目录」:
服务器上的目录结构:
/opt/repo/site.git ← 裸仓库(bare),只存版本历史,不存工作文件
你本地 push 的目标
/www/site ← 检出目录(work tree),真正被 Nginx 指向的目录
由 post-receive 钩子自动更新
/opt/backups/site ← 每次发布前自动打的 tar 快照,用于快速回滚本地 push 到 site.git,钩子脚本自动把内容检出到 /www/site,并更新权限、清理缓存。整个过程一两秒完成,比 FTP 上传还快,而且每一次上线都有完整的 Git 记录和物理快照。
第一步:服务器上初始化裸仓库
# 在服务器上执行
mkdir -p /opt/repo /opt/backups /www
cd /opt/repo
git init --bare site.git
# 关键:让裸仓库知道工作目录在哪
git --git-dir=/opt/repo/site.git config core.bare false
git --git-dir=/opt/repo/site.git config core.worktree /www/site
# 建好检出目录
mkdir -p /www/site
# 推荐:设置接收后不自动合并(我们靠钩子手动 checkout)
git --git-dir=/opt/repo/site.git config receive.denyCurrentBranch ignore关于 core.bare false 和 core.worktree:git init --bare 创建的仓库默认是「没有工作区」的,直接 checkout 会报 fatal: this operation must be run in a work tree。把 core.bare 改成 false 并指定 core.worktree,就等于把这个裸仓库变成「工作区在别处的普通仓库」,钩子里就能直接 git checkout -f 了。
第二步:写 post-receive 钩子
post-receive 是 Git 在裸仓库收到推送之后自动执行的脚本。它从 stdin 接收推送信息,每一行格式是 旧SHA 新SHA 引用名。
这里有个 Git 的经典陷阱:钩子脚本的工作目录是 $GIT_DIR(也就是裸仓库本身),而 GIT_DIR 环境变量在钩子里是被设置好的。如果你直接在里面执行 git checkout,它会尝试在裸仓库里操作,报错或者干出奇怪的事。正确做法是显式用 --git-dir 和 --work-tree 参数,或者先把 GIT_DIR 清空。
#!/bin/bash
# /opt/repo/site.git/hooks/post-receive
set -e
GIT_DIR_REAL="/opt/repo/site.git"
WORK_TREE="/www/site"
BACKUP_DIR="/opt/backups/site"
LOG_FILE="/var/log/deploy.log"
KEEP_BACKUPS=10
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
# 读取推送信息
while read oldrev newrev refname; do
BRANCH=$(echo "$refname" | sed 's|refs/heads/||')
log "接收到推送: 分支=$BRANCH 旧=$oldrev 新=$newrev"
# 只处理主分支
if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
log "跳过非主分支: $BRANCH"
continue
fi
# ---------- 发布前快照 ----------
mkdir -p "$BACKUP_DIR"
if [ -n "$(ls -A "$WORK_TREE" 2>/dev/null)" ]; then
SNAP="$BACKUP_DIR/$(date '+%Y%m%d-%H%M%S')-${oldrev:0:8}.tar.gz"
tar -czf "$SNAP" -C "$WORK_TREE" . 2>/dev/null || true
log "已创建快照: $SNAP"
fi
# ---------- 检出新版本 ----------
git --git-dir="$GIT_DIR_REAL" --work-tree="$WORK_TREE" \
checkout -f "$BRANCH" 2>&1 | tee -a "$LOG_FILE"
# ---------- 修正权限 ----------
# 注意:不要无脑 chown -R,几万个文件会很慢
chown -R www-data:www-data "$WORK_TREE" 2>/dev/null || true
find "$WORK_TREE" -type d -exec chmod 755 {} \; 2>/dev/null || true
find "$WORK_TREE" -type f -exec chmod 644 {} \; 2>/dev/null || true
# ---------- 清理旧快照,只保留最近 N 个 ----------
ls -1t "$BACKUP_DIR"/*.tar.gz 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f
log "发布完成: $newrev"
done
exit 0授予执行权限,这一步不能忘:
chmod +x /opt/repo/site.git/hooks/post-receive
touch /var/log/deploy.log && chmod 664 /var/log/deploy.log第三步:本地配置与首次推送
在服务器上为部署创建一个专用 SSH key(比用 root 密码推送安全得多):
# 服务器上:为部署建一个受限用户(可选但推荐)
useradd -m -s /bin/bash deploy
mkdir -p /home/deploy/.ssh
# 把你本地的公钥写进去
vim /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
# 让 deploy 用户有权限写入仓库和站点目录
chown -R deploy:deploy /opt/repo/site.git /opt/backups/site
# /www/site 需要 www-data 读、deploy 写
chown -R deploy:www-data /www/site
chmod -R 775 /www/site
# 允许 deploy 执行 chown(钩子里用到了)
echo "deploy ALL=(ALL) NOPASSWD: /bin/chown, /usr/bin/find" \
> /etc/sudoers.d/deploy-deploy
chmod 440 /etc/sudoers.d/deploy-deploy本地仓库配置远程地址:
# 本地执行
cd ~/projects/mysite
git init
git remote add deploy ssh://deploy@your-server-ip/opt/repo/site.git
# 首次推送
git add -A
git commit -m "初始化站点"
git push deploy main推送完成后看服务器上的 /var/log/deploy.log,应该能看到完整的「接收 → 快照 → 检出 → 权限 → 完成」流程。同时 ls /www/site 应该已经是你的站点文件了。
回滚:三种粒度
有了上面这套结构,回滚变得非常轻松。按场景分三种:
粒度一:回滚到上一个 commit(最常用)。 在服务器上执行:
cd /opt/repo/site.git
git --git-dir=/opt/repo/site.git --work-tree=/www/site \
checkout -f HEAD~1
# 但这样是「游离 HEAD」状态,下次 push 会被覆盖
# 更规范的做法是在本地操作:
# git revert HEAD (生成一个反向提交,历史可追溯)
# git reset --hard HEAD~1 && git push -f deploy main (丢弃提交)推荐用 git revert 而不是 reset --hard,因为 revert 会生成一个新的提交记录「撤销了某某改动」,历史完整可查;而 reset + force push 会丢失提交,万一你第二天又想把改动捡回来就很麻烦。
粒度二:从物理快照恢复(改动没进 Git 时的救命稻草)。 比如你直接在服务器上改了文件、忘了提交,或者某个文件被误删:
# 看有哪些快照
ls -lt /opt/backups/site/
# 先解压到临时目录预览,别直接覆盖
mkdir -p /tmp/snapshot-check
tar -xzf /opt/backups/site/20260920-031500-a1b2c3d4.tar.gz -C /tmp/snapshot-check
# 确认无误后,只恢复需要的文件
cp /tmp/snapshot-check/wp-config.php /www/site/
# 或者全量恢复
rsync -av --delete /tmp/snapshot-check/ /www/site/粒度三:单文件回滚。 只想把某一个文件恢复到某个历史版本:
# 查看这个文件的修改历史
git --git-dir=/opt/repo/site.git log --oneline -- /www/site/index.php
# 取出版本 a1b2c3d 里的 index.php
git --git-dir=/opt/repo/site.git show a1b2c3d:index.php > /www/site/index.php钩子里还要做什么
上面的钩子只做了「检出 + 权限 + 快照」。实际生产中,根据站点类型还应该加上这些动作:
# 1. PHP 站点:重置 OPcache(否则改了代码还是跑旧的)
if command -v php >/dev/null 2>&1; then
php -r 'function_exists("opcache_reset") && opcache_reset();' 2>/dev/null || true
fi
# 更稳的做法是通过一个受保护的 HTTP 端点触发:
# curl -s -H "X-Deploy-Token: $TOKEN" https://example.com/_deploy/opcache-reset
# 2. 容器化部署:重建并重启
# docker compose -f /opt/app/docker-compose.yml up -d --build
# 3. 静态站点:清理 Nginx 缓存
# find /var/cache/nginx -type f -delete && systemctl reload nginx
# 4. 依赖安装:只有 composer.lock / package.json 变了才执行
if git --git-dir="$GIT_DIR_REAL" diff --name-only "$oldrev" "$newrev" | grep -q '^composer.lock$'; then
cd "$WORK_TREE" && composer install --no-dev --optimize-autoloader --no-interaction
fi
# 5. 数据库迁移:只在需要时执行,且要先备份
if git --git-dir="$GIT_DIR_REAL" diff --name-only "$oldrev" "$newrev" | grep -q '^migrations/'; then
mysqldump -uroot -p"$DBPASS" mydb > "$BACKUP_DIR/db-$(date +%s).sql"
cd "$WORK_TREE" && php migrate.php
fi第 4、5 条用到了 git diff --name-only $oldrev $newrev——这是在钩子里判断「这次推送改了哪些文件」的标准手法,比每次都全量执行依赖安装高效得多,也避免每次上线都跑一遍本来不需要的迁移。
踩坑清单
- 钩子没执行权限。 最常见的问题,
chmod +x别忘。而且post-receive脚本本身如果是从 Windows 传过来的,可能带 CRLF 换行,会报bad interpreter: No such file or directory。用dos2unix或者sed -i 's/$//' post-receive处理一下。 - 推送成功但站点没变化。 先看
/var/log/deploy.log有没有记录。没记录说明钩子没跑(权限问题);有记录但内容没变,检查core.worktree是否指向了正确的目录。 - 文件权限错乱导致 403。 钩子以 deploy 用户运行,检出的文件属主是 deploy。Nginx 通常以 www-data 运行,必须 chown/chmod。如果用了
umask,可以在钩子开头加umask 022。 receive.denyCurrentBranch报错。 如果你没设core.bare false,直接建了普通仓库当远程,push 时会被拒绝。按本文第一步配置即可。- 裸仓库目录别放在 web 根目录下。 有人把
site.git放在/www里面,结果https://example.com/site.git/config能被公网访问,泄露仓库配置。仓库一定要放在 web 根之外(本文用的是/opt/repo),或者在 Nginx 里显式 deny 掉\.git。 - 大文件别进 Git。 上传目录、日志、缓存、node_modules 全部写进
.gitignore。Git 仓库一旦被塞进几百兆的图片,后续所有操作都会变慢,而且这个体积永远留在历史里删不掉。
# 站点根目录里的 .gitignore 示例
/uploads/
/cache/
/logs/
node_modules/
vendor/
*.log
.env
wp-config.php特别注意 .env 和 wp-config.php 这类含数据库密码的文件。它们绝对不该进 Git。正确做法是把这些文件留在服务器上、不纳入版本控制;或者提交一个 .env.example 模板,实际配置在服务器上手工维护。
让部署更安全:Nginx 层面兜底
即使你再小心,也建议在 Nginx 里加一条兜底规则,禁止访问所有点开头的隐藏目录:
location ~ /\.(?!well-known) {
deny all;
access_log off;
log_not_found off;
return 404;
}(?!well-known) 是负向先行断言,作用是放行 /.well-known/(Let's Encrypt 验证、ACME 挑战需要用到),其余所有 /.git、/.env、/.htaccess 一律 404。这条规则成本极低,但能挡掉一整类因为误放文件而导致的源码泄露事故。
小结
整套方案的核心价值不在于「自动化」本身,而在于可回滚。上线不再是不可逆的操作,而是有 Git 记录、有物理快照、有单文件恢复三条路可选的过程。有了这一层保障,你会发现自己改代码的胆子大了很多——敢重构、敢试新配置,因为最坏情况也只是敲一条命令回到十分钟前。
实施顺序建议:先搭好裸仓库和最简单的 post-receive(只做 checkout + 权限),跑通一次完整的 push 上线;确认没问题之后再逐步加入快照、OPcache 重置、依赖安装这些增强项。一次只加一个变量,出问题时才知道是谁引起的。
还有一个容易被忽略的收益:当部署流程被写成脚本、放进 Git 之后,它本身就是一份可执行的文档。半年后你换了服务器、重装系统,只需要照着这几步重新执行一遍,就能把整条流水线复刻出来,而不用去回忆当初到底在面板上点过哪些按钮、改过哪些配置项。这也正是「基础设施即代码」这个说法对个人站长同样适用的原因——规模不重要,确定性才重要。
最后提醒一句:任何自动化部署在正式使用之前,都值得在一台测试机上完整演练一次,包括「故意推一个坏版本再回滚」这个动作。只有真正演练过回滚,你才能在事故发生的那几分钟里不慌。备份策略的价值从来不是体现在顺利的时候,而是体现在最糟糕的那一天。