nginx 反向代理配置详解:proxy_pass 与请求头
逐行配置 nginx 反向代理 server 块,说明 proxy_pass、4 个关键请求头、WebSocket、路径末尾斜杠、上传处理,以及 Ubuntu 24.04 的配置位置。
nginx 反向代理配置的作用
nginx 反向代理接收来自 80 和 443 端口的请求,将每个请求转发给已经监听本地端口的应用,然后把应用的响应返回给浏览器。配置由一个 server 块组成,内容很短。大部分难点集中在其中五六行配置上。这些配置用于告诉应用真实客户端的身份,以及客户端使用的协议。
以下内容从空配置开始,基于 Ubuntu 24.04 和发行版提供的 nginx 软件包完成。起点是一个已经在 127.0.0.1:3000 上提供响应的应用。如果您还没有决定使用哪种代理,请先阅读 nginx 与 Caddy 和 Traefik 的对比。下面将逐行说明 nginx 配置的写法。
请在您自己的服务器上运行这些配置。重新加载前,先使用 sudo nginx -t 测试每次修改,并查看其输出。
Ubuntu 上 nginx 保存配置的位置
sudo apt update
sudo apt install -y nginx
ls -l /etc/nginx/sites-enabled/主配置文件是 /etc/nginx/nginx.conf。它在 http { } 块中设置全局选项,然后引入两个目录:/etc/nginx/conf.d/*.conf 和 /etc/nginx/sites-enabled/*。在 Ubuntu 和 Debian 上,每个站点都应在 /etc/nginx/sites-available/ 中创建一个文件,并通过创建指向 /etc/nginx/sites-enabled/ 的符号链接来启用该站点。删除符号链接即可禁用站点,同时保留配置文件。
后文使用的两个指令只能在 http 上下文中生效,不能放在 server 块中:map 和 upstream。请将它们放在 /etc/nginx/conf.d/ 下的独立文件中,因为该目录会在 http 级别被引入。
软件包会附带一个已启用的站点,名为 default。它标记为 default_server,表示当请求的 Host 请求头与配置中的任何 server_name 都不匹配时,该站点会响应请求。在它保持启用的情况下,未匹配到您站点的请求会落到该站点,而不是您的应用。确认自有站点正常工作后,请删除该符号链接。
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx代理单个应用的最小 server 块
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}将其保存为 /etc/nginx/sites-available/app.example.com,然后启用并加载。
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
curl -sI -H 'Host: app.example.com' http://127.0.0.1/listen 80;绑定 IPv4,listen [::]:80;绑定 IPv6。如果省略第二行,而访客的 DNS(域名系统)查询为服务器返回 AAAA 记录,连接就会被拒绝;IPv4 用户看到的站点则正常运行。你收到的错误报告会是“我这里可以正常访问”。
server_name会与浏览器发送的 Host请求头进行匹配。可以用空格分隔列出多个名称。如果没有匹配的 server 块,nginx 会使用 default_server 的块,这就是必须移除打包站点的原因。
location /会对请求路径执行前缀匹配,/匹配所有路径。proxy_pass是 nginx 建立连接的地址。让应用继续绑定到 127.0.0.1,这样外部只能通过 nginx 访问。如果应用运行在容器中,请将其发布为 127.0.0.1:3000:3000,而不是 3000:3000,因为Docker 会自行写入规则,并直接绕过 ufw 发布端口;因此,无论防火墙如何配置,直接发布的端口都可从互联网访问。
curl行会让服务器本身发送正确的 Host请求头,因此你可以在 DNS 指向任何位置之前测试此 server 块。
未进行其他配置时,nginx 会向上游发送的内容
单独使用 proxy_pass 会隐藏应用需要知道的四项信息。
nginx 默认使用 HTTP/1.0 与后端通信,并发送 Connection: close,因此每个请求都会新建一个上游连接,也无法进行协议升级。
Host 请求头会被重写为 proxy_pass 中的值,即 127.0.0.1:3000。如果应用根据 Host 生成绝对链接,生成的链接将无法从服务器外部访问。
到达应用的连接来自 nginx,因此应用看到的客户端地址是 127.0.0.1。应用中的每条日志和速率限制记录的都是代理,而不是访问者。
应用无法判断浏览器是否使用了 HTTPS,因为它收到的是来自 loopback 地址的普通 HTTP 连接。
添加四行配置即可解决这些问题。
需要设置的 4 个请求头,以及每个请求头能让后端看到的内容
location / {
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;
}Host 携带访问者输入的主机名。$host 是请求中的主机名,已移除端口并转换为小写。设置它后,应用才能生成正确的绝对 URL,例如登录后的重定向地址或密码重置邮件中的链接。如果省略它,这些 URL 会指向 127.0.0.1:3000,登录后浏览器会跳转到拒绝连接的地址。如果应用还需要端口,例如通过 8080 提供服务,请使用 $http_host。该值与客户端发送的请求头完全一致。
X-Real-IP 只携带一个值:$remote_addr,即 nginx 接受连接时的地址。应用会读取它,用于自己的访问日志和限流。
X-Forwarded-For 携带一个列表。$proxy_add_x_forwarded_for 会将 $remote_addr 添加到客户端已写入该请求头的内容之后,因此该值以逗号分隔,而 nginx 添加的条目位于最后。这个细节决定了是否可以信任该请求头:客户端可以发送任意 X-Forwarded-For,因此读取第一项的应用可能被告知任意地址。当 nginx 是边缘服务器时,应改为写入 $remote_addr,并丢弃客户端发送的值。当 CDN 或其他代理位于 nginx 前面时,请使用 realip 模块中的 set_real_ip_from 和 real_ip_header,这样 $remote_addr 本身就会变成真实的客户端地址。
X-Forwarded-Proto 携带 http 或 https。框架会读取它,以决定是否将 Cookie 标记为 Secure,以及是否强制重定向到 HTTPS。在 TLS 站点上省略该请求头时,配置为强制使用 HTTPS 的应用会看到 http,随后响应一个指向 HTTPS 地址的重定向;浏览器通过 nginx 发起下一次请求,应用仍然看到 http,于是再次重定向。浏览器最终放弃,并显示 ERR_TOO_MANY_REDIRECTS。
在每个 location 中重复这 4 行,会导致配置逐渐不一致。请将它们放入一个文件,然后 include 该文件。
# /etc/nginx/snippets/proxy-headers.conf
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 / {
include snippets/proxy-headers.conf;
proxy_pass http://127.0.0.1:3000;
}这里的继承机制有一个陷阱。只有在 location 自身没有定义任何 proxy_set_header 指令时,它才会从 server 块继承这些指令。在 location 中添加一条 proxy_set_header 后,该 location 会丢弃 server 层定义的所有请求头。因此,应将它们统一放在同一层级,或者在每个执行代理转发的 location 中 include 该片段。
为什么我的 WebSocket 应用连接后立即断开?
因为默认配置禁止升级,而且默认读取超时会在 60 秒后关闭空闲隧道。WebSocket 以携带 Upgrade: websocket 和 Connection: Upgrade 的 HTTP 请求开始。这些是逐跳标头,意味着代理应使用它们,而不是将其转发出去;HTTP/1.0 完全没有升级机制。必须手动将这两个标头加回去。
将 map 放在 http 上下文中,并单独保存到一个文件。
# /etc/nginx/conf.d/websocket.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}然后配置 location。
location / {
include snippets/proxy-headers.conf;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}设置 map 是为了让一个 location 同时处理这两类流量。对于普通请求,$http_upgrade 为空,因此 $connection_upgrade 会变成 close。对于升级请求,$http_upgrade 包含 websocket,因此发送到上游的标头是 Connection: upgrade。硬编码 proxy_set_header Connection "upgrade"; 会导致每个普通页面请求也发送该标头,某些后端会对这类请求返回 400。
proxy_read_timeout 是导致“页面能加载,但之后不再更新”问题的原因。它默认设置为 60 秒,衡量的是后端两次读取之间的间隔,而不是连接的持续时间。保持静默 60 秒的 WebSocket 会被 nginx 关闭,浏览器控制台会显示套接字以代码 1006 关闭。应用如果每分钟发送一次以上自己的心跳,就不会遇到这个问题。未发送心跳的应用会在整整一分钟后断开。在线编辑器和仪表板最容易出现此问题,通过 HTTPS 运行的自托管 n8n 实例就是常见示例。
proxy_pass 末尾的斜杠为什么会改变 URL?
规则只有一句话。如果 proxy_pass 以 URI(统一资源标识符)结尾,即使只是一个单独的 /,nginx 也会移除请求路径中与 location 前缀匹配的部分,并将该 URI 放在原位置。如果 proxy_pass 只写到主机和端口,请求路径会原样传递。
location /app/ {
proxy_pass http://127.0.0.1:3000/;
}对 /app/status 的请求到达后端时为 /status。
location /app/ {
proxy_pass http://127.0.0.1:3000;
}对 /app/status 的请求到达后端时为 /app/status。
具体使用哪种形式取决于应用。带有 base-path 或子目录设置的应用需要第二种形式,并将 /app 配置到该设置中。不识别前缀的应用需要第一种形式。第一种形式的影响会立即出现:应用返回的 HTML 仍包含 /static/main.css 这样的绝对路径,浏览器会从站点根路径请求这些资源,但没有任何 location 匹配,因此页面不会加载样式。浏览器的网络面板会显示这些资源请求返回 404。解决方法是配置应用自身的 base-path,或添加第二个 location /static/,将其指向同一个后端。
正则表达式 location 不能在 proxy_pass 中携带 URI。sudo nginx -t 会拒绝该配置,并说明原因:"proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" block。
如果为每个应用分配独立的名称 app.example.com,再从 location / 进行反向代理,这类问题就会消失。只有在无法添加 DNS 记录时,才值得使用子路径。
如何让一个名称后面挂载多个后端?
使用一个 upstream 块。它属于 http 上下文,因此应将其写在同一文件中的 server 块之前,或写入 /etc/nginx/conf.d/。
upstream app_backend {
least_conn;
server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
keepalive 32;
}然后在 location 中引用该名称:proxy_pass http://app_backend;。
默认方法是轮询。least_conn 会将每个请求发送到当前活动连接数最少的后端,适合请求处理时长不均匀的场景。ip_hash 会将一个客户端地址固定到一个后端。当应用在自身内存中保存会话时,需要使用 ip_hash,因为两个此类后端采用轮询时,客户端请求可能被发送到未处理过该客户端请求的实例,从而导致用户随机退出登录。更好的做法是将会话移到共享存储中。
max_fails=3 fail_timeout=30s 表示在 30 秒内失败 3 次后,将该服务器移出服务 30 秒。当块中的所有服务器都处于此状态时,客户端会收到 502,错误日志中会显示 no live upstreams while connecting to upstream。
keepalive 32 会让每个 worker 进程最多保持 32 个与后端的空闲连接,从而使大多数请求无需重新执行 TCP 握手。它仅适用于 proxy_http_version 1.1,并且上游不能使用 Connection: close。如果同一个 location 还使用 WebSocket 映射,应将空值从 close 改为空字符串,这样普通请求不会携带 Connection header,并且可以复用连接池中的连接。
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}upstream 块中的名称会在 nginx 启动时解析。如果后端是容器,并且容器重启后会获得新地址,nginx 会继续使用旧地址,直到重新加载配置。在 Docker 网络中,可以通过内置 resolver 将解析移到请求处理时。
resolver 127.0.0.11 valid=10s;
set $backend http://app:3000;
proxy_pass $backend;当容器频繁创建和销毁,以至于您需要不断修改 nginx 配置时,使用能够读取容器标签的代理会更合适。使用 Traefik 代理多个 Docker Compose 应用会直接根据容器自身生成路由。
为什么上传会因 413 Request Entity Too Large 失败?
client_max_body_size默认为 1 megabyte。请求体较大时,nginx 会在应用收到任何数据前拒绝请求,错误日志会记录 client intended to send too large body。请在 server 块中,或在执行上传的 location 中调高该值。
client_max_body_size 512m;将值设为 0 会完全关闭检查。应用本身也有大小限制,因此修改后仍然出现 413,说明错误来自后端。下一步应检查应用自身的上传设置。
默认情况下,nginx 会先读取完整的请求体,然后再建立 upstream 连接。对于较大的请求体,nginx 会先将其写入磁盘上的临时文件。这样可以避免慢速客户端拖累应用,因为后端会以本地网络的完整速度接收上传内容。对于非常大的上传,可以改为流式传输。
proxy_request_buffering off;此时后端会在请求体到达时接收内容,因此必须能够处理这种传输方式。nginx 也无法再将请求重试到其他 upstream,因为请求体已经无法重新读取。
client_body_timeout默认为 60 seconds,作用于请求体两次连续读取之间的间隔,而不是整个上传过程。速度缓慢但持续上传的请求可以通过该限制。完全停滞的上传会被丢弃。
响应缓冲,以及会破坏实时输出的设置
proxy_buffering默认启用,通常也正是您需要的设置。nginx 会以应用写入响应的速度读取响应,将其暂存,然后按慢速客户端自身的速度发送。应用工作进程会提前结束,而不必在整个慢速下载期间持续占用。
这会破坏流式响应。服务器发送事件和实时日志输出在缓冲区填满前不会向读取方显示任何内容。仅在该位置关闭缓冲。
proxy_buffering off;如果您可以控制应用,更好的做法是仅在流式响应中发送 X-Accel-Buffering: no 响应头。nginx 会逐个响应读取该响应头,仅为该响应禁用缓冲,因此普通页面仍可获得缓冲的好处。
当错误日志显示 upstream sent too big header while reading response header from upstream 时,表示响应头无法放入单个缓冲区。proxy_buffer_size 默认为一个内存页,具体为 4 或 8 kilobytes,取决于平台;较长的 cookie 或较大的身份验证响应头会使其溢出。请同时增大这两个值。
proxy_buffer_size 16k;
proxy_buffers 8 16k;TLS 应配置在哪里?
配置在 nginx 上,位于上述所有内容的前面。TLS(传输层安全)在代理处终止,nginx 到应用的连接通过回环地址保持为普通 HTTP,网络中的其他设备无法读取该连接。应用通过 X-Forwarded-Proto 获知访问者使用了 HTTPS;这是四个标头中的第四个。
不要手动填写证书路径。将 DNS 记录指向服务器,开放防火墙端口,然后让 Certbot 修改同一个 server block:它会添加 listen 443 ssl 行和 ssl_certificate 路径,并将端口 80 重定向到 HTTPS。使用 Certbot 为 nginx 签发 Let's Encrypt 证书介绍证书签发过程和续期计时器。
sudo ufw allow 'Nginx Full'
sudo ufw statusNginx Full 是 nginx 软件包安装的应用配置,它会同时开放端口 80 和端口 443。即使所有访问者都会被重定向到 HTTPS,端口 80 仍必须保持开放,以便通过 HTTP-01 完成续期验证。
测试配置,然后重新加载
sudo nginx -t
sudo systemctl reload nginxnginx -t 会解析所有包含的文件,并报告测试是否成功;如果失败,则输出停止位置所在的文件和行号。重新加载前先阅读该输出。配置损坏时重新加载不会生效:nginx 会继续使用之前的配置提供服务,因此网站仍保持在线,但您的修改会静默失效。systemctl restart 的行为不同,而且后果更严重,因为重启会先停止正在运行的服务器;配置错误会导致 nginx 完全无法运行。默认使用重新加载,仅在确实需要时才重启。
sudo tail -f /var/log/nginx/error.log
sudo ss -lntp | grep -E ':(80|443|3000)'ss 行会显示每个端口由哪个进程占用,因此您可以确认应用确实在 proxy_pass 所指向的位置监听。
实际会遇到的故障
502 Bad Gateway,错误日志中包含 connect() failed (111: Connection refused) while connecting to upstream。 proxy_pass 中的地址没有任何进程监听。应用已停止、绑定到了其他端口,或绑定到了主机无法访问的容器内部地址。
502,错误日志中包含 no live upstreams while connecting to upstream。 upstream 块中的所有服务器当前都已被 max_fails 标记为失败。请修复后端。fail_timeout 到期后,nginx 会重试这些后端。
504 Gateway Time-out,错误日志中包含 upstream timed out (110: Connection timed out) while reading response header from upstream。 后端已接受连接,但在 proxy_read_timeout 秒内没有发送任何内容。对于确实运行缓慢的报表,提高超时值是正确的;对于已卡住的应用,这样做无法解决问题。
每个路径都由应用返回 404。 末尾斜杠规则重写了路径。比较应用日志记录的路径和您请求的路径。
由其他站点响应。 server_name 与 Host 标头不匹配,因此请求落入了 default_server 块。
页面可以加载,但大约 1 分钟后界面冻结。 这是 WebSocket 问题:缺少 Upgrade 处理,或 proxy_read_timeout 仍为 60 秒。
FAQ
为什么添加 proxy_pass 后 nginx 返回 502 Bad Gateway?
nginx 无法连接到 proxy_pass 中的地址。/var/log/nginx/error.log 中的错误日志会说明原因:connect() failed (111: Connection refused) while connecting to upstream 表示该地址没有进程监听,no live upstreams 表示 upstream 块中的所有服务器都已被标记为失败。运行 sudo ss -lntp | grep 3000,查看哪个进程占用了该端口,以及该进程绑定到哪个地址。应用绑定到容器内部地址,或绑定到与配置中不同的端口时,都会持续出现此错误。
为什么应用在 nginx 后运行约一分钟后断开连接?
该连接是 WebSocket,而 proxy_read_timeout 仍使用默认值 60 秒。此值表示后端两次读取之间允许的最长间隔。空闲 socket 会被 nginx 关闭,浏览器控制台会报告关闭代码 1006。设置 proxy_http_version 1.1,通过 Upgrade 和 Connection,并在 $http_upgrade 上使用 map,将 proxy_read_timeout 调高到类似 3600s 的值。如果没有 Upgrade 标头,升级根本不会发生,因此应用会回退到轮询,或不再显示实时更新。
proxy_pass 末尾的斜杠重要吗?
重要,而且它会改变后端收到的路径。使用 location /app/ 和 proxy_pass http://127.0.0.1:3000/ 时,对 /app/status 的请求到达后端后会变为 /status,因为主机和端口后的 URI 会替换匹配到的 location 前缀。删除最后的斜杠后,同一个请求会变为 /app/status。删除前缀通常会破坏应用自身的资源链接。这些链接保持绝对路径,随后会在站点根路径下返回 404。因此,对于设置了 base path 的应用,最好使用能够传递该路径的写法。
为什么应用记录的每个访客 IP 地址都是 127.0.0.1?
因为应用实际收到的连接确实来自 loopback 地址上的 nginx。只有通过您设置的标头,访客地址才能传递给应用:单个地址使用 proxy_set_header X-Real-IP $remote_addr;,追加的地址链使用 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;。随后还必须将应用配置为信任这些标头。请注意,客户端可以自行发送 X-Forwarded-For。因此,当 nginx 是边缘服务器时,应使用 $remote_addr 覆盖该值,而不是追加。
nginx 与应用之间的连接需要 TLS 吗?
如果应用运行在同一台服务器上,并绑定到 127.0.0.1,则不需要,因为该流量不会离开本机。在 nginx 上终止 TLS,让 proxy_pass 通过 loopback 使用普通 HTTP,并发送 X-Forwarded-Proto $scheme,使应用知道访客使用的是 HTTPS。如果后端位于另一台主机上,且两台主机之间的网络不受您控制,则该链路需要单独保护。可以使用到后端的 HTTPS,或在两台机器之间建立专用隧道。