用 Docker 部署网站时,后台程序和 Nginx 往往不在同一个容器里。后台已经启动,Nginx 也显示运行正常,浏览器打开却是 502 Bad Gateway。这时去改域名、反复放行端口,可能一直找不到问题。
这篇从配置文件开始搭一套环境:一个容器提供网页,另一个容器做反向代理。先把网页访问通,再分别改错上游地址、端口和监听地址,看看报错到底长什么样,最后恢复配置。
需要用到的文件都在下面,不依赖数据库、域名或证书。前半部分先照着部署,后半部分可以用来对照自己的 502 问题。
环境说明:配置与后面的四种故障已在 Docker Engine 29.7.2、Compose 5.3.1、Nginx 1.31.6 的 Linux 容器中验证,宿主环境为 Docker Desktop。下文给出的文件创建命令使用 Linux Shell,适合在 Ubuntu、Debian 云服务器的 SSH 终端执行。云安全组和跨电脑的 SSH 转发步骤,需要按自己的服务器环境设置,不计入这里的容器故障验证。
1:先看一下要搭什么
这里用两只容器:
app:放一个静态网页,内部监听 8080,不把端口发布到宿主机。gateway:作为访问入口,内部监听 80,再把请求转发给app:8080。
两只容器都加入 webnet 网络,访问关系如下:
在服务器上访问 http://127.0.0.1:18980
│
▼
宿主机 127.0.0.1:18980 → gateway 容器的 80
│
webnet 网络
│
▼
app 容器的 8080
│
▼
site/index.html注意这三个端口:外面访问 18980,代理自己监听 80,后端网页监听 8080。后面看到 proxy_pass 时,要填最后一个端口,不能看到 Docker 列表里有个 80 就直接填 80。
先在服务器里检查工具是否已经装好:
docker version
docker compose version
curl --version这里使用的是带空格的 docker compose。如果提示 docker: 'compose' is not a docker command,先安装 Compose 插件;如果提示没有权限连接 Docker socket,使用已有 Docker 操作权限的账号,或在下面的 Docker 命令前加 sudo。不要为了省事把 /var/run/docker.sock 改成所有人可写。
如果 docker version 只有 Client 信息,Server 部分报连接失败,也先把 Docker 服务的问题处理好。下面的步骤建立在 Docker 已经能正常启动容器的基础上。
2:创建目录和四个文件
所有文件都放在一个新目录里,别覆盖现有网站的配置。
mkdir -p ~/nginx-502-demo/site ~/nginx-502-demo/nginx
cd ~/nginx-502-demo
pwdpwd 打印出来的路径应该以 nginx-502-demo 结尾。后面的 docker compose 命令都在这个目录里执行。如果关闭 SSH 后重新登录,先执行 cd ~/nginx-502-demo,再继续操作。
2.1:网页文件
把下面一整段复制到终端执行。最后单独一行的 EOF 也要复制,它表示文件内容到这里结束。
cat > site/index.html <<'EOF'
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>Docker 反向代理演示</title>
<h1>页面来自 app 容器</h1>
<p>gateway → app:8080 → index.html</p>
</html>
EOF这里特意写上“页面来自 app 容器”。稍后如果看到的是 Nginx 默认欢迎页,说明访问到的还不是这份网页,不能只看到一个页面就认为配置完成。
2.2:后端的 Nginx 配置
创建 nginx/app.conf:
cat > nginx/app.conf <<'EOF'
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
EOFlisten 8080 是后端在容器内部监听的端口。root 指向容器内部的网页目录,后面会把宿主机的 site 文件夹挂载到这里。
try_files 会先找请求对应的文件或目录,找不到就返回 404。这个例子只放了 index.html,所以 / 能打开,而随便写一个不存在的路径,返回 404 是正常的。
命令里的 <<'EOF' 不要擅自去掉引号。配置中的 $uri 是 Nginx 变量,需要原样写入文件;去掉引号后,Shell 可能先把它当成自己的变量展开,生成的文件就和上面不一样了。
2.3:入口的反向代理配置
创建 nginx/gateway.conf:
cat > nginx/gateway.conf <<'EOF'
server {
listen 80;
server_name _;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
EOF最关键的是 proxy_pass http://app:8080;。app 对应稍后 Compose 文件中的服务名称,Docker 会在共享网络里解析这个名称。它不是公网域名,也不需要去域名服务商那里添加解析记录。
这份配置没有改写请求路径:访问入口的 /,就交给后端的 /。不要在还没跑通的时候,顺手加 /api、正则匹配或路径重写,否则遇到 404 时又多了一处需要检查。
后面的三行把 Host 和客户端来源信息传给上游。本例只返回静态页面,并不依赖这些请求头;换成自己的程序时,是否信任代理传来的来源信息,要按程序的代理设置处理。
2.4:Compose 文件
创建 compose.yaml:
cat > compose.yaml <<'EOF'
name: nginx502-demo
services:
app:
image: nginx:alpine
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"]
interval: 5s
timeout: 2s
retries: 10
start_period: 3s
volumes:
- ./site:/usr/share/nginx/html:ro
- ./nginx/app.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- webnet
gateway:
image: nginx:alpine
depends_on:
app:
condition: service_healthy
ports:
- "127.0.0.1:18980:80"
volumes:
- ./nginx/gateway.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- webnet
networks:
webnet:
EOF简单说一下这里几处容易改错的地方:
name 固定了演示项目名,避免换个目录就被当作另一套项目。如果机器上已经有叫 nginx502-demo 的项目,先改成另一个未使用的名称,再执行启动命令。
volumes 冒号左侧是宿主机路径,右侧是容器路径,最后的 ro 表示容器只读。我们在宿主机上编辑文件,容器读取这些文件即可,不需要给容器写配置的权限。
ports 只写在 gateway 下面。app 没有发布端口,入口也能通过共享网络访问它,不用为了两个容器通信,把后端的 8080 也开放到公网。
healthcheck 从 app 容器内部请求自己的网页。gateway 会等这个检查通过后再启动。只写普通 depends_on,主要保证启动顺序,不等于等待网页服务已经准备好。这里的检查只证明 app 内部能访问自己,后面会专门演示“healthy 但代理仍然 502”的情况。
nginx:alpine 是官方 Nginx 镜像的可变标签,后续拉取可能得到不同版本。要复用同一镜像,可以记录 docker image inspect nginx:alpine --format '{{json .RepoDigests}}' 的结果,再用 nginx@sha256:实际摘要 替换两个 image 值。不要把摘要中的省略号当作真实内容粘进去。
2.5:确认文件确实创建好了
ls -l compose.yaml site/index.html nginx/app.conf nginx/gateway.conf
cat nginx/gateway.conf
docker compose config -q
图 1:四份文件均已创建,proxy_pass 指向 app:8080。截图在临时演示目录查看相同文件,目录名可以与正文不同。
前四个路径都应该是文件,ls -l 每行最前面不是目录的 d。cat 的结果中应当还保留 $host、$remote_addr 等变量。
最后一条检查 Compose 语法,正常时不输出内容,检查失败就先改它报出的行。它不会替你检查 Nginx 的 proxy_pass 是否正确,也不会验证目标端口能不能连通。
如果后面启动时出现 not a directory 一类挂载错误,先回来看文件是否存在、路径是否写反。短格式挂载的源路径写错后,Docker 可能创建一个同名目录;把那个目录当成 .conf 文件挂进去当然不行。确认这是自己误建的空目录后再处理,不要直接删除现有网站目录。
3:启动,然后分两段测试
3.1:启动容器
docker compose up -d
docker compose ps -a第一次需要拉取镜像。国内网络如果卡在 Pulling、出现超时或 TLS 连接错误,先处理镜像下载;还没有创建出容器时,改反向代理地址不会有帮助。本机已有可信镜像缓存的,可以先使用缓存,不必反复拉取。
正常启动后,app 应该出现 healthy,gateway 也应当处于运行状态。刚启动时 app 暂时显示 health: starting,稍等一次检查周期,再执行 docker compose ps -a 看结果。
如果一直不健康,执行:
docker compose logs --tail=50 app
docker compose exec app wget -S -O - http://127.0.0.1:8080/第二条是在 app 容器里访问 app 自己。能返回网页,说明程序已经提供响应;如果返回连接拒绝,检查 app 的配置和启动日志。如果容器已经退出,exec 进不去,先看日志,别反复执行 exec。
启动日志里可能有 can not modify /etc/nginx/conf.d/default.conf (read-only file system?)。这份配置本来就是只读挂载,镜像的初始化脚本无法自动修改它。需要结合后面的启动结果看,不能把这条提示单独当作 Nginx 已经启动失败。
3.2:从入口容器直接访问后端
docker compose exec gateway wget -S -O - http://app:8080/验证时返回了以下内容,省略了日期和下载进度:
Connecting to app:8080 (172.22.0.2:8080)
HTTP/1.1 200 OK
Server: nginx/1.31.6
...
<h1>页面来自 app 容器</h1>
<p>gateway → app:8080 → index.html</p>172.22.0.2 是这次 Docker 分配的地址,你机器上的可能不一样,不用照着改。这里要看两件事:名称 app 能解析,8080 上也确实返回了我们写的网页。
这一步通过后,gateway 到 app 这一段已经能通。接下来测试宿主机入口。
3.3:在服务器上访问入口
curl -i --max-time 5 http://127.0.0.1:18980/正常响应的关键部分是:
HTTP/1.1 200 OK
Content-Type: text/html
...
<h1>页面来自 app 容器</h1>
图 2:入口返回的是刚才创建的网页,页面内容与 site/index.html 一致。
如果返回 502,说明有 HTTP 网关返回错误,继续看第 5 节。如果连 HTTP 响应都没有,先确认 gateway 是否运行、发布地址是否正确:
docker compose port gateway 80
docker compose logs --tail=50 gateway按照本文配置,第一条应该返回 127.0.0.1:18980。命令最后写的是 gateway 的容器端口 80,不要填 18980。
还有一个容易误判的地方:docker compose ps 可能在 app 的 PORTS 栏显示 80/tcp。那是镜像声明的端口信息,并不表示我们这个 app 仍在监听 80,也不是已经映射了一个宿主机端口。查看程序实际监听情况,用:
docker compose exec app netstat -lnt在本文使用的 Alpine 镜像中可以看到:
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN换成别的精简镜像时,里面未必有 wget 或 netstat。提示工具不存在,不代表网络一定不通;这里的命令是按本文镜像写的,不要直接套到所有镜像上。
4:在自己的电脑浏览器里打开
到这里服务器本机已经能收到网页,但你在笔记本输入 http://云服务器IP:18980,仍然打不开,因为配置绑定的是服务器的 127.0.0.1。
这里先用 SSH 转发,把电脑的 18981 连到服务器的 18980,不需要修改安全组开放网页端口。
在自己的电脑上另外打开终端。Windows 可以使用 PowerShell,macOS、Linux 使用终端。执行:
ssh -N -o ExitOnForwardFailure=yes -L 127.0.0.1:18981:127.0.0.1:18980 your_user@203.0.113.10需要改两处:your_user 是自己的 SSH 用户名,203.0.113.10 是文档示例 IP,换成自己的服务器 IP。SSH 如果不是默认的 22 端口,在 ssh 后面加 -p 实际端口。
完成登录后,这个终端会一直停在那里,没有普通 SSH 的命令提示符。-N 就是只做转发、不打开远程 Shell,这时不要因为没出现提示符就直接关掉窗口。
保持连接,在电脑浏览器打开:
http://127.0.0.1:18981/看到“页面来自 app 容器”,浏览器到服务器入口这一段也接上了。这里浏览器使用的是电脑的 18981,服务器内部仍然使用 18980。
如果提示 Address already in use,换一个未占用的本地端口,例如把命令中的第一个 18981 改成 18982,浏览器同步改成 http://127.0.0.1:18982/。不要改后面的服务器目标端口 18980。
如果提示 administratively prohibited,检查 SSH 服务端是否允许 TCP 转发,或使用已有的远程访问通道。ExitOnForwardFailure=yes 能帮助发现本地转发建立失败,但不会提前替你验证网页后端是否正常;后端已经停了,通道仍可能建立成功。
5:故意改错配置,看看 502 怎么查
下面只改这套演示环境。先留一份正确配置,后面每次改错都能恢复:
cp nginx/gateway.conf nginx/gateway.conf.good
cp nginx/app.conf nginx/app.conf.good如果只想部署,不想做故障演示,可以跳过本节。要跟着做,就一次只改一个问题,恢复后确认 200,再进行下一个。
5.1:上游写成 127.0.0.1,语法正常却一直 502
打开宿主机上的 nginx/gateway.conf,找到:
proxy_pass http://app:8080;把这一行改成:
proxy_pass http://127.0.0.1:8080;保存以后,执行以下命令。&& 表示前一条检查成功,才执行后一条重载:
docker compose exec gateway nginx -t && docker compose exec gateway nginx -s reload
curl -i --max-time 5 http://127.0.0.1:18980/
docker compose logs --tail=30 gateway这时 nginx -t 仍然可以返回 syntax is ok 和 test is successful,但网页会变成 502。

图 3:把上游改成 127.0.0.1:8080 后,浏览器实际返回 502。接下来要看代理日志,单凭这个页面无法知道连接失败的具体地址。
日志里的关键内容是:
connect() failed (111: Connection refused) while connecting to upstream
upstream: "http://127.0.0.1:8080/"原因就在这个地址。Nginx 在 gateway 容器里,它找的 127.0.0.1 是 gateway 自己,不是 app,也不是宿主机。gateway 自己的 8080 上没有我们要找的网页,自然会连接失败。
如果你用 1Panel 配反向代理,先确认执行代理请求的 Nginx 或 OpenResty 到底运行在宿主机、host 网络,还是独立的 bridge 容器里。同样写 127.0.0.1,在不同位置指向的对象并不一样。不能因为宿主机终端里 curl 能通,就认为另一个容器里的同一地址也能通。
恢复正确配置:
cp nginx/gateway.conf.good nginx/gateway.conf
docker compose exec gateway nginx -t && docker compose exec gateway nginx -s reload
curl -i --max-time 5 http://127.0.0.1:18980/返回 200 和演示网页后再继续。
5.2:服务名对了,端口填成 80 也会 502
这次把 nginx/gateway.conf 中的上游改成:
proxy_pass http://app:80;同样检查并重载,然后访问:
docker compose exec gateway nginx -t && docker compose exec gateway nginx -s reload
curl -i --max-time 5 http://127.0.0.1:18980/
docker compose logs --tail=30 gateway这里的日志仍然是连接拒绝,但上游变成了 app 的容器 IP 和 80 端口:
connect() failed (111: Connection refused) while connecting to upstream
upstream: "http://172.22.0.2:80/"这说明服务名已经被解析成 IP,下一步应该查后端监听端口,而不是继续改域名解析。
分别在 gateway 里测试两个地址:
docker compose exec gateway wget -S -O - http://app:80/
docker compose exec gateway wget -S -O - http://app:8080/在本例中,第一条报 Connection refused,第二条返回网页。对照 app 配置的 listen 8080,就知道应该把上游恢复成 app:8080。
cp nginx/gateway.conf.good nginx/gateway.conf
docker compose exec gateway nginx -t && docker compose exec gateway nginx -s reload
curl -i --max-time 5 http://127.0.0.1:18980/5.3:app 显示 healthy,gateway 却连不上
这次保持 gateway 的正确配置不动,只编辑 nginx/app.conf,把:
listen 8080;改成:
listen 127.0.0.1:8080;检查后重启 app。这里改变的是监听地址,为了让演示中的监听状态重新建立,使用容器重启:
docker compose exec app nginx -t && docker compose restart app
docker compose ps -a等健康检查通过后,在两个位置访问同一个服务:
# 在 app 容器里访问自己
docker compose exec app wget -S -O - http://127.0.0.1:8080/
# 在 gateway 容器里访问 app
docker compose exec gateway wget -S -O - http://app:8080/第一条仍然能拿到网页;第二条会报连接拒绝,外面的入口也返回 502。
这并不矛盾。app 只在自己的回环地址上监听,健康检查正好也是从 app 内部访问 127.0.0.1,所以检查可以通过。gateway 通过 app 的网络接口访问时,那里却没有对外监听的 8080。
如果你部署的是 Python、Node.js 等后端程序,也可能遇到同样的现象。需要检查程序自身的监听参数,而不只是看 Compose 的 ports。至于监听参数叫 --host、HOST 还是其他名字,按那个程序的文档修改,不要把本例的 Nginx 配置原样塞进别的程序。
恢复 app:
cp nginx/app.conf.good nginx/app.conf
docker compose exec app nginx -t && docker compose restart app
docker compose exec gateway wget -S -O - http://app:8080/
curl -i --max-time 5 http://127.0.0.1:18980/app 刚重启的短暂时间里可能还没准备好;等内部请求恢复 200 后,再检查入口。
5.4:服务名写错,检查时就会报 host not found
把 nginx/gateway.conf 中的上游改成一个不存在的服务名:
proxy_pass http://not-a-service:8080;这次先只检查配置:
docker compose exec gateway nginx -t会看到:
host not found in upstream "not-a-service"
nginx: configuration file /etc/nginx/nginx.conf test failed不要继续重启 gateway。检查没有通过时,原来运行中的配置仍可能继续提供网页;如果此时直接重启,新的进程读取到错误配置,就可能启动不起来。
先核对 Compose 中服务到底叫 app、backend 还是别的名字,再确认双方有没有共享 Docker 网络。本文环境可以用下面两条检查:
docker compose exec gateway nslookup app
docker network inspect nginx502-demo_webnet第二条的结果会包含这个网络里的容器,应该能找到 app 和 gateway。网络名称来自项目名 nginx502-demo 加网络名 webnet;如果前面改过项目名,这里也要相应修改。
如果自己的代理和后台分属两份 Compose,默认情况下可能各自在不同网络中。服务名不是整个 Docker 主机都能解析的“全局域名”,需要把双方接到同一个合适的网络,再配置服务名或网络别名。本文这两只容器已经通过 webnet 完成这一步。
恢复文件并检查,通过以后再重载:
cp nginx/gateway.conf.good nginx/gateway.conf
docker compose exec gateway nginx -t && docker compose exec gateway nginx -s reload
curl -i --max-time 5 http://127.0.0.1:18980/6:改完文件以后,到底要不要重启
这里分三种情况,别每次都先重启整个 Docker 服务。
只改 site/index.html:它是挂载进去的网页文件,保存后重新请求即可。浏览器仍显示旧内容时,用终端 curl 一次,先排除浏览器缓存。
改 nginx/gateway.conf 中的代理规则:先 nginx -t,通过后再 nginx -s reload。重载会重新读取配置;检查失败时应先修文件。nginx -T 可以打印容器当前路径中的完整配置文件,适合确认自己改的文件是否真的挂载进去了,但它不能独立证明运行中的 worker 已经加载了这次修改,仍要结合重载结果和实际请求判断。
改 compose.yaml 中的 ports、网络或挂载:执行 docker compose config -q 检查,然后 docker compose up -d 应用变化。单纯 docker compose restart 不会按新 Compose 文件重建容器,因此不能用它来应用新的端口映射。
后端容器重建后 IP 可能变化。本例使用普通的静态 proxy_pass http://app:8080,不要假设旧 Nginx worker 一定会及时重新解析变化后的地址。更新后先测试 gateway 到 app 的请求,必要时检查并重载 gateway。若要长期自动跟随后端地址变化,再单独配置适合所用 Nginx 版本的动态解析,不在这份入门配置里混入另一套规则。
7:需要公开访问时,再处理公网入口
目前绑定的是 127.0.0.1:18980,正常部署下只打算让宿主机访问。需要对外提供服务时,可以改 gateway 的发布行为,例如:
ports:
- "0.0.0.0:18980:80"改完执行:
docker compose config -q
docker compose up -d gateway
docker compose port gateway 80再在云服务器对应的安全组里添加 TCP 18980 入站规则。自己测试时,来源限制为自己电脑当前的公网 IP /32,不要直接把所有端口全部放开。如果使用腾讯云轻量应用服务器,还要检查实例的防火墙页面。
然后在电脑上访问 http://自己的云服务器IP:18980/。这里还是 HTTP,没有配置 443 和证书,不要改成 HTTPS 后再把浏览器的 TLS 报错误当成 Docker 网络故障。用于正式网站时,再配置域名、证书和相应入口;这里只验证网络路径。
Linux 上 Docker 发布端口与 UFW 的规则并不能简单等同。不能只看到 ufw deny 就断言容器入口已经被挡住,Docker 官方对此有专门说明。也不要靠禁用 Docker 的防火墙规则来尝试修复 502。
另外,Docker 官方提示:28.0.0 以前的版本存在同一二层网络主机可能访问 localhost 发布端口的情况。旧版环境不要仅凭 127.0.0.1 这个写法就认定已经实现完整隔离。
8:结束演示,或者留下现场继续排查
如果已经做完,先关闭电脑上的 SSH 转发窗口,再回到服务器的演示目录,执行:
cd ~/nginx-502-demo
docker compose down这会停止并移除演示项目的容器和网络。网页及配置仍然保留在当前目录,下一次执行 docker compose up -d 可以再次启动。本文没有创建数据库或命名数据卷,不需要附加 -v,更不用执行 docker system prune。
临时添加过的公网入站规则,也在测试结束后删除。
如果自己的项目仍有问题,先保留容器,不要急着删掉。收集 docker compose ps -a、出错时间附近的代理日志,以及从代理容器内访问上游的结果。配置中有密码、令牌或真实业务地址的,贴日志前先处理掉。日志写的是连接哪个地址、哪个端口失败,通常比一张 502 网页截图更有用。
参考文档:Compose 网络、启动顺序和健康检查、Nginx proxy_pass、Nginx 配置重载、Docker 端口发布、Docker 与防火墙、OpenSSH 转发参数、腾讯云轻量实例防火墙。