成为全栈·Node 后端篇·部署上线:从本地起服到真正对外服务
本地 pnpm start 跑通,和"用户能从公网访问你的接口"之间,隔着环境变量、数据库迁移、HTTPS、CORS、进程守护整整一串事。很多人第一次部署卡住的地方,其实不是代码,而是"我到底该配哪些东西"。

这一篇对照 node-backend 的真实代码,讲清两条部署路线(自管 Linux 与 Cloudflare Workers)、部署真正的"旋钮"------环境变量,以及一份翻车对照表:症状、根因、代码依据,照着排就能定位。
一、部署不是"把代码传上去"那么简单
本地起服和线上部署有三个本质差异,漏掉任何一个都会出事:
- 配置来源不同 :本地靠
.env文件,线上靠服务器环境变量 / 平台绑定。配置一旦没注入,进程可能带着空密钥静默运行(最危险的 bug 之一)。 - 存储位置不同 :本地 SQLite 文件在硬盘上,上传文件在
uploads/。线上这些要么挂持久卷,要么换对象存储,否则容器/实例一重启全丢。 - 流量入口不同 :本地是
localhost:3000,线上要走域名 + HTTPS + 反向代理,还要处理跨域、限流、健康检查。
我们的项目还多了第四个差异:双运行时。这引出了本章的重点。
二、我们真实的两条部署路线(一套代码,两端落地)
读 src/ 就能确认,项目故意保留了两个入口,对应两种部署目标:

src/index.ts:Node 自管 Linux 部署入口。用@hono/node-server的serve起服务,顶层await migrate(db)后再setDb(db)。src/worker.ts:Cloudflare Workers 部署入口。wrangler.toml里main = "src/worker.ts"。它导出{ fetch },每次请求注入 CF 绑定(DB即 D1、R2_BUCKET即 R2),调用同一个createApp(env)。
两个入口复用同一套 createApp ------这是"一套代码双部署"的底层支撑(裁决 Q5 的落地)。业务路由、中间件、service 层一份代码写死,只在最外层的"怎么拿到 DB / 怎么起服务"上分叉。所以"部署"对我们来说不是选一个目标,而是两条路线都要会。
两条路线对"迁移"的处理还不一样,这是真实细节、也是常见坑:
- Node 入口在运行时
await migrate(db)------进程起来先把表建好,不怕空库。 - Workers 入口不在运行时迁移 ,
worker.ts注释明确写"D1 迁移在 deploy 阶段通过 drizzle-kit 应用,运行时不再迁移"。
为什么分叉?因为 Workers 是边缘无状态运行时,每次冷启动都跑一遍迁移既慢又可能在并发下打架;D1 的迁移更适合在 CI deploy 阶段 用 drizzle-kit 一次性应用。这条差异不是我编的,是两个入口代码白纸黑字写着的------部署脚本必须分别对待。
三、环境变量:部署真正的"旋钮"
我们的配置收口在 src/config/env.ts 的 readEnv,它用 zod 定义了所有外部配置。部署前,先把这个 schema 读熟,它就是你的"部署清单":

