<- Back

一次 TypeScript 全栈博客部署复盘:为什么它比 Go 后端更折腾

这次部署的是一个用于学习 TypeScript 的个人博客:Fastify API、React/Vite 前端、SQLite + Prisma、pnpm workspace、共享 Zod schema、HttpOnly Cookie 登录。

功能不复杂,但部署过程比一个 Go 后端累很多。回头看,麻烦不是来自某一个 “大问题”,而是来自一串边界叠在一起:前端构建、后端运行时、Prisma 生成代码、 pnpm、Nginx、Docker、Cookie、SQLite 持久化。每个点都不难,但组合起来就很容易 踩坑。

这篇是一次工程复盘,记录问题、原因和最后沉淀下来的部署边界。

最终部署边界

最后稳定下来的方案是:

  • Docker Compose 只负责博客自己的运行环境。
  • api 容器运行 Fastify API。
  • web 容器用 Nginx 托管 Vite 静态资源,并把 /api 反代到 api:3000
  • SQLite 数据库挂载到宿主机 data/draft.db
  • Docker 的 web 容器只绑定 127.0.0.1:18383
  • 宿主机 Nginx 继续管理公网 80 端口和所有域名。
  • 某个明确的博客域名通过宿主机 Nginx 反代到 127.0.0.1:18383

最关键的一条是:不要让博客容器直接监听公网 80,尤其不要在容器 Nginx 里用 server_name _ 作为公网入口。

正确的边界应该是:

Browser
  -> Host Nginx :80
  -> specific blog server block
  -> 127.0.0.1:18383
  -> Docker web Nginx
  -> /api -> Docker api:3000

坑 1:TypeScript 后端不是天然的“部署产物”

Go 后端通常是:

go build -o app
./app

一个二进制里封住了大部分复杂度。

TypeScript 后端默认不是这样。它至少涉及:

  • 源码是 .ts
  • 构建后是 .js
  • 运行依赖在 node_modules
  • ESM/CJS 解析规则会影响运行时路径。
  • Prisma 还会生成额外 client 代码。

这次 API 一开始尝试跑编译产物,遇到了两个问题:

  1. tsconfigrootDir 让入口不在直觉里的 dist/main.js,而是在更深的 dist/apps/api/src/main.js
  2. Prisma 7 生成的 JS 里有 extensionless import,例如类似 ./internal/class,纯 Node ESM 运行编译产物时会解析失败。

最终为了先让部署可靠,API 容器选择运行 TypeScript 入口:

pnpm --filter api start

其中 apistart 是:

tsx src/main.ts

这不是最“生产洁癖”的方案,但它减少了 Prisma 生成代码和 Node ESM 之间的摩擦。 后续如果要做更干净的生产部署,可以再引入打包器,把 API 打成单入口 JS。

坑 2:Prisma 需要 generate、migrate、seed 三步顺序正确

这次出现过两个典型错误。

第一个是 seed 时找不到生成的 Prisma client:

Cannot find module 'src/generated/client.js'

原因是没有先跑:

prisma generate

所以 db:seed 后来改成先自动生成 client:

prisma generate && tsx prisma/seed.ts

第二个是 seed 时数据库表不存在:

SQLITE_ERROR: no such table: main.User

原因是先 seed 了,但 migration 还没真正落到当前数据库。正确顺序是:

docker compose -f deploy/docker/docker-compose.yml run --rm api pnpm exec prisma migrate deploy
docker compose -f deploy/docker/docker-compose.yml run --rm api pnpm db:seed

这里还有一个很容易忽略的点:SQLite 的 DATABASE_URL 指向哪个文件,migration 就会落到哪个文件。切换到 Docker 后,数据库路径应固定到容器内:

DATABASE_URL=file:/data/draft.db

然后通过 volume 挂载到宿主机:

volumes:
  - ../../data:/data

这样容器重建不会丢数据库。

坑 3:.env 里一个字符错了,后面全都错

这次 .env 里曾经出现过类似:

o9DATABASE_URL="file:/path/to/dev.db"

Prisma 看到的就是没有 DATABASE_URL,于是 migrate deploy 报:

The datasource.url property is required

这类错误很朴素,但排查时容易被 Docker、Prisma、pnpm 的噪声带偏。第一反应应该是:

  • 当前进程是否真的读到了 .env
  • 变量名有没有拼错?
  • 容器里的环境变量和宿主机的环境变量是不是同一份?
  • SQLite 文件路径是不是指向预期文件?

坑 4:systemd 不是你的交互 shell

一开始尝试 systemd 部署 API,遇到了:

/usr/bin/env: "pnpm": No such file or directory

以及:

exec: pnpm: not found

原因是 systemd 不会加载你交互 shell 里的 PATH。你在终端里能跑 pnpm,不代表 systemd 也能找到它。

可以通过写绝对路径解决,但这会继续引出 Node 入口、编译产物路径、Prisma 生成物路径等问题。最后选择 Docker,是为了把运行时环境收进镜像,而不是继续和 systemd 的 PATH、工作目录、环境变量互相拉扯。

坑 5:Node slim 镜像里缺 OpenSSL

Dockerfile 使用 node:22-bookworm-slim 时,Prisma 在 install/generate 阶段提示过:

Prisma failed to detect the libssl/openssl version

