从域名到数据库:React + Node.js 项目部署全流程与用户访问链路
- 前言
- [1. 先建立生产环境的完整心智模型](#1. 先建立生产环境的完整心智模型)
-
- [1.1 开发环境与生产环境有什么不同](#1.1 开发环境与生产环境有什么不同)
- [1.2 各组件分别负责什么](#1.2 各组件分别负责什么)
- [2. 上线前先准备正确的生产产物](#2. 上线前先准备正确的生产产物)
-
- [2.1 React + TypeScript 最终产出的不是"组件"](#2.1 React + TypeScript 最终产出的不是“组件”)
- [2.2 Node.js 后端需要进程、配置与依赖](#2.2 Node.js 后端需要进程、配置与依赖)
- [3. 准备服务器、域名与网络边界](#3. 准备服务器、域名与网络边界)
-
- [3.1 服务器、域名、备案与部署方案选择](#3.1 服务器、域名、备案与部署方案选择)
- [3.2 安全组和防火墙是两道不同的门](#3.2 安全组和防火墙是两道不同的门)
- [4. 使用宝塔搭建 Nginx + Node.js 生产环境](#4. 使用宝塔搭建 Nginx + Node.js 生产环境)
-
- [4.1 从空服务器到可运行服务](#4.1 从空服务器到可运行服务)
- [4.2 用 Nginx 同时托管前端并代理 API](#4.2 用 Nginx 同时托管前端并代理 API)
- [4.3 配置 DNS、HTTPS 并进行上线验证](#4.3 配置 DNS、HTTPS 并进行上线验证)
- [5. 用户访问网站时到底发生了什么](#5. 用户访问网站时到底发生了什么)
-
- [5.1 从输入域名到看到页面的完整时序](#5.1 从输入域名到看到页面的完整时序)
- [5.2 第二次访问为什么通常更快](#5.2 第二次访问为什么通常更快)
- [6. Vite 代理、Nginx 代理与跨域的真实关系](#6. Vite 代理、Nginx 代理与跨域的真实关系)
-
- [6.1 开发阶段由 Vite 接住 `/api`](#6.1 开发阶段由 Vite 接住
/api) - [6.2 生产阶段由 Nginx 提供同源入口](#6.2 生产阶段由 Nginx 提供同源入口)
- [6.1 开发阶段由 Vite 接住 `/api`](#6.1 开发阶段由 Vite 接住
- [7. 安全、排错与上线验收](#7. 安全、排错与上线验收)
-
- [7.1 最小可用不等于可以裸奔](#7.1 最小可用不等于可以裸奔)
- [7.2 按请求经过的层次定位故障](#7.2 按请求经过的层次定位故障)
- 总结
前言
在本地开发一个前后端分离项目时,启动方式通常很简单:React 页面运行在 http://localhost:5173,Node.js 接口运行在 http://localhost:3001,数据库也在本机。可一旦网站需要让公网用户访问,问题就不再只是"代码能不能运行",而是要把域名、DNS、云服务器、安全组、防火墙、HTTPS、Nginx、Node.js 进程和数据库串成一条稳定、安全的请求链路。
本文以一个 React + TypeScript、Node.js、MySQL 的待办事项项目为例,使用腾讯云轻量服务器与宝塔面板搭建生产环境。前端调用 /api/todos,Nginx 对静态页面和 API 请求进行分流,Node.js 查询 MySQL 后返回 JSON。读完不仅能完成一次部署,还能真正理解:用户在浏览器里输入域名之后,每一层到底做了什么。
部署的本质不是把源码复制到服务器,而是准备可运行的生产产物,并为每一类请求安排正确、安全、可观测的处理路径。
1. 先建立生产环境的完整心智模型
1.1 开发环境与生产环境有什么不同
开发阶段,Vite 同时承担前端开发服务器、热更新和开发代理等职责;生产阶段,浏览器不需要 Vite,而是直接下载构建后的 HTML、CSS 和 JavaScript。静态文件由 Nginx 返回,接口请求也先进入 Nginx,再由它转发给内部 Node.js 服务。
| 对比项 | 开发环境 | 生产环境 |
|---|---|---|
| 前端入口 | Vite,常见端口为 5173 |
Nginx,对外使用 80/443 |
| 前端内容 | 源码按需转换并支持热更新 | npm run build 生成的 dist/ 静态文件 |
| API 转发 | Vite 的 server.proxy |
Nginx 的 proxy_pass |
| Node.js 服务 | 手动启动、热重载 | 由宝塔或 PM2 守护,异常后自动拉起 |
| 数据库 | 可在本机开发环境运行 | 位于服务器本机或云数据库,只允许受信来源连接 |
| HTTPS | 通常省略或使用开发证书 | 使用可信 CA 签发的正式证书 |
| 访问主体 | 开发者本人 | 不可预测的公网用户与自动化流量 |
这意味着,生产部署中不能把 npm run dev 当作前端服务长期运行。开发服务器追求调试效率,Nginx 才是面向公网流量的正式入口。
1.2 各组件分别负责什么
整个系统可以先压缩成一张架构图:
#mermaid-svg-6L2smxy8opeN9KNT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6L2smxy8opeN9KNT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6L2smxy8opeN9KNT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6L2smxy8opeN9KNT .error-icon{fill:#552222;}#mermaid-svg-6L2smxy8opeN9KNT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6L2smxy8opeN9KNT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6L2smxy8opeN9KNT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6L2smxy8opeN9KNT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6L2smxy8opeN9KNT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6L2smxy8opeN9KNT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6L2smxy8opeN9KNT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6L2smxy8opeN9KNT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6L2smxy8opeN9KNT .marker.cross{stroke:#333333;}#mermaid-svg-6L2smxy8opeN9KNT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6L2smxy8opeN9KNT p{margin:0;}#mermaid-svg-6L2smxy8opeN9KNT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6L2smxy8opeN9KNT .cluster-label text{fill:#333;}#mermaid-svg-6L2smxy8opeN9KNT .cluster-label span{color:#333;}#mermaid-svg-6L2smxy8opeN9KNT .cluster-label span p{background-color:transparent;}#mermaid-svg-6L2smxy8opeN9KNT .label text,#mermaid-svg-6L2smxy8opeN9KNT span{fill:#333;color:#333;}#mermaid-svg-6L2smxy8opeN9KNT .node rect,#mermaid-svg-6L2smxy8opeN9KNT .node circle,#mermaid-svg-6L2smxy8opeN9KNT .node ellipse,#mermaid-svg-6L2smxy8opeN9KNT .node polygon,#mermaid-svg-6L2smxy8opeN9KNT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6L2smxy8opeN9KNT .rough-node .label text,#mermaid-svg-6L2smxy8opeN9KNT .node .label text,#mermaid-svg-6L2smxy8opeN9KNT .image-shape .label,#mermaid-svg-6L2smxy8opeN9KNT .icon-shape .label{text-anchor:middle;}#mermaid-svg-6L2smxy8opeN9KNT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6L2smxy8opeN9KNT .rough-node .label,#mermaid-svg-6L2smxy8opeN9KNT .node .label,#mermaid-svg-6L2smxy8opeN9KNT .image-shape .label,#mermaid-svg-6L2smxy8opeN9KNT .icon-shape .label{text-align:center;}#mermaid-svg-6L2smxy8opeN9KNT .node.clickable{cursor:pointer;}#mermaid-svg-6L2smxy8opeN9KNT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6L2smxy8opeN9KNT .arrowheadPath{fill:#333333;}#mermaid-svg-6L2smxy8opeN9KNT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6L2smxy8opeN9KNT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6L2smxy8opeN9KNT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6L2smxy8opeN9KNT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6L2smxy8opeN9KNT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6L2smxy8opeN9KNT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6L2smxy8opeN9KNT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6L2smxy8opeN9KNT .cluster text{fill:#333;}#mermaid-svg-6L2smxy8opeN9KNT .cluster span{color:#333;}#mermaid-svg-6L2smxy8opeN9KNT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6L2smxy8opeN9KNT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6L2smxy8opeN9KNT rect.text{fill:none;stroke-width:0;}#mermaid-svg-6L2smxy8opeN9KNT .icon-shape,#mermaid-svg-6L2smxy8opeN9KNT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6L2smxy8opeN9KNT .icon-shape p,#mermaid-svg-6L2smxy8opeN9KNT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6L2smxy8opeN9KNT .icon-shape .label rect,#mermaid-svg-6L2smxy8opeN9KNT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6L2smxy8opeN9KNT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6L2smxy8opeN9KNT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6L2smxy8opeN9KNT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询域名
返回公网 IP
/、/assets/*
/api/* 反向代理
内网或本机连接
JSON
HTTPS 响应
用户浏览器
DNS 解析
云厂商安全组
Linux 防火墙
Nginx :80/:443
React dist 静态文件
Node.js :3001
MySQL :3306
| 组件 | 核心职责 | 是否应直接暴露给公网 |
|---|---|---|
| 域名与 DNS | 把容易记忆的域名解析成服务器公网 IP | DNS 本身是公共服务 |
| 云安全组 | 在流量到达服务器前进行网络层过滤 | 只开放业务必需端口 |
| Linux 防火墙 | 在操作系统内部进行第二层访问控制 | 不适用 |
| 宝塔面板 | 可视化管理软件、站点、证书、文件、进程与日志 | 管理端口仅允许可信 IP 访问 |
| Nginx | HTTPS 终止、静态文件服务、请求分流、反向代理 | 是,开放 80/443 |
| Node.js | 执行业务逻辑并返回 JSON | 否,只监听本机或内网地址 |
| MySQL | 持久化业务数据 | 通常否,只接受后端或受信网络连接 |
反向代理 是指客户端只访问 Nginx,由 Nginx 代表客户端访问后端服务。浏览器知道的是
https://example.com/api/todos,并不知道服务器内部的127.0.0.1:3001。
2. 上线前先准备正确的生产产物
2.1 React + TypeScript 最终产出的不是"组件"
开发时编写的 .tsx 组件不能原样交给浏览器执行。运行构建命令后,Vite 会完成 TypeScript 转译、模块打包、压缩和资源指纹处理,默认把结果写入 dist/。
bash
moss@VM-0-4-ubuntu:~/todo-web$ npm ci
moss@VM-0-4-ubuntu:~/todo-web$ npm run build
npm ci 严格依据锁文件安装依赖,适合可复现的部署;npm run build 执行项目在 package.json 中定义的生产构建脚本。典型产物如下:
text
dist/
├── index.html
├── assets/
│ ├── index-a81f3c2d.js
│ └── index-29d7e6b1.css
└── favicon.svg
index.html 是入口,带哈希的 JS 和 CSS 是静态资源。Nginx 只需要读取这些文件,无须理解 React 组件。前端请求地址应写成相对路径,让开发代理和生产代理都能接住同一个请求:
typescript
const response = await fetch('/api/todos');
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const todos = await response.json();
如果使用 Vite 环境变量,可把公共 API 前缀设为 /api,但不能把数据库密码、JWT 私钥等秘密放进 VITE_ 开头的变量。它们会在构建时进入前端代码,任何用户都可以下载并查看。
dotenv
VITE_API_BASE_URL=/api
2.2 Node.js 后端需要进程、配置与依赖
Node.js 后端不是静态文件。它必须保持一个长期运行的进程,监听端口、处理请求、访问数据库并返回响应。为了避免对公网开放后端端口,可以让服务只监听 127.0.0.1:3001:
javascript
import express from 'express';
const app = express();
app.get('/todos', async (request, response) => {
const todos = await todoRepository.findAll();
response.json(todos);
});
app.listen(3001, '127.0.0.1', () => {
console.log('API listening on http://127.0.0.1:3001');
});
这里后端路由是 /todos,而浏览器请求的是 /api/todos。稍后会让 Nginx 在转发时去掉 /api 前缀。这样的设计既保留了清晰的公网 API 命名,又允许后端使用简洁路由。
| 部署内容 | 前端 | 后端 |
|---|---|---|
| 必需文件 | dist/ |
源码或编译产物、package.json、锁文件 |
| 运行方式 | Nginx 直接读取文件 | Node.js 常驻进程 |
| 环境变量 | 只能放公开配置,构建后不可秘密修改 | 数据库地址、密码、令牌密钥等服务端配置 |
| 更新方式 | 重新构建并替换静态文件 | 安装依赖、迁移数据、重启或平滑重载进程 |
| 验证方式 | 页面与静态资源返回 200 |
本机调用健康检查或 API 返回正确 JSON |
3. 准备服务器、域名与网络边界
3.1 服务器、域名、备案与部署方案选择
个人项目可以选择一台带公网 IP 的 Linux 轻量服务器。CPU、内存和带宽应根据并发量、Node.js 进程数量和数据库规模选择,不能只看首年价格;续费价格、流量额度、磁盘容量、快照能力与地域同样会影响长期成本。
域名购买后,需要在 DNS 控制台添加 A 记录,例如把 example.com 和 www.example.com 指向服务器公网 IPv4。DNS 只负责"告诉浏览器服务器在哪里",并不会自动安装网站、开放端口或配置 HTTPS。
如果网站使用中国大陆境内的云服务器对外提供服务,应先按当前接入商与属地要求完成 ICP 备案。备案周期会受到资料、接入商审核和各地通信管理局处理进度影响,不应把某个天数当作固定承诺。腾讯云当前的备案流程说明明确要求取得备案号后再开通访问,实际操作时还应核对最新规则。
| 方案 | 更适合的场景 | 优点 | 需要承担的工作 |
|---|---|---|---|
| Vercel + 托管数据库 | Next.js、Supabase 等偏前端和 Serverless 的项目 | Git 推送即可构建部署,证书与 CDN 管理简单 | 关注区域访问、平台限制、函数运行模型和成本 |
| 腾讯云 + 宝塔 | 国内访问、自建 React/Node/Java/Go/Python 服务 | 环境自由,可控制 Nginx、进程、数据库和网络 | 自行负责安全、升级、备份、监控与故障恢复 |
| 纯 Linux 命令行 | 熟悉系统运维、需要自动化与细粒度控制 | 配置透明,便于脚本化和基础设施即代码 | 学习与维护成本更高 |
宝塔降低的是日常操作门槛,它相当于安装在服务器上的可视化运维控制台,但底层仍然是 Nginx、Linux 进程、文件权限和防火墙。理解这些组件,才能在面板按钮失效时找到真正原因。
3.2 安全组和防火墙是两道不同的门
安全组 位于云厂商网络层,流量尚未进入服务器就会先经过它;Linux 防火墙 位于操作系统内部。腾讯云轻量服务器控制台有时把网络层规则也称为"防火墙",但它和 ufw、firewalld 等系统防火墙仍不是同一层。腾讯云的轻量服务器说明也区分了实例网络访问控制与操作系统内部规则。
| 端口 | 用途 | 建议入站来源 | 生产建议 |
|---|---|---|---|
22 |
SSH 管理 Linux | 管理员固定 IP | 使用密钥登录,避免全网开放 |
80 |
HTTP | 全网 | 用于访问或跳转到 HTTPS |
443 |
HTTPS | 全网 | 正式网站主要入口 |
| 宝塔实际面板端口 | Web 管理后台 | 管理员固定 IP | 修改默认设置并限制来源,不要假定永远是 8888 |
3001 |
Node.js API | 无公网入站 | 仅由本机 Nginx 访问 |
3306 |
MySQL | 无公网入站 | 仅允许后端或受信内网访问 |
需要特别纠正一个常见笔误:HTTPS 的默认端口是 443,不是 442 。安全组应遵循最小开放原则。腾讯云安全组文档提供的 Web 模板也以 22、80 和 443 为基础,而不是直接放通全部端口。
4. 使用宝塔搭建 Nginx + Node.js 生产环境
4.1 从空服务器到可运行服务
宝塔官方建议在未安装其他 Web 环境的纯净系统上安装面板。安装脚本和支持系统可能更新,应从宝塔官方快速安装文档复制当时的命令,不要使用来历不明的镜像脚本。完成安装后,应立即修改面板账号、强密码、安全入口和访问端口,并在云防火墙中把面板端口限制为管理员 IP。
随后在软件商店安装 Nginx、项目所需的 Node.js 版本以及 MySQL;如果数据库使用独立云服务,则不必在同一台机器安装 MySQL。本文使用以下目录:
text
/www/wwwroot/todo-app/
├── frontend/
│ └── dist/
└── backend/
├── server.js
├── package.json
├── package-lock.json
└── .env
将前端 dist/ 上传到对应目录,把后端代码和锁文件放到 backend/。后端生产依赖安装完成后,可以通过宝塔的 Node 项目管理器配置启动文件、端口和"开机启动",也可以使用 PM2 守护进程:
bash
moss@VM-0-4-ubuntu:/www/wwwroot/todo-app/backend$ npm ci --omit=dev
moss@VM-0-4-ubuntu:/www/wwwroot/todo-app/backend$ pm2 start server.js --name todo-api
moss@VM-0-4-ubuntu:/www/wwwroot/todo-app/backend$ pm2 save
moss@VM-0-4-ubuntu:/www/wwwroot/todo-app/backend$ curl http://127.0.0.1:3001/todos
npm ci --omit=dev 按锁文件安装依赖并省略开发依赖;pm2 start 启动名为 todo-api 的进程;pm2 save 保存当前进程清单。仍需按 PM2 或宝塔提示配置系统开机恢复,不能把"进程当前在线"等同于"服务器重启后也会自动上线"。最后一条 curl 从服务器内部访问 Node.js,如果能得到预期 JSON,说明后端本身已经可用;此时即使公网不能访问 3001 也是正确状态。
后端 .env 应只保存在服务器,文件权限仅授予运行账户。MySQL 应创建专用业务用户,只授予该项目所需库表权限,不能让应用长期使用数据库 root 账号。
4.2 用 Nginx 同时托管前端并代理 API
在宝塔"网站"中创建站点、绑定域名,并把站点配置改成下面的核心结构。证书路径由宝塔实际生成,不能直接照抄占位符:
nginx
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privatekey.pem;
root /www/wwwroot/todo-app/frontend/dist;
index index.html;
location = /index.html {
add_header Cache-Control "no-cache";
}
location ^~ /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.(?:css|js|mjs|map|png|jpe?g|gif|svg|ico|webp|woff2?)$ {
try_files $uri =404;
expires 7d;
}
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:3001/;
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;
}
}
location / 中的 try_files 先寻找真实文件,找不到时返回 index.html,这是 React Router 刷新 /todos/42 不出现 Nginx 404 的关键。带内容哈希的 /assets/ 可以长期缓存,而 index.html 不应长期强缓存,否则发布后用户可能继续引用旧资源。
proxy_pass 末尾的斜杠会影响转发路径,这也是 API 部署中最容易被忽略的细节之一。Nginx 官方的 proxy_pass 说明指出:当代理地址包含 URI 时,匹配到的 location 部分会被替换。
| Nginx 写法 | 浏览器请求 | Node.js 实际收到 | 适用后端路由 |
|---|---|---|---|
proxy_pass http://127.0.0.1:3001/; |
/api/todos |
/todos |
app.get('/todos') |
proxy_pass http://127.0.0.1:3001; |
/api/todos |
/api/todos |
app.get('/api/todos') |
修改配置后,先检查语法,再平滑重载 Nginx:
bash
moss@VM-0-4-ubuntu:~$ sudo nginx -t
moss@VM-0-4-ubuntu:~$ sudo systemctl reload nginx
nginx -t 检查配置语法及引用文件;只有检查成功后才运行 systemctl reload nginx。reload 会加载新配置并尽量不中断已有连接,比直接停止服务更适合生产变更。若宝塔内安装的 Nginx 未注册为系统服务,应使用面板提供的重载按钮或它显示的实际管理命令。
4.3 配置 DNS、HTTPS 并进行上线验证
建议按"域名解析 → 等待 DNS 生效 → 创建 HTTP 站点 → 申请证书 → 开启 HTTPS → 配置跳转"的顺序操作。宝塔可以申请并续期受信任证书,但仍应定期检查证书到期时间和自动续期日志。不要在证书尚未签发时就强制跳转 HTTPS,否则排错会更困难。
上线前可以从本地和服务器分别验证:
bash
moss@VM-0-4-ubuntu:~$ dig +short example.com
moss@VM-0-4-ubuntu:~$ curl -I http://example.com
moss@VM-0-4-ubuntu:~$ curl -I https://example.com
moss@VM-0-4-ubuntu:~$ curl https://example.com/api/todos
moss@VM-0-4-ubuntu:~$ sudo ss -lntp
dig +short 检查域名解析结果;curl -I 只获取响应头,适合确认 HTTP 是否跳转、HTTPS 是否返回成功;普通 curl 验证完整 API 内容;ss -lntp 列出 TCP 监听端口及进程,应看到 Nginx 监听公网 80/443,Node.js 只监听 127.0.0.1:3001。如果本地电脑没有 dig,也可以使用 nslookup 完成基础 DNS 检查。
5. 用户访问网站时到底发生了什么
5.1 从输入域名到看到页面的完整时序
假设用户第一次访问 https://example.com/todos,浏览器会经历下面这条链路:
MySQL Node.js Nginx 安全组与防火墙 DNS 解析器 浏览器 MySQL Node.js Nginx 安全组与防火墙 DNS 解析器 浏览器 #mermaid-svg-CwNDVqO9n2moSTbv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CwNDVqO9n2moSTbv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CwNDVqO9n2moSTbv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CwNDVqO9n2moSTbv .error-icon{fill:#552222;}#mermaid-svg-CwNDVqO9n2moSTbv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CwNDVqO9n2moSTbv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CwNDVqO9n2moSTbv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CwNDVqO9n2moSTbv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CwNDVqO9n2moSTbv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CwNDVqO9n2moSTbv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CwNDVqO9n2moSTbv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CwNDVqO9n2moSTbv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CwNDVqO9n2moSTbv .marker.cross{stroke:#333333;}#mermaid-svg-CwNDVqO9n2moSTbv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CwNDVqO9n2moSTbv p{margin:0;}#mermaid-svg-CwNDVqO9n2moSTbv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CwNDVqO9n2moSTbv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-CwNDVqO9n2moSTbv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CwNDVqO9n2moSTbv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-CwNDVqO9n2moSTbv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-CwNDVqO9n2moSTbv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-CwNDVqO9n2moSTbv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-CwNDVqO9n2moSTbv .sequenceNumber{fill:white;}#mermaid-svg-CwNDVqO9n2moSTbv #sequencenumber{fill:#333;}#mermaid-svg-CwNDVqO9n2moSTbv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-CwNDVqO9n2moSTbv .messageText{fill:#333;stroke:none;}#mermaid-svg-CwNDVqO9n2moSTbv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CwNDVqO9n2moSTbv .labelText,#mermaid-svg-CwNDVqO9n2moSTbv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-CwNDVqO9n2moSTbv .loopText,#mermaid-svg-CwNDVqO9n2moSTbv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-CwNDVqO9n2moSTbv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CwNDVqO9n2moSTbv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-CwNDVqO9n2moSTbv .noteText,#mermaid-svg-CwNDVqO9n2moSTbv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-CwNDVqO9n2moSTbv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CwNDVqO9n2moSTbv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CwNDVqO9n2moSTbv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CwNDVqO9n2moSTbv .actorPopupMenu{position:absolute;}#mermaid-svg-CwNDVqO9n2moSTbv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-CwNDVqO9n2moSTbv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CwNDVqO9n2moSTbv .actor-man circle,#mermaid-svg-CwNDVqO9n2moSTbv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-CwNDVqO9n2moSTbv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户 输入 https://example.com/todos 查询 example.com 的 IP 返回服务器公网 IP 建立 TCP 与 TLS 连接,请求 /todos 允许 443 流量进入 返回 index.html 请求 /assets/*.js 和 *.css 返回静态资源 执行 React,渲染页面 GET /api/todos GET /todos 执行参数化 SQL 查询 返回数据行 返回 JSON 返回 HTTPS 响应 更新待办列表 UI 用户
首先,浏览器会检查自身 DNS 缓存,操作系统也可能检查本地缓存,然后向递归 DNS 解析器查询。如果解析器没有缓存,它会沿 DNS 层级查找:根服务器提供顶级域名服务器线索,.com 顶级域名服务器再指向该域名的权威 DNS,最终由权威 DNS 返回 A 或 AAAA 记录。根服务器并不是直接保存所有网站 IP 的"大通讯录"。
拿到 IP 后,浏览器连接服务器的 443 端口。云安全组先判断来源、协议和端口是否允许,Linux 防火墙随后再次检查。通过后,浏览器与 Nginx 建立 TCP 连接并完成 TLS 握手:Nginx 出示证书,浏览器验证域名、有效期和证书链,双方协商加密参数。之后的 HTTP 内容才在加密通道中传输。
请求 /todos 时,服务器上并不存在这个真实文件,但 try_files 会回退到 index.html。浏览器解析 HTML,继续下载 JS、CSS 等资源;React 启动后读取当前 URL,由前端路由渲染待办页面。组件随后发送 GET /api/todos,Nginx 命中 /api/ 规则,把请求改写为 /todos 并转发至 127.0.0.1:3001。Node.js 执行业务校验并查询 MySQL,再把数据序列化为 JSON。响应沿着 Node.js → Nginx → 浏览器原路返回,React 根据数据更新界面。
| 请求路径 | Nginx 的处理方式 | 最终结果 |
|---|---|---|
/ |
查找入口文件 | 返回 dist/index.html |
/assets/index-a81f3c2d.js |
查找真实静态文件 | 返回 JS,可长期缓存 |
/todos/42 |
文件不存在,回退到 /index.html |
交给 React Router 解析 |
/api/todos |
命中反向代理规则 | 转为内部 GET /todos |
/unknown.png |
静态文件不存在 | 返回 404,不应回退成 HTML |
5.2 第二次访问为什么通常更快
DNS 结果会按 TTL 在浏览器、操作系统或递归解析器中缓存,因此后续访问不一定重新走完整 DNS 层级。带哈希的 JS、CSS 也可以从浏览器缓存读取;只要文件内容变化,构建工具就生成新文件名,从而自然避开旧缓存。
不过,缓存不是"所有内容永久不请求"。index.html 通常需要重新验证,以便及时拿到新资源地址;/api/todos 是动态数据,一般仍会访问后端,除非业务明确设置 HTTP 缓存或在 Nginx、CDN、应用层增加缓存策略。已有的 TCP/TLS 连接还可能通过 keep-alive 复用,减少重复握手成本。
6. Vite 代理、Nginx 代理与跨域的真实关系
6.1 开发阶段由 Vite 接住 /api
浏览器对"同源"的判断由协议、主机名和端口 共同决定。http://localhost:5173 与 http://localhost:3001 端口不同,因此是两个源。开发时可以让浏览器始终请求 5173 下的相对路径,再由 Vite 在服务器侧转发:
typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:3001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
浏览器发送 http://localhost:5173/api/todos,Vite 匹配 /api 后转发为 http://127.0.0.1:3001/todos。Vite 的官方代理配置只作用于开发服务器,不会被自动打进 dist/。
"代理"和"Mock"也不是一回事:代理把请求发给真实后端;Mock 则由插件、开发中间件或 Service Worker 直接构造假响应,请求可能根本不会到达 Node.js。两者都能让页面收到 JSON,但执行链路完全不同。
6.2 生产阶段由 Nginx 提供同源入口
生产环境中,页面和 API 对浏览器都属于 https://example.com。浏览器只连接标准 HTTPS 端口,Nginx 在服务器内部访问 Node.js,因此前端不会产生"从 5173 请求 3001"这种跨域行为。
| 调用方式 | 浏览器看到的页面源 | API 地址 | 是否跨源 | 评价 |
|---|---|---|---|---|
| 前端直连 Node.js | https://example.com |
http://IP:3001/todos |
是,且可能触发混合内容拦截 | 不推荐,还暴露内部端口 |
| 子域名 API | https://example.com |
https://api.example.com/todos |
是 | 可用,但后端需精确配置 CORS |
| Nginx 同源代理 | https://example.com |
https://example.com/api/todos |
否 | 单体部署最简单稳妥 |
因此,"Nginx 解决跨域"并不是它关闭了浏览器安全机制,而是它给浏览器提供了统一的同源入口 。如果业务确实需要不同域名访问 API,仍应在 Node.js 或网关中配置明确的 Access-Control-Allow-Origin,不能为省事直接允许任意来源携带凭证。
7. 安全、排错与上线验收
7.1 最小可用不等于可以裸奔
宝塔让部署更直观,但也增加了一个高权限管理入口。生产环境至少应落实以下基线:
- SSH 优先使用密钥认证,只允许可信 IP,避免把管理端口长期暴露给全网。
- 宝塔面板修改默认入口和强密码,启用二次验证,并在云防火墙设置来源白名单。
- 公网只开放真正需要的端口;Node.js 的
3001和 MySQL 的3306保持内网或本机可见。 - 应用密钥只存放在服务端环境变量中,数据库使用最小权限账号,SQL 使用参数化查询。
- 定期升级 Linux、Nginx、Node.js 与依赖包,关注安全公告,删除不再使用的面板插件。
- 同时备份数据库和上传文件,并定期做恢复演练;"创建了备份任务"不代表备份一定可恢复。
- 保留 Nginx 访问日志、错误日志和 Node.js 应用日志,配置磁盘、内存、证书到期与进程存活告警。
- 对登录、验证码和写接口增加应用层限流;高风险业务还应增加 WAF、审计和更严格的网络隔离。
HTTPS 保护的是传输过程,并不会自动阻止弱密码、SQL 注入、越权访问或泄露在前端包里的密钥。安全必须覆盖网络、系统、代理、应用和数据库每一层。
7.2 按请求经过的层次定位故障
遇到问题时,从外到内逐层验证,通常比反复修改配置更快。先查 DNS,再查端口和证书,然后看 Nginx,最后检查 Node.js 与数据库。
| 现象 | 常见原因 | 优先检查 |
|---|---|---|
| 域名无法解析 | A/AAAA 记录错误、DNS 尚未生效 |
dig 或 nslookup 的结果 |
| 连接超时 | 安全组、防火墙未放行,服务未监听 | 云控制台规则、ss -lntp |
| HTTPS 证书报错 | 证书域名不匹配、过期、证书链不完整 | 浏览器证书详情、续期日志 |
Nginx 返回 502 |
Node.js 未运行、端口错误、仅监听了错误地址 | pm2 status、本机 curl、Nginx 错误日志 |
/api/todos 返回 404 |
proxy_pass 斜杠语义与后端路由不一致 |
Node.js 实际收到的路径 |
React 子路由刷新 404 |
缺少 try_files ... /index.html |
Nginx 的 location / |
| 浏览器提示 CORS | 前端绕过 Nginx 直连了其他源 | 构建后的 API 地址、Network 面板 |
| 页面更新后仍是旧版本 | index.html 被强缓存 |
响应头、浏览器缓存与 CDN 缓存 |
API 返回 500 |
应用异常、数据库连接或 SQL 失败 | Node.js 日志、数据库状态与慢查询 |
最终验收不能只看"首页能打开"。至少要检查 HTTP 自动跳转 HTTPS、证书有效、React 子路由可刷新、API 读写正常、错误请求返回合理状态码、Node.js 重启后可恢复、数据库备份可用,以及公网扫描不到 3001、3306 和未授权的面板端口。
总结
全栈项目上线是一条连续请求链:DNS 将域名解析为公网 IP,流量经安全组和防火墙进入 Nginx,由它处理 HTTPS、静态文件与 API 代理;React 在浏览器运行,Node.js 在本机端口执行业务,MySQL 只向后端提供数据。开发时由 Vite 代理 /api,生产时由 Nginx 提供同源入口。理解端口边界、proxy_pass 路径、SPA 回退、进程守护、日志与备份,才能把面板操作变成可验证、可排错、可恢复的部署工程。