按道理来说,没人会这么做,因为Github、Netlify、Vercel之类的能保存静态博客的地方太多了,所以网上也没什么教程,起因是我受够了Github,每次仓库Action,保存到Pages,然后main分支就会提醒我落后于gh-pages分支,两个分支又没法合并,而且Github Pages在国内的访问速度懂得都懂。还有一个原因是博客的评论系统从Valine切换到了Waline,保留了以前的所有数据,并且部署到了VPS,这样一来也有理由把博客整个放到VPS上了。
首先考虑要不要保留版本控制功能,如果不需要的话,大概下载一个Hugo的二进制程序,然后自己构建源码,再用一个Web服务器,顺便解决SSL证书,就完全没问题了,但是这样每次更新博客的时候需要ssh到Hugo源码目录编辑,就算是用WebDAV挂载源码目录或者Syncthing同步,也免不了,也需要在本地有hugo的二进制程序,而且出门在外的话,是没有办法很方便写博客的。
最终决定保留版本控制,然后通过git push触发钩子,自动构建,才算是方便的步骤。
然后跟AI大模型较真两天,聊了2.7MB的json,顺便踩坑无数,最后得到了一个满意的结果,遂记录下来。
1、先在VPS上搭建了git服务
services:
forgejo:
image: codeberg.org/forgejo/forgejo:13
container_name: forgejo
environment:
- USER_UID=1000
- USER_GID=1000
restart: always
networks:
- lan
volumes:
- /root/docker-data/forgejo:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- '3000:3000'
- '22:22'
networks:
lan:
external: true
Forgejo相对更自由一些,喜欢Gitea也行,但是Gitea目前商业化比较严重。
这里需要注意的两点,第一是不能用0:0,否则会报错,第二是22端口,这个看个人喜好了,我可不想日后在git的时候还要带上端口,所以我就把VPS的22端口让出来了,本来为了VPS的安全,也是推荐换端口的。如果确定使用22端口的话,建议在VPS上装个fail2ban。
networks这里我是提前建立了一个叫做的lan的网络,会把相关容器都放在这个网络里。
用一个Caddy容器,负责所有域名的转发,自己写一个caddyfile,真的是很方便,比起Nginx来说简单不少,也不用管证书的问题,注意不要用Nginx Proxy Manager之类的,因为将来Hugo构建出public目录会是博客的访问地址,这个文件夹用NPM这种类似代理容器的软件是不行的,如果非要用Nginx,就需要自己写conf文件,还有搞定https访问。
version: "3.7"
services:
caddy:
image: caddy:alpine
container_name: caddy
ports:
- "80:80"
- "443:443"
- "443:443/udp" # If you want HTTP/3
networks:
- lan
volumes:
- /root/docker-data/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- /root/docker-data/caddy/data:/data
restart: unless-stopped
networks:
lan:
external: true
在Forgejo容器跑起来以后,在caddyfile里添加上对应域名方便访问,顺便重启一下Caddy容器:
git.heartnn.com {
reverse_proxy forgejo:3000
}
注册Forgejo的管理员账户,然后将已有的代码push到一个仓库里,假设我的是ssh://git@git.heartnn.com/heartnn/hugo-blog.git,当然先提前配置好SSH公钥和私钥,并把公钥上传到个人信息里。
现在就得到了一个属于自己的git服务,而且Forgejo在手机上的界面也还算友好,写新文章可以直接在Web界面上完成了,当然也可以用git客户端更新。
2、创建钩子
创建钩子的时候其实遇到了很多困难,首先想到的是,把仓库放到自宿主机上,然后通过钩子执行fetch仓库,构建等操作,实际上最难的是文件所有权的问题。
进入容器调试,实际运行脚本的是root用户,而实际上执行钩子的是容器里的git用户,也就是1000:1000,但把宿主机仓库的文件所有权调整成1000:1000,这不是我想要的,因为后续会无法从VPS仓库手动push更新,折腾了半天,果断pass了。
另一种办法就是Webhook,正好找到了一个Docker容器,里面执行操作的用户是0:0,这个感觉是完全可行的,把里面的9000端口通过caddy挂载到https://git.heartnn.com/webhook,在配合复杂的id防止被非法执行。这个方法没有继续下去,主要是因为Webhook必须一直运行等待git push,而且只要是更新仓库,就必须解决ssh证书的问题,或者可以选择从Forgejo的裸仓库里直接把文件git出来,但上面就说了,Forgejo的git用户是1000:1000。
睡了一晚上以后,又有了现在用着的方法,就是在钩子脚本里执行ssh命令到宿主机,然后操作宿主机的脚本,直接git pull或git fetch仓库代码,运行Hugo构建等操作,生成的public目录,交给Caddy容器。这里需要解决的只剩下ssh的公钥和私钥所有权的问题,以及把公钥写入/root/.ssh/authorized_keys中,下面是相关代码,放到裸仓库的hooks/post-receive.d目录里(裸仓库一般在forgejo/git/用户名/仓库名目录里):
#!/bin/sh
set -e
echo "[$(date)] Post-receive hook script (hugo.sh) started."
# --- SSH Configuration ---
SSH_HOST="172.20.20.1" # 这里是你的docker0对应的IP,可以从容器里获得,这里就是lan这个我自建的网络
SSH_PORT="####" # VPS的ssh端口
SSH_USER="root" # VPS用户名
SSH_KEY="/data/git/.ssh/forgejo_hook_key" # 权限600,所有者为1000:1000
SCRIPT_TO_RUN="/root/data/scripts/hugo-build.sh" # 权限755,所有者为1000:1000
SSH_CMD="ssh -i $SSH_KEY -p $SSH_PORT -o IdentitiesOnly=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
# --- Execute build script on host via SSH ---
echo "Triggering build script on host ($SSH_HOST:$SSH_PORT) via SSH..."
$SSH_CMD $SSH_USER@$SSH_HOST "$SCRIPT_TO_RUN"
echo "[$(date)] Post-receive hook script (hugo.sh) finished."
exit 0
3、开始部署
使用git clone在VPS上克隆一个仓库,比如我的在/root/data/git/hugo-blog/里,执行脚本hugo-build.sh放入/root/data/scripts/中,hugo程序放入/root/data/bin/,这样方便管理,另外hugo构建时会产生一个名叫.hugo_build.lock的文件,可以把这个文件加入.gitignore里。
hugo-build.sh的内容:
#!/bin/bash
# /root/data/scripts/hugo-build.sh - 在 VPS 宿主机上由 Forgejo 钩子触发
set -e
# --- 配置 ---
HUGO_WORKING_DIR="/root/data/git/hugo-blog"
HUGO_DEST_DIR="$HUGO_WORKING_DIR/public"
HUGO_BINARY_DIR="/root/data/bin"
HUGO_BINARY_NAME="hugo"
HUGO_BINARY_PATH="$HUGO_BINARY_DIR/$HUGO_BINARY_NAME"
REQUIRED_HUGO_VERSION="0.148.2"
HUGO_DOWNLOAD_URL="https://github.com/gohugoio/hugo/releases/download/v${REQUIRED_HUGO_VERSION}/hugo_${REQUIRED_HUGO_VERSION}_Linux-64bit.tar.gz"
# --- 日志 ---
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
# --- 检查并下载 Hugo ---
check_and_download_hugo() {
if [[ ! -f "$HUGO_BINARY_PATH" ]] || ! "$HUGO_BINARY_PATH" version 2>/dev/null | grep -q "v$REQUIRED_HUGO_VERSION"; then
if [[ -f "$HUGO_BINARY_PATH" ]]; then
log "Hugo 版本不匹配或需要更新。"
else
log "未找到 Hugo 二进制文件。"
fi
log "正在下载 Hugo 版本 $REQUIRED_HUGO_VERSION..."
TEMP_DIR=$(mktemp -d)
if [[ $? -ne 0 ]]; then log "ERROR: 无法创建临时目录"; exit 1; fi
cd "$TEMP_DIR"
if command -v wget >/dev/null 2>&1; then
wget -q "$HUGO_DOWNLOAD_URL" -O hugo.tar.gz
if [[ $? -ne 0 ]]; then log "ERROR: 下载失败"; cd - > /dev/null; rm -rf "$TEMP_DIR"; exit 1; fi
else
log "ERROR: 未找到 'wget'。"; cd - > /dev/null; rm -rf "$TEMP_DIR"; exit 1
fi
tar -xzf hugo.tar.gz "$HUGO_BINARY_NAME"
if [[ $? -ne 0 ]] || [[ ! -f "$HUGO_BINARY_NAME" ]]; then
log "ERROR: 解压失败"; cd - > /dev/null; rm -rf "$TEMP_DIR"; exit 1
fi
mkdir -p "$HUGO_BINARY_DIR"
if [[ $? -ne 0 ]]; then log "ERROR: 无法创建 bin 目录"; cd - > /dev/null; rm -rf "$TEMP_DIR"; exit 1; fi
mv "$HUGO_BINARY_NAME" "$HUGO_BINARY_PATH"
if [[ $? -ne 0 ]]; then log "ERROR: 无法移动二进制文件"; cd - > /dev/null; rm -rf "$TEMP_DIR"; exit 1; fi
chmod +x "$HUGO_BINARY_PATH"
if [[ $? -ne 0 ]]; then log "ERROR: 无法设置权限"; cd - > /dev/null; rm -rf "$TEMP_DIR"; exit 1; fi
cd - > /dev/null
rm -rf "$TEMP_DIR"
log "Hugo v$REQUIRED_HUGO_VERSION 已下载并安装到 $HUGO_BINARY_PATH"
else
log "Hugo v$REQUIRED_HUGO_VERSION 已存在且版本正确"
fi
}
# --- 从 'origin' 同步代码 ---
sync_code() {
log "正在同步 $HUGO_WORKING_DIR 与远程 'origin'..."
if [[ ! -d "$HUGO_WORKING_DIR" ]] || [[ ! -d "$HUGO_WORKING_DIR/.git" ]]; then
log "ERROR: $HUGO_WORKING_DIR 不是有效的 Git 仓库。"
log "请确保它是 git@git.heartnn.com:heartnn/hugo-blog.git 的克隆。"
exit 1
fi
cd "$HUGO_WORKING_DIR"
log "正在获取 'origin/main' 的最新更改..."
git fetch origin main
if [[ $? -ne 0 ]]; then
log "ERROR: 'git fetch origin main' 失败。检查网络/SSH 密钥。"
exit 1
fi
log "正在重置工作目录以匹配 'origin/main'..."
git reset --hard origin/main
if [[ $? -ne 0 ]]; then
log "ERROR: 'git reset --hard origin/main' 失败。"
exit 1
fi
log "代码同步成功。"
}
# --- 执行 Hugo 构建 ---
run_hugo_build() {
log "正在运行 Hugo 构建..."
if [[ ! -x "$HUGO_BINARY_PATH" ]]; then
log "ERROR: Hugo 二进制文件 $HUGO_BINARY_PATH 未找到或不可执行。"
exit 1
fi
"$HUGO_BINARY_PATH" --minify --destination "$HUGO_DEST_DIR" --source "$HUGO_WORKING_DIR"
if [[ $? -eq 0 ]]; then
log "Hugo 构建成功完成。输出在 $HUGO_DEST_DIR"
else
log "ERROR: Hugo 构建失败。"
exit 1
fi
}
# --- 主流程 ---
main() {
log "========== Hugo 构建脚本开始 =========="
check_and_download_hugo
sync_code
run_hugo_build
log "========== Hugo 构建脚本成功结束 =========="
exit 0
}
main "$@"
上面的脚本会在钩子脚本登录到VPS后执行,用Forgejo的仓库覆盖当前的克隆仓库,然后自动下载对应版本的hugo程序,完成构建。
最后,把生成的public对应目录在相应的Caddy容器中映射一下/root/data/git:/git,再在Caddyfile里加上下面内容:
heartnn.com {
redir https://www.heartnn.com{uri} permanent
}
www.heartnn.com {
root * /git/hugo-blog/public
encode zstd gzip
file_server {
hide .git .hg .svn .cvs .bzr _manifest
}
}
最终获得一个Forgejo仓库和一个无延迟部署的静态网站,对应的还有许多更强大的玩法。