这次构建没有立刻失败,但这是一个部署隐患。修法是在基础镜像里装上 ca-certificatesopenssl

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates openssl \
    && rm -rf /var/lib/apt/lists/*

经验是:只要 Prisma 参与 Docker 部署,slim 镜像里最好明确处理 OpenSSL。

坑 6:pnpm 的版本字段不要写范围

项目里曾经有:

{
  "devEngines": {
    "packageManager": {
      "name": "pnpm",
      "version": "^11.9.0"
    }
  }
}

Docker 构建时 pnpm 报:

Invalid package manager specification in package.json

这里它期望的是精确版本,而不是 semver range。修成:

{
  "version": "11.9.0"
}

然后 Dockerfile 里也固定同一个版本:

RUN corepack enable && corepack prepare [email protected] --activate

部署环境里,包管理器版本最好别“差不多”。它差一点,锁文件和安装行为就可能差很多。

坑 7:容器 Nginx 绑定公网 80 会接管所有站点

这是这次最值得记住的事故。

服务器上本来已经有宿主机 Nginx 管理多个域名。Docker web 容器一开始直接绑定了:

ports:
  - "80:80"

容器 Nginx 又用了:

server_name _;

结果就是:博客容器变成公网 80 的默认入口,其他域名全都被它接走。

正确修法不是“把 server_name _ 改成某个域名”这么简单。更稳的边界是:

ports:
  - "127.0.0.1:18383:80"

也就是 Docker web 只在宿主机本地监听。公网 80 永远由宿主机 Nginx 管,然后只给 博客域名加一个明确的 server block:

server {
    listen 80;
    server_name blog.example.com;

    location / {
        proxy_pass http://127.0.0.1:18383;
        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;
    }
}

这条边界可以避免一个新服务影响同机上的其他服务。

坑 8:HTTP 下登录 Cookie 不能默认 Secure

登录使用 HttpOnly Cookie。生产环境里,通常会设置:

Secure

它的含义是:浏览器只会在 HTTPS 下保存和发送这个 Cookie。

但这次博客一开始是 HTTP 部署。如果 Cookie 仍然 Secure=true,就会出现“接口登录 成功,但刷新后像没登录”的现象。

所以加了:

COOKIE_SECURE=false

HTTP 部署时使用:

COOKIE_SECURE=false
FRONTEND_ORIGIN=http://blog.example.com

以后上 HTTPS 后再改成:

COOKIE_SECURE=true
FRONTEND_ORIGIN=https://blog.example.com

这个配置不是为了降低安全性,而是为了明确区分 HTTP 学习部署和 HTTPS 正式部署。

坑 9:Docker Compose 文件移动后,路径语义会变

把 Docker 相关文件移入 deploy/ 是好事,但 Compose 里的相对路径会跟着变化。

例如 compose 文件如果在:

deploy/docker/docker-compose.yml

那么 build.contextenv_file、volume 挂载路径都要按这个文件的位置重新计算。

这类改动最好每次都跑:

docker compose -f deploy/docker/docker-compose.yml config --quiet

只检查语法还不够,最好还要在目标机器上真正跑一次:

docker compose -f deploy/docker/docker-compose.yml build
docker compose -f deploy/docker/docker-compose.yml up -d
curl http://127.0.0.1:18383/api/health

路径移动是部署脚本里最容易“看起来没问题,实际全错”的一种改动。

这次为什么比 Go 后端累

Go 服务部署时,复杂度通常集中在一个地方:编译后的二进制如何运行。

Node 全栈部署时,复杂度散在很多地方:

  • 前端是静态产物。
  • 后端是 Node 运行时。
  • TypeScript 需要编译或运行时转译。
  • Prisma 需要生成 client。
  • SQLite 需要明确持久化路径。
  • pnpm workspace 需要正确安装和构建顺序。
  • 浏览器 Cookie 行为受 HTTP/HTTPS 影响。
  • Nginx 既可能在容器里,也可能在宿主机上。

所以它不是某一步特别难,而是每一步都要求边界清楚。

最终 checklist

以后再部署类似 Node 全栈项目,我会先按这个 checklist 过一遍:

  1. 前端产物由谁服务:宿主机 Nginx、容器 Nginx,还是后端服务?
  2. API 运行的是 TS 源码、编译产物,还是打包后的单入口文件?
  3. Prisma client 是否在镜像构建阶段生成?
  4. migration 和 seed 是否明确分开执行?
  5. SQLite 的宿主机持久化路径是否固定?
  6. .env 是否被容器实际读取?
  7. Cookie 在 HTTP/HTTPS 下的 Secure 策略是否明确?
  8. Docker 是否只监听本机端口,公网入口是否仍由宿主机 Nginx 管理?
  9. 同机多站点场景下,是否避免 server_name _ 接管公网默认入口?
  10. Compose 文件移动目录后,所有相对路径是否重新验证?
  11. slim 镜像是否补齐 OpenSSL 等运行依赖?
  12. pnpm/Node/Prisma 版本是否固定且一致?

这次最大的教训不是“Node 部署很麻烦”,而是:全栈部署里每一层都要有明确主人。

公网入口归宿主机 Nginx,博客运行归 Docker,数据库归宿主机 volume,登录态策略归 .env。边界清楚以后,后续维护才会接近 Go 后端那种踏实感。