仓库地址:https://github.com/magicskysword/NightBlog
前言 一直想拥有一个属于自己的独立博客,用来写点技术和杂谈。
经过一番折腾,最终选择了 Hexo + NexT 主题。
由于该博客使用Claw Cloud Run进行托管,服务商不支持API更新(蛋疼),因此改为采用 Docker 部署基础服务器 + 使用 Github Action 构建网站后主动通知服务器拉取更新的方式。
前置环境
系统:Ubuntu in wsl
编辑器:vscode
node.js:22
docker:最新发行版
一、Hexo 初始化与主题集成 万事开头难,Hexo 提供了简洁的命令行工具,很适合搭建个人博客。
1. 初始化 Hexo 博客 1 2 3 4 npm install -g hexo-cli hexo init blog cd blognpm install
初始化完成后,项目结构如下:
1 2 3 4 5 6 7 blog/ ├── _config.yml # 全局配置 ├── package.json # Node.js 项目文件 ├── source/ # 文章与静态资源目录 ├── themes/ # 主题目录 ├── scaffolds/ # 文章模板 └── public/ # 构建输出目录
2. 集成 NexT 主题 我选择了 NexT 主题,比较顺眼,对中文支持较好。
1 2 3 4 5 6 cd themesgit clone https://github.com/theme-next/hexo-theme-next next cd nextrm -rf .git
配置 _config.yml
指定主题:
3. Docker配置 为了方便开发,配置了一套本地构建的开发环境Docker
由于我初学Docker,这部分基本都使用AI生成,赞叹AI的伟大。
Dockerfile
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 FROM node:22 # 设置工作目录 WORKDIR /app # 安装 pnpm RUN npm install -g pnpm # 复制包管理配置文件 COPY package.json pnpm-lock.yaml ./ # 安装主项目依赖(包含 hexo、主题依赖声明) RUN pnpm install # 复制所有源代码 COPY . . # 单独为 NexT 主题安装依赖 RUN if [ -f themes/next/package.json ]; then \ cd themes/next && pnpm install || true; \ fi # 构建静态页面 RUN pnpm run build # 启动本地 Hexo 服务器 EXPOSE 4000 # 如果设置了 BUILD_ONLY=true,则容器启动时什么也不做 # 否则,启动 Hexo 本地服务器 CMD ["/bin/sh", "-c", "if [ \"$BUILD_ONLY\" != \"true\" ]; then pnpm run server; else echo 'Build only mode. Nothing to run.'; fi"]
docker-compose.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 version: '3' services: # 本地开发:启动 hexo server hexo: build: . ports: - "4000:4000" volumes: - ./source:/app/source - ./themes:/app/themes - ./_config.yml:/app/_config.yml command: pnpm run server # CI 构建服务:只构建静态文件 hexo-builder: build: . environment: - BUILD_ONLY=true volumes: - ./public:/app/public # 导出构建结果 command: /bin/true # 什么都不做,避免执行 CMD 中的 server
第一篇帖子与构建 使用以下命令生成第一篇帖子。
1 hexo new post HelloWorld
使用以下指令构建博客
到这里博客搭建部分就结束了。
博客自动化部署 接下来我打算使用 node.js 制作一个docker image部署到服务器上。核心步骤如下:
graph TD
subgraph 博客流程
A[本地编写博客] --> B[推送到Github]
B --> C[Github构建静态页面]
C --> D[发布为Release]
D --> E[通知云服务器下载]
end
subgraph 云服务器流程
F[接受通知] --> G[下载最新Release]
G --> H[部署并更新]
end
E --> F
1.编写 server.js 和 deploy.js server.js
的作用就是挂载博客页面和监听更新
server.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 const express = require ('express' );const path = require ('path' );const { exec } = require ('child_process' );const fs = require ('fs' );const { downloadAndExtract } = require ('./deploy' );const app = express ();require ('dotenv' ).config ();const PORT = process.env .PORT || 3000 ;const TOKEN = process.env .UPDATE_TOKEN ;const BLOG_ZIP_URL = process.env .BLOG_ZIP_URL ;const DEPLOY_DIR = process.env .DEPLOY_DIR || path.join (__dirname, 'public' );app.use ('/' , express.static (DEPLOY_DIR )); app.post ('/api/update' , async (req, res) => { const reqToken = req.query .token ; if (reqToken !== TOKEN ) { return res.status (403 ).json ({ message : 'Forbidden: Invalid token' }); } try { console .log ('Triggered update via API.' ); await downloadAndExtract (BLOG_ZIP_URL , DEPLOY_DIR ); res.json ({ message : 'Update successful' }); } catch (err) { console .error ('Update failed:' , err); res.status (500 ).json ({ message : 'Update failed' , error : err.message }); } }); app.listen (PORT , '0.0.0.0' , async () => { console .log (`Server running at http://localhost:${PORT} ` ); console .log ('Performing initial deployment...' ); try { await downloadAndExtract (BLOG_ZIP_URL , DEPLOY_DIR ); console .log ('Initial deployment complete.' ); } catch (err) { console .error ('Initial deployment failed:' , err); } });
deploy.js
的作用是从GitHub下载文件。
需注意,此处使用了 follow-redirects
库替代了内置的http
库,是因为GitHub的文件链接存在一次重定向,而内置的库不支持重定向。
deploy.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 const { https } = require ('follow-redirects' );const fs = require ('fs' );const path = require ('path' );const AdmZip = require ('adm-zip' );async function downloadAndExtract (url, targetDir ) { const tmpZip = path.join ('/tmp' , 'blog.zip' ); console .log (`Downloading ${url} ...` ); await new Promise ((resolve, reject ) => { const file = fs.createWriteStream (tmpZip); https.get (url, (response ) => { if (response.statusCode !== 200 ) { return reject (new Error (`Failed to download: ${response.statusCode} ` )); } response.pipe (file); file.on ('finish' , () => { file.close ((err ) => { if (err) return reject (err); resolve (); }); }); }).on ('error' , reject); }); const stats = fs.statSync (tmpZip); console .log (`Downloaded zip size: ${stats.size} bytes` ); if (fs.existsSync (targetDir)) { console .log (`Removing existing directory: ${targetDir} ` ); fs.rmSync (targetDir, { recursive : true , force : true }); } fs.mkdirSync (targetDir, { recursive : true }); console .log (`Extracting to ${targetDir} ...` ); const zip = new AdmZip (tmpZip); zip.extractAllTo (targetDir, true ); fs.unlinkSync (tmpZip); console .log ('Deploy complete.' ); } module .exports = { downloadAndExtract };
对应的package.json
package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { "name": "blog-deployer", "version": "1.0.0", "description": "Webhook-based blog deployer for GitHub Actions", "main": "server.js", "scripts": { "start": "node server.js" }, "author": "your-name", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", "dotenv": "^16.4.1", "express": "^4.19.2", "follow-redirects": "^1.15.11", "unzipper": "^0.10.11" } }
2.编写Docker 这一步是为了把环境打包成一个Docker,方便后续挂载到云服务器上。
Dockerfile
1 2 3 4 5 6 7 8 9 10 11 12 FROM node:18 WORKDIR /app COPY package*.json ./ RUN npm install COPY server.js deploy.js ./ EXPOSE 3000 CMD ["node", "server.js"]
docker-compose.yml
1 2 3 4 5 6 7 8 services: deployer: build: . container_name: blog-deployer ports: - "3000:3000" env_file: - .env
3.Github Action自动化 新建一个文件 .github/workflows/build.yml
,该文件用于 Github 上的自动化。
我的目的是修改了deployer目录里的文件时,自动打新的包。而 Github Action,对我而言并不是特别熟悉。
因此我选择把我的流程描述给 LLM,让 LLM 帮我来编写
Action的主要功能如下:
blog目录变动时,打包博客页面,并让云服务器自动拉取更新。
deploy目录变动时,构建新的docker镜像。
build.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 name: Build and Release Blog + Deployer on: push: branches: - main paths: - 'blog/**' - 'deployer/**' - '.github/workflows/**' workflow_dispatch: permissions: contents: write packages: write jobs: build-blog: name: Build and Release Blog runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 with: fetch-depth: 0 - name: Detect blog directory change id: changes uses: dorny/paths-filter@v3 with: filters: | blog: - 'blog/**' - name: Exit if no blog changes if: steps.changes.outputs.blog != 'true' run: echo "No changes in blog/, skipping build." - name: Set up Git identity if: steps.changes.outputs.blog == 'true' run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Set up Node.js if: steps.changes.outputs.blog == 'true' uses: actions/setup-node@v3 with: node-version: 22 - name: Install pnpm if: steps.changes.outputs.blog == 'true' run: npm install -g pnpm - name: Install dependencies if: steps.changes.outputs.blog == 'true' working-directory: blog run: | pnpm install if [ -f themes/next/package.json ]; then cd themes/next pnpm install || true fi - name: Build static site if: steps.changes.outputs.blog == 'true' working-directory: blog run: pnpm run build - name: Check built files if: steps.changes.outputs.blog == 'true' run: ls -R blog/public - name: Zip built blog if: steps.changes.outputs.blog == 'true' working-directory: blog/public run: zip -r $GITHUB_WORKSPACE/blog-release.zip . - name: Force-update blog-main tag if: steps.changes.outputs.blog == 'true' run: | git tag -f blog-main git push origin blog-main --force - name: Create or Update GitHub Release if: steps.changes.outputs.blog == 'true' uses: softprops/action-gh-release@v1 with: tag_name: blog-main name: Blog Release (main) files: blog-release.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Trigger Remote Deploy via Webhook if: steps.changes.outputs.blog == 'true' run: | curl -X POST "${{ secrets.DEPLOY_WEBHOOK_URL }}?token=${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ || echo "⚠️ Webhook failed, continuing anyway." build-deployer: name: Build and Push Deployer Image runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref_type != 'tag' steps: - name: Checkout code uses: actions/checkout@v3 - name: Detect deployer directory change id: changes uses: dorny/paths-filter@v3 with: filters: | deployer: - 'deployer/**' - name: Log in to GitHub Container Registry if: steps.changes.outputs.deployer == 'true' uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image if: steps.changes.outputs.deployer == 'true' uses: docker/build-push-action@v5 with: context: ./deployer file: ./deployer/Dockerfile push: true tags: ghcr.io/${{ github.repository_owner }}/deployer:latest
总结 这次博客搭建的过程蛮曲折的,中途有各种报错,各种各种小问题。
但是现在的大语言模型还是太强大了,一点一点使用大模型辅助排查报错,在无人指导的情况下还是顺利搭建完成了。