ZVIOLIN
工程部署

Astro SSR + Docker 搭建个人站点:从选型到容器化部署

记录本站的完整技术方案:为什么选 Astro 而不是 Next.js、SSR 模式的内容管理设计、Docker 多阶段构建,以及部署过程中踩过的真实坑

你现在看到的这个站点,前后折腾了十几轮。最早是一个纯静态博客,后来想要留言板、想要 AI 问答、想要后台能改文章,就一路演变成了现在的形态:Astro SSR + SQLite + Docker 单容器方案。这篇把整个技术方案和踩过的坑完整记录一遍,给同样想搭个人站的同学一个能抄的作业。

为什么是 Astro

选型的时候把主流方案都过了一遍。Hugo 那类纯静态生成器构建快,但想加动态功能就得另起炉灶;Next.js 全能但对内容站来说太重了,我一个个人博客跑个 RSC 着实没必要。最后选 Astro 主要三个原因:

一是内容优先。markdown 在 Astro 里是一等公民,frontmatter 类型校验都是现成的;二是默认零 JS,页面输出纯 HTML,交互组件按需以岛屿形式 hydrate,Lighthouse 分数躺着拿;三是它允许静态和 SSR 共存——内容页保持轻,需要后台和 API 的部分用 @astrojs/node 适配器走 SSR。

当时也犹豫过要不要直接上 Next.js,毕竟生态摆在那。但个人站点的核心诉求是「内容为主、维护成本低」,Astro 恰好把这两点做到极致,就它了。

整体架构

一个容器跑完所有东西:

浏览器
  │
  ▼
Astro SSR (Node adapter, :4321)
  ├── 内容层:src/content/**/*.md(gray-matter 解析)
  ├── API 层:src/pages/api/*(评论、点赞、后台管理)
  └── 数据层:SQLite (/app/data/zviolin.db, WAL 模式)
  │
  ▼
Docker 容器 (zviolin-site)

两个设计决策说一下。内容即文件:博客、项目、页面配置全部是 src/content 下的 markdown,好处是内容本身进 Git 版本管理,后台编辑器说到底只是文件读写的一层皮。动态数据进 SQLite:评论、点赞这类运行时产生的数据走 WAL 模式的 SQLite,不碰文件系统,容器重建数据也不丢(挂 volume)。

SSR 模式下让内容"热更新"

这里有个坑我印象很深。Astro 默认在 astro build 时把 content collection 快照进构建产物——也就是说,运行时改 markdown 文件,页面纹丝不动。我第一次做后台编辑功能时,保存成功、文件确实写了,页面就是不更新,盯着看了半天还以为是缓存问题。

解法是绕开 content collections 的构建期快照,在运行时直接读文件:

import fs from 'node:fs';
import path from 'node:path';
import matter from 'gray-matter';

const CONTENT_DIR = path.resolve(process.cwd(), 'src/content');

export function listBlogPosts() {
    const dir = path.join(CONTENT_DIR, 'blog');
    return fs
        .readdirSync(dir)
        .filter((f) => f.endsWith('.md'))
        .map((file) => {
            const raw = fs.readFileSync(path.join(dir, file), 'utf-8');
            const { data, content } = matter(raw);
            return { slug: file.slice(0, -3), ...data, body: content };
        })
        .sort((a, b) => new Date(b.date) - new Date(a.date));
}

配套的后台编辑 API 加了三层防御:路径白名单(只允许 blog/pages/projects 三个目录下的 .md,先 normalize 再校验,杜绝目录穿越)、文件名规范(slug 只许 [a-zA-Z0-9_-],和前台路由一致)、session 认证(所有写操作过中间件)。

Docker 化

多阶段构建没什么花哨的:

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/src/content ./src/content
EXPOSE 4321
CMD ["node", "dist/server/entry.mjs"]

有个细节值得说:src/content 单独 COPY 进运行镜像。因为内容是运行时热读的,不进产物 bundle,这个 COPY 就是内容层的代价。SQLite 数据文件挂 volume(./data:/app/data),compose 里 restart: unless-stopped,基本不用管。

真实踩过的坑

这部分是全文最值钱的,每一条都实打实消耗过排查时间。

Astro checkOrigin 拦截无 Content-Type 的请求。 Astro 4.9+ 默认开 CSRF 防护,POST/DELETE 请求不带 Content-Type 头会直接 403,报错还很迷惑:"Cross-site form submissions forbidden"。我用 curl 测评论接口时被咬了半天——前端 fetch 发 JSON 时浏览器自动带头,所以线上没感知,纯测试工具踩坑。解法就是测试时记得带 -H "Content-Type: application/json"。

改了源码页面不生效。 SSR 不等于热更新。.astro、.ts、markdown 的任何改动都要重新构建镜像:docker compose build && docker compose up -d。只重启容器没用——产物在镜像里;宿主机直接改 markdown 也没用——文件是构建时 COPY 进镜像的,容器里看不到。这个坑踩一次就记住了,但第一次真的会怀疑人生。

shiki 高亮器首次初始化慢。 运行时渲染 markdown,shiki 的 createHighlighter 第一次调用要几百毫秒(加载一堆语言定义),首访文章页能明显感觉卡一下。解法是模块级单例惰性初始化,全站只建一次:

let rendererPromise: Promise<MarkdownIt> | null = null;

function getRenderer() {
    if (!rendererPromise) rendererPromise = createRenderer();
    return rendererPromise;
}

Windows 宿主机路径。 开发机是 Windows、容器是 Linux,内容路径一律在容器内用 path.resolve(process.cwd(), ...) 拼,绝对不硬编码分隔符,基本就不会出问题。

发布流程

现在完整的发布闭环就四步:本地改代码或内容 → docker compose build → docker compose up -d → curl 验证关键页面 200。已经跑了十几个迭代,很稳定。

下一步想加 GitHub Actions,push 完自动构建部署,把最后这四步也省了。到时候再更新这篇。

写在最后

搭个人站点这件事,最大的收益其实不是站点本身——流量多少无所谓,重要的是它是你完全掌控的工程沙盒:渲染器、API、数据库、容器编排,每一层都能按自己的理解重写一遍。这种从零到完整跑起来的工程判断力,比站点的访问量值钱多了。

想动手的话,建议从 Astro 官方 blog template 起步,先上线再迭代。这个站的第一版,也就是一个模板改出来的。