主题
从零搭建个人博客:VitePress + Nginx 完全指南
本文记录了搭建本站点(njcodex.com)的完整过程,从零开始,包含所有踩坑记录。
2026-08-25 更新
本站线上架构在 8 月发生了较大变化,第六步、第七步、第八步和踩坑记录已按当前实际情况重写:
- 443 端口被另一套
network_mode: host的 Docker Nginx 接管,原先的 stream 层 SNI 分流方案已废弃,改为「容器终止 TLS + 宿主机源站」两层结构 - 域名前面加了一层 腾讯云 EdgeOne CDN
- 证书续期从
--nginx改为 webroot
前五步(VitePress 本身的搭建)没有变化。
为什么选择 VitePress?
在搭建个人博客之前,我对比了几个主流方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| WordPress | 功能强大,插件丰富 | 需要数据库,较重 |
| Hexo | 中文社区活跃 | 构建速度一般 |
| Hugo | 构建极快 | 模板语法学习成本 |
| VitePress | 极快、Vue 生态、Markdown | 需要一定前端基础 |
最终选择 VitePress 的理由:
- 🚀 基于 Vite,构建速度极快
- 📝 原生支持 Markdown,写作体验好
- 🎨 默认主题就很美观
- 🔧 高度可定制
环境准备
服务器配置
- 操作系统:Linux (Debian/Ubuntu)
- Web 服务器:Nginx 1.26+
- Node.js:v24.x
- 域名:njcodex.com
检查环境
bash
# 检查 Node.js
node -v # 需要 v18+
# 检查 Nginx
nginx -v
# 检查 certbot(用于 HTTPS)
certbot --version第一步:创建项目
bash
# 创建项目目录
mkdir -p /www/blog && cd /www/blog
# 初始化 npm 项目
npm init -y
# 安装 VitePress
npm add -D vitepress第二步:创建项目结构
bash
# 创建目录结构
mkdir -p docs/.vitepress
mkdir -p docs/public
mkdir -p docs/notes最终目录结构:
/www/blog/
├── docs/
│ ├── .vitepress/
│ │ └── config.mts # VitePress 配置
│ ├── public/
│ │ └── logo.svg # 网站 Logo
│ ├── notes/
│ │ └── index.md # 笔记列表页
│ ├── about.md # 关于我
│ └── index.md # 首页
├── package.json
└── node_modules/第三步:配置 VitePress
创建 docs/.vitepress/config.mts:
typescript
import { defineConfig } from 'vitepress'
export default defineConfig({
title: "NJCodeX",
description: "记录学习与成长",
lang: 'zh-CN',
head: [
['link', { rel: 'icon', href: '/logo.svg' }],
// SEO 相关
['meta', { property: 'og:title', content: 'NJCodeX' }],
['meta', { property: 'og:description', content: '编程笔记、AI 工具' }],
],
themeConfig: {
logo: '/logo.svg',
nav: [
{ text: '🏠 首页', link: '/' },
{ text: '📝 笔记', link: '/notes/' },
{ text: '👤 关于', link: '/about' }
],
sidebar: {
'/notes/': [
{ text: '📋 总览', link: '/notes/' },
{
text: '🤖 Claude Code',
items: [
{ text: '快速入门', link: '/notes/claude-code-quickstart' },
{ text: '快捷键参考', link: '/notes/claude-code-shortcuts' },
]
}
]
},
search: {
provider: 'local' // 本地搜索
},
footer: {
message: '用代码记录成长 🚀',
copyright: '© 2026 NJCodeX'
}
}
})第四步:创建首页
创建 docs/index.md:
markdown
---
layout: home
hero:
name: "NJCodeX"
text: "记录学习与成长"
tagline: "每一次学习,都是一次蜕变 🚀"
image:
src: /logo.svg
alt: Logo
actions:
- theme: brand
text: 📚 开始阅读
link: /notes/
features:
- icon: 🤖
title: Claude Code
details: AI 编程助手的快速入门指南
- icon: ✍️
title: 提示词工程
details: 掌握 AI 提示词技巧
---第五步:构建与预览
bash
# 添加构建脚本到 package.json
# "scripts": {
# "docs:dev": "vitepress dev docs",
# "docs:build": "vitepress build docs",
# "docs:preview": "vitepress preview docs"
# }
# 构建静态文件
npm run docs:build
# 本地预览
npm run docs:preview构建完成后,静态文件会输出到 docs/.vitepress/dist/ 目录。
第六步:配置 Nginx
如果你的服务器上 80/443 是空闲的,一个 server block 就够了,直接跳到本节末尾的「单层写法」。
本站的情况要复杂一点:机器上另有一套面板的 Docker Nginx 以 network_mode: host 运行,独占了宿主机的 80 和 443。它用 SO_REUSEPORT 绑定,宿主机 Nginx 再去 listen 443 是抢不过的,只会启动失败。所以拆成了两层。
第一层:容器 Nginx 终止 TLS
/home/web/conf.d/njcodex.com.conf:
nginx
upstream njcodex_origin {
server 127.0.0.1:4480;
keepalive 32;
}
server {
listen 80;
listen 443 ssl;
listen 443 quic;
server_name njcodex.com www.njcodex.com;
ssl_certificate /etc/nginx/certs/njcodex.com_cert.pem;
ssl_certificate_key /etc/nginx/certs/njcodex.com_key.pem;
# ACME 续期走 webroot,必须放在跳转之前
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
root /var/www/letsencrypt;
}
if ($scheme = http) {
return 301 https://$host$request_uri;
}
location / {
proxy_pass http://njcodex_origin;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}容器是 host 网络模式,所以这里的 127.0.0.1 就是宿主机回环,能直接打到下面那层。
第二层:宿主机 Nginx 出静态文件
/etc/nginx/sites-available/njcodex.com。只监听回环,不对公网暴露:
nginx
server {
listen 127.0.0.1:4480;
server_name njcodex.com www.njcodex.com;
root /www/blog/docs/.vitepress/dist;
index index.html;
# 带 content hash 的构建产物,可以放心长缓存
# ^~ 不能省:正则 location 优先级高于前缀 location,不加会被下面那条抢走
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# docs/public/ 原样复制过来的资源,文件名不带 hash,绝不能 immutable
location ~* \.(png|jpe?g|gif|ico|svg|webp|woff2?|mp4|webm)$ {
add_header Cache-Control "public, max-age=604800" always;
}
location / {
try_files $uri $uri/ $uri.html /index.html;
# 为什么缓存头写在这里而不是 `location ~* \.(html)$`,见下面的坑 2
add_header Cache-Control "no-cache, must-revalidate" always;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
}try_files 里的 $uri.html 这一档是 cleanUrls: true 的命根子——构建产物仍然是 about.html,但站内所有链接都指向 /about,去掉这一档整站 404。
单层写法
如果 443 没被占用,把上面两层合并即可:listen 443 ssl + ssl_certificate + 直接 root 到 dist,缓存 location 原样照抄。
启用配置
bash
ln -sf /etc/nginx/sites-available/njcodex.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
# 两层架构下,容器那层要单独 reload
docker exec nginx nginx -t && docker exec nginx nginx -s reload第七步:申请 HTTPS 证书
首次签发:
bash
certbot certonly --webroot -w /home/web/letsencrypt \
-d njcodex.com \
-d www.njcodex.com \
--non-interactive \
--agree-tos \
--email admin@njcodex.com这里用 --webroot 而不是 --nginx。 certbot 的 nginx 插件会去改宿主机 Nginx 的配置、并要求它监听 80 端口来应答挑战——但 80 归容器管,插件改了个没人听的配置,验证必然失败:
Detail: Invalid response from .../.well-known/acme-challenge/xxx: 404webroot 模式只往磁盘目录里丢挑战文件,由容器那层的 location ^~ /.well-known/acme-challenge/ 直接出文件,绕开了这个问题。
证书同步钩子
容器读不到宿主机的 /etc/letsencrypt,证书是复制进 /home/web/certs/ 的。所以必须加一个 deploy hook,否则续期后容器还在发旧证书,直到过期为止:
/etc/letsencrypt/renewal-hooks/deploy/njcodex-sync.sh
sh
#!/bin/sh
set -e
[ "${RENEWED_LINEAGE#*njcodex.com}" = "$RENEWED_LINEAGE" ] && exit 0
# live/ 下面是指向 archive/ 的软链,必须 cp -L 解引用
cp -L "$RENEWED_LINEAGE/fullchain.pem" /home/web/certs/njcodex.com_cert.pem
cp -L "$RENEWED_LINEAGE/privkey.pem" /home/web/certs/njcodex.com_key.pem
chmod 644 /home/web/certs/njcodex.com_cert.pem
chmod 600 /home/web/certs/njcodex.com_key.pem
docker exec nginx nginx -s reload一定要演练一次,别等到到期那天才发现续期是坏的:
bash
certbot renew --cert-name njcodex.com --dry-run第八步:配置 DNS
在域名注册商处添加 DNS 记录:
| 类型 | 名称 | 值 |
|---|---|---|
| A | @ | 你的服务器 IP |
| A | www | 你的服务器 IP |
等待 DNS 生效(通常几分钟到几小时)。
如果套了 CDN
本站后来在前面加了一层腾讯云 EdgeOne,A 记录指向的是边缘节点,不是源站。这件事对排障影响很大,值得单独记一笔——判断方法是看响应头:
bash
curl -sSI https://www.njcodex.com/ | grep -iE 'eo-cache-status|eo-log-uuid'只要出现 eo-cache-status,说明你 curl 到的是边缘缓存,不能用它来验证源站部署是否成功。正确做法是三层分别打:
bash
# 源站
curl -sSI -H 'Host: www.njcodex.com' http://127.0.0.1:4480/
# 容器 TLS 层
curl -sSI --resolve www.njcodex.com:443:127.0.0.1 https://www.njcodex.com/
# 经 CDN
curl -sSI https://www.njcodex.com/⚠️ 踩坑记录
坑 1:443 端口被 host 网络的 Docker 抢走
最早的方案是在 nginx.conf 的 stream 块里做 SNI 分流:宿主机 Nginx 占着 443,用 $ssl_preread_server_name 按域名把流量分给博客(4443)和 DERP(8443)。这个方案能跑,但很脆——它要求宿主机 Nginx 始终是 443 的所有者。
后来机器上部署了一套面板,它的 Nginx 容器用 network_mode: host 运行,用 SO_REUSEPORT 绑走了 80 和 443。宿主机 Nginx 被 SIGKILL 掉给它腾位置,之后就再也起不来了:
nginx.service: Main process exited, code=killed, status=9/KILL
nginx.service: Failed with result 'signal'而宿主机 Nginx 同时扛着两件事——443 的 SNI 分流、4443 的博客 vhost——它一死两件全断。容器的 conf.d/ 里没有本站配置,default_server 对未知域名直接 return 444,于是整站连接重置。这个状态持续了 4 天才被发现。
教训有两条:
network_mode: host的容器会和宿主机服务抢端口,而且SO_REUSEPORT先到先得,后来者只会静默失败。部署这类容器前先ss -lntp看一眼。- 给 Nginx 加个存活监控。 静态站没有请求日志异常、没有 5xx,进程死了外部完全无感。
排查时最有用的一条命令是分层验证监听状态:
bash
systemctl is-active nginx # 宿主机这层还活着吗
ss -lntp | grep -E ':(80|443)' # 端口到底是谁占的
docker ps --format '{{.Names}}\t{{.Ports}}'坑 2:location ~* \.(html)$ 对 clean URL 永远不生效
这是本站最隐蔽的一个坑,症状是「点导航链接页面不变」。
cleanUrls: true 下,/about 走的是 try_files 的 $uri.html 这一档,nginx 直接把文件读出来返回,不产生内部重定向、不重新匹配 location。所以 location ~* \.(html)$ 这种写法压根不命中,整个页面一个 Cache-Control 都没有。
而 / 和 /notes/ 走的是 $uri/ + index 这一档,会内部重定向到 /index.html,会重新匹配 location,于是正常拿到 no-cache。
结果就是首页有缓存头、文章页没有——一半生效一半不生效,肉眼几乎不可能发现。验证方法:
bash
for u in / /about /tags /notes/; do
printf '%-10s ' "$u"
curl -sSI "https://你的域名$u" | grep -i '^cache-control' || echo '无'
done没有 Cache-Control 时,浏览器会套用启发式缓存(大约是 now - Last-Modified 的 10%),CDN 则套用自己的默认 TTL。于是:
旧 HTML 被缓存 → 它引用的 assets/*.js 已经被新构建换掉了 hash → 请求 404 → VitePress 客户端路由的 import() 静默失败 → 点链接页面不动。而 assets 是 immutable,缓存自己不会过期,这个状态能一直卡着。
正确做法是把缓存头挂在 location / 里(静态资源已经被前面的 location 分流走了,location / 剩下的基本就是 HTML)。另外注意 location ^~ /assets/ 的 ^~ 不能省——正则 location 优先级高于前缀 location,不加 ^~ 会被后面的扩展名正则抢走。
坑 3:CDN 把故障期间的坏响应缓存了下来
源站修好之后,站点看起来还是不正常:首页能开,点进文章是空白。
原因是前面那层 EdgeOne CDN。故障期间它去回源,拿到的是坏响应;又因为坑 2,文章页没有 Cache-Control,CDN 就按自己的默认 TTL 把这些坏响应缓存了下来。源站恢复后,CDN 仍在发缓存里的空页面。
实测对比(同一个 URL,CDN 返回 0 字节,源站返回 39996 字节):
bash
curl -sS -o /dev/null -w '%{size_download}\n' https://www.njcodex.com/notes/xxx
curl -sS -o /dev/null -w '%{size_download}\n' -H 'Host: www.njcodex.com' http://127.0.0.1:4480/notes/xxx修法:给 HTML 加上 no-cache, must-revalidate(坑 2 的修复顺带解决了这个),再去 CDN 控制台刷一次缓存。
更重要的是意识到 CDN 的存在——套了 CDN 之后,curl https://你的域名/ 量到的是边缘缓存,不是你的服务器。所有部署验证都必须绕过 CDN 直接打源站。
坑 4:VitePress 死链接检查
构建时如果报 Found dead link,说明有链接指向不存在的页面。检查:
- 链接路径是否正确
- 文件是否在正确的位置
坑 5:DNS 解析延迟
修改 DNS 后,可能需要等待几分钟到几小时才能生效。可以用以下命令检查:
bash
# 检查 DNS 解析
dig +short njcodex.com A
# 检查全球 DNS 生效情况
# 访问 https://dnschecker.org/第九步:发布文章
bash
# 创建新文章
cat > docs/notes/my-first-post.md << 'EOF'
---
title: 我的第一篇文章
date: 2026-07-19
tags: [入门]
---
# 我的第一篇文章
这是我的第一篇博客文章!
## 内容
写点什么...
EOF
# 重新构建
npm run docs:build总结
初次搭建大约 30 分钟,主要步骤:
- ✅ 安装 VitePress
- ✅ 创建项目结构
- ✅ 配置主题和导航
- ✅ 配置 Nginx
- ✅ 申请 HTTPS 证书
- ✅ 配置 DNS
- ✅ 发布文章
但真正花时间的不是搭建,是后面这些:端口被别的服务抢走、缓存头写在了永远不会命中的 location 上、CDN 缓存了故障期间的坏响应。三个问题叠加起来,表现出来就是一句「博客打不开了」和一句「点链接页面没变化」。
如果只从这篇文章里带走三件事:
- 静态站没有 5xx、没有异常日志,进程死了外部完全无感,一定要加存活监控
cleanUrls+try_files下,别用扩展名正则给 HTML 挂缓存头- 套了 CDN 之后,curl 你自己的域名验证不了任何事
技术栈:
- VitePress(静态站点生成)
- Nginx(两层:容器终止 TLS + 宿主机出静态文件)
- Let's Encrypt(HTTPS 证书,webroot 续期)
- 腾讯云 EdgeOne(CDN)
成本:
- 服务器:已有
- 域名:已有
- HTTPS:免费(Let's Encrypt)
- 总成本:0 元
如果你也想搭建个人博客,可以按照本文的步骤操作。有问题欢迎留言交流!