前言

当你开始把个人项目往服务器上部署时,很快就会遇到一个问题:只有一个服务器,却有多个服务要跑。

比如我的博客 Kirameku,它实际上由三个服务组成:

  • Next.js 前端 — 端口 3000
  • FastAPI 后端 — 端口 8000
  • reader3 阅读服务 — 端口 8085

不可能让用户记 http://boke.hiromu.top:3000、http://boke.hiromu.top:8000 这种端口号。解决方案就是用 Nginx 做反向代理,把所有服务统一到 80/443 端口,通过路径区分转发。

什么是反向代理

简单来说:Nginx 站在前面接客,根据请求路径判断应该交给后面哪个服务处理。

用户 → boke.hiromu.top/api/xxx → Nginx → FastAPI (8000) 用户 → boke.hiromu.top/reader3/ → Nginx → reader3 (8085) 用户 → boke.hiromu.top/ → Nginx → Next.js (3000)

server {
    listen 80;
    listen 443 ssl;
    http2 on;
    server_name boke.hiromu.top;
    index index.html index.htm default.htm default.html;
    include /www/server/panel/vhost/nginx/extension/Kirameku/*.conf;

    #CERT-APPLY-CHECK--START
    include /www/server/panel/vhost/nginx/well-known/Kirameku.conf;
    #CERT-APPLY-CHECK--END

    #SSL-START
    set $isRedcert 1;
    if ($server_port != 443) {
        set $isRedcert 2;
    }
    if ( $uri ~ /\.well-known/ ) {
        set $isRedcert 1;
    }
    if ($isRedcert != 1) {
        rewrite ^(/.*)$ https://$host$1 permanent;
    }
    ssl_certificate    /www/server/panel/vhost/cert/Kirameku/fullchain.pem;
    ssl_certificate_key    /www/server/panel/vhost/cert/Kirameku/privkey.pem;
    ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
    ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_tickets on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
    add_header Strict-Transport-Security "max-age=31536000";
    error_page 497  https://$host$request_uri;
    #SSL-END

    # 禁止访问的敏感文件
    location ~* (\.user.ini|\.htaccess|\.env.*|\.gitignore|LICENSE|README\.md|package\.json|yarn\.lock|pnpm-lock\.yaml|\.sql)$ {
        return 404;
    }

    # 禁止访问的敏感目录
    location ~* /(\.git|\.svn|\.vscode|\.idea|node_modules)/ {
        return 404;
    }

    location /.well-known/ {
        root  /www/wwwroot/Kirameku;
    }

    # Next.js Route Handler — 必须在 location /api/ 之前
    location = /api/music {
        proxy_pass http://127.0.0.1:3000;
        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 = /api/uapis {
        proxy_pass http://127.0.0.1:3000;
        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 = /feed {
        proxy_pass http://127.0.0.1:3000;
        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 /reader3/ {
        proxy_pass http://127.0.0.1:8085;
        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;
    }

    # 后端 API 反向代理
    location /api/ {
        proxy_pass http://127.0.0.1:8000;
        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 /uploads/ {
        proxy_pass http://127.0.0.1:8000;
        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 / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host:$server_port;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header REMOTE-HOST $remote_addr;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_no_cache 1;
        proxy_cache_bypass 1;
        proxy_cache off;
    }

    access_log  /www/wwwlogs/Kirameku.log;
    error_log  /www/wwwlogs/Kirameku.error.log;
}

逐块讲解

1. 基础配置

server_name 指定这个配置块匹配哪个域名。listen 443 ssl 开启 HTTPS,http2 on 启用 HTTP/2,listen 80 负责把 HTTP 流量重定向到 HTTPS。

2. 最关键的 location 匹配顺序

Nginx 的 location 匹配有优先级:

精确匹配(=) > 前缀匹配(^~) > 正则匹配(~) > 普通前缀匹配

所以我的配置里有几条精确匹配放在普通路径前面:

location = /api/music {
    proxy_pass http://127.0.0.1:3000;
}

location = /feed {
    proxy_pass http://127.0.0.1:3000;
}

这三个路由虽然是 /api/ 开头或是 /feed,但它们是 Next.js 的 Route Handler,需要转发到前端而不是后端。用 = 精确匹配让它们优先于后面的 /api/ 和 / 匹配。

3. proxy_pass 的斜杠陷阱

proxy_pass 后面加不加斜杠有区别:

# 不加斜杠:传递完整路径
location /reader3/ {
    proxy_pass http://127.0.0.1:8085;
}
# 请求 /reader3/something → http://127.0.0.1:8085/reader3/something

# 加斜杠:截断匹配部分
location /reader3/ {
    proxy_pass http://127.0.0.1:8085/;
}
# 请求 /reader3/something → http://127.0.0.1:8085/something

我这里的配置没加斜杠,因为 reader3 服务本身也需要路径前缀来正确路由。

4. WebSocket 支持

Next.js 的 HMR(热模块替换)和某些功能依赖 WebSocket。不加下面这几行,WebSocket 连接会失败:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Upgrade 和 Connection 是 hop-by-hop 头部,默认不会被代理转发。必须显式设置。

5. 传递真实 IP

当你用了反向代理,后端服务收到的请求来源 IP 永远是 127.0.0.1(Nginx 本机)。需要透传真实客户端 IP:

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;

后端从 X-Forwarded-For 头部取真实 IP。比如我的 FastAPI 后端就用了 X-Forwarded-For 来记录访客 IP。

6. HTTPS 重定向

宝塔面板自动生成的配置段:

if ($server_port != 443) {
    rewrite ^(/.*)$ https://$host$1 permanent;
}

把所有非 443 端口的请求 301 重定向到 HTTPS 版本。permanent 返回 301 状态码,浏览器会缓存这个跳转。

踩坑记录

坑 1:Nginx 缓存导致页面不更新

这是遇到最多的问题。部署新版本后,Nginx 可能返回缓存的旧页面:

location / {
    proxy_no_cache 1;
    proxy_cache_bypass 1;
    proxy_cache off;
}

对动态内容直接关掉缓存,省心。如果非要缓存静态资源,用 proxy_cache_path 配合 proxy_cache_valid 精细化控制。

坑 2:SSL 证书路径搞错

宝塔面板统一管理证书,存放在 /www/server/panel/vhost/cert/ 下。如果手动配置,注意证书文件路径要跟面板生成的保持一致,否则 HTTPS 会挂。

坑 3:敏感文件暴露

location ~* (\.user.ini|\.htaccess|\.env.*|\.gitignore|LICENSE|README\.md|package\.json)$ {
    return 404;
}

location ~* /(\.git|\.svn|\.vscode|\.idea|node_modules)/ {
    return 404;
}

防止用户直接访问项目文件。特别是 .env 文件,里面可能包含密钥。

宝塔面板的小技巧

如果是用宝塔面板,配置最终会合并成这个样子:

# 在主配置中引入面板生成的 SSL、伪静态等配置
include /www/server/panel/vhost/nginx/extension/Kirameku/*.conf;
include /www/server/panel/vhost/rewrite/node_Kirameku.conf;

好处是 SSL 证书续期、伪静态规则都能通过面板 UI 管理,不需要每次都手改 Nginx 配置。关键的反代规则写在面板的"配置文件"里就行。

总结

一套 Nginx 反代配置的核心就是:

  1. 想清楚路径规划 — 哪个路径转发到哪个服务
  2. 注意匹配顺序 — 精确匹配优先,特殊路由放在前面
  3. 透传必要信息 — 真实 IP、WebSocket 头部
  4. 缓存策略要明确 — 动态内容关缓存,静态资源再考虑开

配置本身并不复杂,但每个细节都有它的道理。理解了这些,以后加新服务就只是加几行 location 的事。