一次 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 一开始尝试跑编译产物,遇到了两个问题:
tsconfig的rootDir让入口不在直觉里的dist/main.js,而是在更深的dist/apps/api/src/main.js。- Prisma 7 生成的 JS 里有 extensionless import,例如类似
./internal/class,纯 Node ESM 运行编译产物时会解析失败。
最终为了先让部署可靠,API 容器选择运行 TypeScript 入口:
pnpm --filter api start
其中 api 的 start 是:
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-certificates 和 openssl:
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.context、env_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 过一遍:
- 前端产物由谁服务:宿主机 Nginx、容器 Nginx,还是后端服务?
- API 运行的是 TS 源码、编译产物,还是打包后的单入口文件?
- Prisma client 是否在镜像构建阶段生成?
- migration 和 seed 是否明确分开执行?
- SQLite 的宿主机持久化路径是否固定?
.env是否被容器实际读取?- Cookie 在 HTTP/HTTPS 下的
Secure策略是否明确? - Docker 是否只监听本机端口,公网入口是否仍由宿主机 Nginx 管理?
- 同机多站点场景下,是否避免
server_name _接管公网默认入口? - Compose 文件移动目录后,所有相对路径是否重新验证?
- slim 镜像是否补齐 OpenSSL 等运行依赖?
- pnpm/Node/Prisma 版本是否固定且一致?
这次最大的教训不是“Node 部署很麻烦”,而是:全栈部署里每一层都要有明确主人。
公网入口归宿主机 Nginx,博客运行归 Docker,数据库归宿主机 volume,登录态策略归
.env。边界清楚以后,后续维护才会接近 Go 后端那种踏实感。