ts
// src/config/env.ts
const schema = z.object({
JWT_SECRET: z.string().min(1, 'JWT_SECRET 必填'),
DB_FILE: z.string().default(':memory:'),
STORAGE_DRIVER: z.enum(['local', 'r2']).default('local'),
CORS_ORIGINS: z.string().default('*'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().default('3000'),
// CF 绑定(本地为 undefined)
R2_BUCKET: z.unknown().optional(),
DB: z.unknown().optional(),
});
逐项对应部署动作:
JWT_SECRET:min(1)必填。线上必须 设成强随机值。如果漏设,Node 入口在模块加载时readEnv直接抛错、进程退出------这是好事,宁可起不来也不要带空密钥签发 token。DB_FILE:本地默认:memory:(测试友好),但生产绝不能内存库 。Node 部署要把它指到持久卷里的真实路径(如/data/app.db);Workers 路线根本不用这个字段,改用DB(D1 绑定)。STORAGE_DRIVER:local或r2。它决定文件落本地磁盘还是对象存储。部署到哪端,就拧到对应值。CORS_ORIGINS:默认*。生产必须填白名单 (逗号分隔的具体域名),否则任意网站都能带用户 cookie 调你的接口。审阅 B05 明确:留空或*应被拒绝跨域。PORT:src/index.ts里serve({ port })读它,默认 3000,反向代理 / 容器映射要对得上。
这套"配置单一入口 + zod 校验"的设计,本身就是部署安全网:业务代码不直接读 process.env,所有配置经 readEnv 解析校验后注入。部署时少配了关键项,启动即失败(fail fast),而不是带着残缺配置跑半夜。
四、路线 A:Node 自管 Linux 部署
适合你有一台自己的服务器(云主机 / 内网机),想完全掌控。步骤:
- 构建与起服 :
pnpm install→pnpm start(tsx src/index.ts)。如前篇所述,生产建议用 Docker 镜像起,或由pm2/systemd托管进程,保证崩溃自动重启。 - 持久化 :
DB_FILE指向挂载卷路径;STORAGE_DRIVER=local时uploads/也挂卷,且目录要对运行用户可写。 - 反向代理 + HTTPS:不要直接把 3000 端口暴露公网。前面架一层 nginx / Caddy 做 TLS 终止、域名路由、 gzip。Caddy 甚至能自动签 Let's Encrypt 证书,省去手动续期。
- CORS 白名单 :
CORS_ORIGINS填https://www.example.com,https://admin.example.com,和前端域名严格对应。 - 首部署补数据 :容器/进程起来后跑一次
pnpm seed创建初始账号(index.ts已自动migrate,无需手动建表)。
这套路线的好处是"所见即所得"------和你本地开发环境几乎一致,排错直觉通用。代价是要自己管操作系统、TLS、进程守护、数据库备份。
五、路线 B:Cloudflare Workers 部署
适合想免运维、自动全球边缘加速、按量付费。步骤:
wrangler.toml配置 :main = "src/worker.ts"、compatibility_date、在[vars]配JWT_SECRET/NODE_ENV=production/STORAGE_DRIVER,并解开注释启用[[d1_databases]](填database_id)与[[r2_buckets]]绑定。- D1 迁移在 deploy 阶段 :本地 / CI 跑
drizzle-kit migrate(或wrangler d1 migrations apply),把迁移文件应用到 D1。运行时worker.ts不再迁移。 wrangler deploy:把worker.ts+ 打包后的代码推到边缘。createApp(env)每次请求用注入的 D1 / R2 绑定构造。- TLS 与域名 :
*.workers.dev自带 HTTPS,绑自定义域名也在控制台点几下。无需自己管反向代理。 - 存储切换 :
STORAGE_DRIVER=r2时,文件走 R2 绑定,Worker 本身无状态,天然利于水平扩容。
Workers 路线的代价:它是边缘无状态运行时,不能用本地文件系统(所以 uploads/ 本地磁盘模式在 Workers 上无效,必须 R2);冷启动、运行时限制(CPU 时间、子请求数)也要留意。这正是我们"双存储适配层"存在的意义------STORAGE_DRIVER 一拧,存储策略跟着部署目标切换。
六、STORAGE_DRIVER 是部署的"存储开关"
这一点值得单独强调,因为它最容易被部署时忽略。我们的附件存储是适配层 + 配置驱动:
- 部署到自管 Linux +
STORAGE_DRIVER=local:文件落uploads/,必须挂持久卷,且要注意运行用户对该目录的写权限(USER appuser后挂载点可能属 root,要在 entrypoint 里chown)。 - 部署到 Cloudflare +
STORAGE_DRIVER=r2:文件走 R2 绑定,Worker 无状态,卷都不用挂。
同一个 uploadAttachment 接口,部署目标不同,落点完全不同。部署清单里这一项必须和部署路线对齐,否则要么文件写不进(本地盘没挂)、要么引用了不存在的 R2 绑定(Workers 启动报错)。
七、CORS:生产别留 *
env.ts 里 CORS_ORIGINS 默认是 *,这是为本地开发省事。但生产环境 * 配合 credentials 在浏览器里本就被禁止,且等于对全网开放跨域------审阅 B05 因此要求生产填具体白名单。部署时这一步是硬动作 :把前端管理后台域名、官网域名逗号分隔写进 CORS_ORIGINS。漏做的话,前端上线后调接口会被浏览器 CORS 拦截,表现就是"本地好端端的,一上线就跨域报错"。
八、部署翻车对照表:症状 → 根因 → 代码依据
讲完正确做法,再用一张"翻车对照表"把上面每一条落进真实症状。这些都是对照我们代码能推出来的、部署时高频出现的问题,先于读者踩坑前点破:
| 上线症状 | 根因 | 代码依据 |
|---|---|---|
Node 进程一启动就退出,日志报 JWT_SECRET 必填 |
生产忘了设密钥,readEnv 在模块加载即抛 |
env.ts 中 JWT_SECRET: z.string().min(1) |
本地好端端,Worker 一请求就 no such table |
忘了在 deploy 阶段跑 D1 迁移,且运行时不再迁移 | worker.ts 注释"D1 迁移在 deploy 阶段应用" |
| Node 起服后首请求偶发 500、日志说表不存在 | migrate 没 await 就起服(我们已 await,但若有人改成 fire-and-forget 就会中招) |
index.ts 顶层 await migrate(db) 后再 serve |
| 本地能上传,线上上传接口 500 | STORAGE_DRIVER 与部署目标错配:Worker 上设了 local 却无本地盘 |
env.ts enum 仅 local/r2,本地盘在 CF 不存在 |
| 前端一上线调接口就被浏览器 CORS 拦 | CORS_ORIGINS 仍是默认 *,生产应白名单 |
env.ts default '*' + 审阅 B05 |
| 容器重启后文章/用户全没了 | DB_FILE 用了默认 :memory:,没挂持久卷 |
env.ts DB_FILE default ':memory:' |
| 管理后台登录后调接口 401 但本地正常 | JWT_SECRET 生产/本地不一致,签名对不上 |
env.ts 单一 JWT_SECRET 收口,缺则 fallback 到旧值 |
这张表的每一行,都能在 env.ts / index.ts / worker.ts 里找到确切出处------它不是"别人说会出这问题",而是从我们自己的代码推出来的。部署前照着逐行核对,能把大部分上线事故消灭在发版前。
九、部署核对清单(以实测为准)
把上面所有点收成一个"部署前核对",每一项都要 ls / 读代码确认,不靠记忆:
| 核对项 | 真实来源 | 生产要求 |
|---|---|---|
| JWT_SECRET 已设 | env.ts min(1) |
强随机,缺失即启动失败 |
| DB 持久化 | index.ts DB_FILE / worker.ts DB |
挂载卷或 D1 绑定 |
| STORAGE_DRIVER 对齐 | env.ts enum |
local 挂盘 / r2 绑 R2 |
| CORS 白名单 | env.ts default * |
填具体域名,禁 * |
| 迁移已应用 | index.ts 运行时 / worker.ts deploy 阶段 |
两侧策略不同,勿混 |
| 端口/域名对齐 | PORT + 反向代理 |
与 upstream 一致 |
十、小结
部署上线是把"能跑的代码"变成"对外服务",核心差异在配置来源、存储位置、流量入口,以及我们的双运行时:
- 两条路线 :Node 自管 Linux(
index.ts,运行时 migrate)+ Cloudflare Workers(worker.ts,deploy 阶段 migrate),共用createApp。 - 配置即清单 :
readEnv的 zod schema 就是部署清单------JWT_SECRET必填、DB_FILE不能内存库、STORAGE_DRIVER选 local/r2、CORS_ORIGINS禁*。 - 存储开关 :
STORAGE_DRIVER随部署目标拧,local 要挂卷、r2 要绑 R2,两侧不可混。 - 迁移分叉:Node 运行时建表,Workers 靠 drizzle-kit 在 deploy 阶段建,部署脚本要对号入座。
- fail fast :配置缺失
readEnv直接抛错,宁可起不来也不带残缺配置跑。
下一篇({{LINK:M1-24}})我们专门拆解"一套后端双部署"这个架构决策:为什么这么设计、两个入口如何共享 createApp、db/client.ts 怎么同时适配 SQLite 和 D1,以及适配层里那些值得记一辈子的坑。
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
