用 Docker 多阶段构建部署 Docusaurus 静态站点
Docusaurus 构建出来是一堆纯静态文件,部署本质上就是「找个能托管静态文件的服务」。但直接把 build 产物丢到服务器上手动同步,既不优雅也容易出错。用 Docker 把构建和托管封装到一个镜像里,换机器部署只要 docker compose up,省心很多。
下面是我这个博客站点实际的部署方式。
整体思路
两阶段构建:
- builder 阶段:用
node:22-alpine跑npm ci && npm run build,产出build/静态文件。 - serve 阶段:把
build/拷进nginx:alpine,用 nginx 托管。
最终镜像里没有 Node、没有源码、没有 node_modules,只有 nginx + 静态文件,体积大概 50MB 出头。
Dockerfile
# ==================== Stage 1: Build ====================
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ==================== Stage 2: Serve ====================
FROM nginx:alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
几个细节:
- 先
COPY package.json package-lock.json再npm ci,最后才COPY . .。这样只要依赖没变,npm ci那一层就命中缓存,改文章重新构建只要几秒。 - 用
npm ci而不是npm install,保证依赖按 lockfile 精确安装,CI 里可复现。 package-lock.json必须提交进仓库,否则npm ci直接报错。
nginx 配置要点
Docusaurus 是 SSG(静态站点生成),不是 SPA。这意味着每个路由在 build/ 里都有对应的 index.html,不需要 SPA 那种「所有路径回退到 index.html」的逻辑。
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
absolute_redirect off;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json
application/javascript application/xml+rss
application/atom+xml image/svg+xml;
location / {
try_files $uri $uri/ =404;
}
location ~* \.(?:js|css|woff2?|ttf|eot|png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
error_page 404 /404.html;
}
关键点:
try_files $uri $uri/ =404:先找精确文件,再找目录(落到index.html),都没有就 404。千万别写成try_files $uri $uri/ /index.html,那是 SPA 回退,会让 Docusaurus 的 404 页失效、所有错误路径都返回首页。- HTML 不缓存:文章更新后能立刻生效;而带 hash 的 JS/CSS 设置长期不可变缓存,再访问直接走浏览器缓存。
absolute_redirect off:nginx 目录重定向默认会用绝对地址,开了这个更可控。- gzip:把
application/atom+xml也加上,RSS feed 才会被压缩。
踩过的坑
1. npm ci 报缺 package-lock.json
最初我图省事没提交 lockfile,CI 里 npm ci 直接挂了。npm ci 是严格模式,没有 lockfile 不干活。lockfile 必须进版本控制。
2. 容器里访问 404 但本地 docusaurus serve 正常
本地 npm run serve 用的是 Docusaurus 内置服务,带 SPA 回退;而 nginx 是纯静态。如果构建时有 broken link,本地可能看不出来,nginx 下就是 404。所以 docusaurus.config.ts 里我把 onBrokenLinks 设成 'throw',构建阶段就直接挂掉,不留到部署。
3. 构建产物路径
Docusaurus 默认输出到 build/,baseUrl 决定了产物里的子路径。我这个站点 baseUrl: '/',所以 build/ 直接就是 web root,COPY --from=builder /app/build /usr/share/nginx/html 刚好对上。如果 baseUrl 不是 /,nginx 的 root 和 location 都要相应调整。
4. 前置反代偶发 504
容器跑起来后,外面套一层 nginx-ui 反向代理做 TLS。一开始偶发 504,排查下来是容器 mem_limit 太紧、峰值时 nginx worker 被挤掉,反代侧表现为超时。把内存上限放宽、给静态资源开长缓存之后就好了。反代那侧 proxy_read_timeout 设 30s,足够了。详见我另一篇 排查 nginx 反向代理 502/504 错误。
构建与运行
docker compose up -d --build
docker-compose.yml 里把容器 80 映射到宿主机 8080,再由 192.168.1.3 上的 nginx-ui 反代到 https://www.anderslane.cn。整条链路:浏览器 → nginx-ui (TLS) → 容器 nginx (静态文件)。
小结
多阶段构建 + nginx 托管静态文件,是 SSG 站点最干净的部署方式之一:构建环境和运行环境彻底分离,镜像小、启动快、无状态。唯一要记住的是 SSG 不等于 SPA,别用 SPA 的回退逻辑。