Skip to content

从零搭建个人博客: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: 404

webroot 模式只往磁盘目录里丢挑战文件,由容器那层的 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
Awww你的服务器 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.confstream 块里做 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 天才被发现。

教训有两条:

  1. network_mode: host 的容器会和宿主机服务抢端口,而且 SO_REUSEPORT 先到先得,后来者只会静默失败。部署这类容器前先 ss -lntp 看一眼。
  2. 给 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 分钟,主要步骤:

  1. ✅ 安装 VitePress
  2. ✅ 创建项目结构
  3. ✅ 配置主题和导航
  4. ✅ 配置 Nginx
  5. ✅ 申请 HTTPS 证书
  6. ✅ 配置 DNS
  7. ✅ 发布文章

但真正花时间的不是搭建,是后面这些:端口被别的服务抢走缓存头写在了永远不会命中的 location 上CDN 缓存了故障期间的坏响应。三个问题叠加起来,表现出来就是一句「博客打不开了」和一句「点链接页面没变化」。

如果只从这篇文章里带走三件事:

  • 静态站没有 5xx、没有异常日志,进程死了外部完全无感,一定要加存活监控
  • cleanUrls + try_files 下,别用扩展名正则给 HTML 挂缓存头
  • 套了 CDN 之后,curl 你自己的域名验证不了任何事

技术栈:

  • VitePress(静态站点生成)
  • Nginx(两层:容器终止 TLS + 宿主机出静态文件)
  • Let's Encrypt(HTTPS 证书,webroot 续期)
  • 腾讯云 EdgeOne(CDN)

成本:

  • 服务器:已有
  • 域名:已有
  • HTTPS:免费(Let's Encrypt)
  • 总成本:0 元

如果你也想搭建个人博客,可以按照本文的步骤操作。有问题欢迎留言交流!

用代码记录成长 · RSS 订阅 · 标签