目录
Docker Nginx 教程:搭建静态网站与反向代理
本节分为两部分:先使用 Nginx 提供静态网站,再将 Nginx 用作反向代理,放在其他应用程序容器前面。最后介绍如何使用 Caddy 自动获取 HTTPS 证书。
最快搭建静态网站
准备一个文件夹 nginx-site,将网站文件放在 public/ 中:
nginx-site/
└── public/
├── index.html
├── style.css
└── app.js
public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Static Site</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<h1>My Static Site</h1>
<p>Served by Nginx in a Docker container.</p>
<p id="path"></p>
</main>
<script src="/app.js"></script>
</body>
</html>
public/app.js 只有一行代码,用于在页面上显示当前网址路径(稍后测试 SPA 时会用到):
document.getElementById("path").textContent = "Current path: " + location.pathname;
style.css 的内容随意。将 public/ 挂载到官方镜像的网站根目录 /usr/share/nginx/html:
$ docker run -d --name site -p 8080:80 -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
806fb20a7c3ba980710bf43520c118d724346248d387f8743f8ba26e5e182345
$ curl -s http://localhost:8080/ | head -n 6
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Static Site</title>
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard/settings
404
Windows 对应写法(点击选项卡切换):
docker run -d --name site -p 8080:80 -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
:ro 表示只读,Nginx 只需读取文件。打开 http://localhost:8080:

最后一条命令请求不存在的路径 /dashboard/settings,得到 404。对于一般网站,这是正确的;但对于 React、Vue 等单页应用(SPA),网址路径由前端处理,服务器应该返回 index.html。这需要自定义配置文件。
自定义配置文件
官方镜像会加载 /etc/nginx/conf.d/ 中所有的 .conf 文件,默认只有一个 default.conf。最简单的做法是用自己的文件替换它。
第一版 default.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 单页应用(SPA):找不到文件时返回 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源长期缓存
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
| 配置 | 作用 |
|---|---|
try_files $uri $uri/ /index.html | 依次查找文件、文件夹,都找不到就返回 /index.html |
location ~* \.(js|css|...)$ | ~* 表示不区分大小写的正则表达式,用于匹配静态资源 |
gzip on + gzip_types | 压缩文本类型的响应。HTML 默认就会压缩,其他类型要列在 gzip_types |
把配置文件也挂载进去:
$ docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
89c2d3163e3db3bb4192bac6dfd48732c5f72d2d9d4584b9d3e1a32416c01f2a
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard/settings
200
$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/style.css
HTTP/1.1 200 OK
Server: nginx/1.30.5
Date: Tue, 13 Oct 2026 00:40:31 GMT
Content-Type: text/css
Last-Modified: Tue, 13 Oct 2026 00:38:42 GMT
Connection: keep-alive
ETag: W/"6acd7d92-154"
Expires: Thu, 12 Nov 2026 00:40:31 GMT
Cache-Control: max-age=2592000
Cache-Control: public, immutable
Content-Encoding: gzip
Windows 对应写法(点击选项卡切换):
docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
/dashboard/settings现在返回200,try_files已生效。Content-Encoding: gzip表示 CSS 已被压缩。- 但
Cache-Control出现了两次:expires 30d会自动生成Cache-Control: max-age=2592000,add_header又添加了一个。浏览器通常能处理,但这不是我们想要的结果。
在浏览器中打开 http://localhost:8080/dashboard/settings,显示的是 index.html,而 JavaScript 读取到的路径是 /dashboard/settings,前端路由就能据此显示对应的页面:

修正缓存设置:删除 expires,只用一个 add_header 写完整:
location ~* \.(js|css|png|jpg|svg|woff2)$ {
add_header Cache-Control "public, max-age=2592000, immutable";
}
$ docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
868d331a579d208e646ee1be3a3d2a906720578a39418db2c4bd50febb892fd7
$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/style.css | grep -E "Cache-Control|Content-Encoding"
Cache-Control: public, max-age=2592000, immutable
Content-Encoding: gzip
Windows 对应写法(点击选项卡切换):
docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
2592000 秒就是 30 天。immutable 告诉浏览器在有效期内无需再次确认文件是否更新,因此只适用于文件名包含版本哈希的文件(例如 app.3f9a1c.js,前端构建工具通常会自动生成)。
检查语法并重新加载
修改配置后,先用 nginx -t 检查语法,通过后再用 nginx -s reload 重新加载,无需重启容器,正在进行的连接也不会中断:
$ docker exec site nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ docker exec site nginx -s reload
2026/10/13 00:40:34 [notice] 27#27: signal process started
$ docker logs site 2>&1 | tail -n 4
2026/10/13 00:40:34 [notice] 1#1: reconfiguring
2026/10/13 00:40:34 [notice] 1#1: using the "epoll" event method
2026/10/13 00:40:34 [notice] 1#1: start worker processes
2026/10/13 00:40:34 [notice] 1#1: start worker process 32
日志中的 reconfiguring、start worker process 表示 Nginx 已使用新配置启动了新的 worker。
语法有误时,nginx -t 会指出错误所在。例如,删除 listen 80; 的分号:
$ sed 's/listen 80;/listen 80/' default.conf > broken.conf
$ docker run --rm -v "$(pwd)/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/10/13 00:40:35 [emerg] 1#1: invalid parameter "server_name" in /etc/nginx/conf.d/default.conf:3
nginx: [emerg] invalid parameter "server_name" in /etc/nginx/conf.d/default.conf:3
nginx: configuration file /etc/nginx/nginx.conf test failed
Windows 对应的写法(点击标签页切换):
docker run --rm -v "$(pwd)/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
docker run --rm -v "${PWD}/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
docker run --rm -v "%cd%/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
错误信息指向第 3 行的 server_name,但真正的问题是第 2 行缺少分号:Nginx 把下一行的 server_name 也当作 listen 的参数。遇到难以理解的错误时,记得检查前一行。
前面几行以 /docker-entrypoint.sh: 开头的信息来自官方镜像的启动脚本;can not modify ... (read-only file system?) 是因为我们使用 :ro 只读挂载配置文件,脚本无法自动添加 IPv6 配置,可以忽略。
只用
docker run ... nginx -t就能检查配置文件,无需真正启动网站。在 CI 中可以用这个方法先检查配置再部署。
使用环境变量生成配置
官方镜像支持模板:将文件放在 /etc/nginx/templates/ 下,扩展名为 .conf.template,启动时会用环境变量替换其中的 ${变量},并输出到 /etc/nginx/conf.d/。
templates/default.conf.template:
server {
listen 80;
server_name ${SERVER_NAME};
root /usr/share/nginx/html;
location / {
add_header X-Environment ${APP_ENV};
try_files $uri $uri/ /index.html;
}
}
$ docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "$(pwd)/templates:/etc/nginx/templates:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
77b6991d1d85b713cc741c86504d61e6a8dc867997e80219ec77b3f3c3be3165
$ docker logs tpl 2>&1 | grep -i template
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
20-envsubst-on-templates.sh: Running envsubst on /etc/nginx/templates/default.conf.template to /etc/nginx/conf.d/default.conf
$ docker exec tpl cat /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html;
location / {
add_header X-Environment staging;
try_files $uri $uri/ /index.html;
}
}
$ curl -sI http://localhost:8080/ | grep X-Environment
X-Environment: staging
Windows 对应的写法(点击标签页切换):
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "$(pwd)/templates:/etc/nginx/templates:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "${PWD}/templates:/etc/nginx/templates:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "%cd%/templates:/etc/nginx/templates:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
${SERVER_NAME}、${APP_ENV} 被替换成了环境变量的值,但 $uri 这类 Nginx 自身的变量没有被改动:启动脚本只替换已定义的环境变量。同一个镜像就能通过不同的环境变量部署到测试和生产环境。
将内容打包为镜像
挂载适合开发;部署时,将配置和网站文件一起打包成镜像。Dockerfile:
FROM nginx:1.30-alpine
COPY default.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
$ docker build -t my-site .
[+] Building 2.3s (8/8) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 145B 0.0s
=> [internal] load metadata for docker.io/library/nginx:1.30-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/3] FROM docker.io/library/nginx:1.30-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94 1.9s
=> => resolve docker.io/library/nginx:1.30-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94 0.1s
=> => extracting sha256:e3320d02d5786716dcc40d9f6267baec5d3d2c46945082958050c3ac5ea23751 0.3s
=> => extracting sha256:3d85d110b167fae23d8eb3a7060cd079682b682b04d295fc7bbdf454c9e722c8 0.0s
=> => extracting sha256:22e5a8a110ec69c6af30325ccfe8bb0bfb94e5783117808061bae5115e1e3dcb 0.0s
=> => extracting sha256:6b4dfb2e8f8a72d7ae1380b667f50faccdc19147869b2afa09aeddfdea59ce64 0.0s
=> => extracting sha256:703c5424632fab32aa3327bebe6e5202ac53a93740062efb830f8188e580b9db 0.0s
=> => extracting sha256:b335b7ac3a400afa780d0d89e0b778ee19dc3e3df95bed4b274a763af2264fd7 0.0s
=> => extracting sha256:b27cf3f7c39dd0da0aa52a285f0c0cce938ac61472babfea353b1daa83586c49 1.4s
=> [internal] load build context 0.1s
=> => transferring context: 1.51kB 0.0s
=> [2/3] COPY default.conf /etc/nginx/conf.d/default.conf 0.1s
=> [3/3] COPY public/ /usr/share/nginx/html/ 0.0s
=> exporting to image 0.1s
=> => exporting layers 0.0s
=> => exporting manifest sha256:8bac0b1cbdfd6612d9eb656089bb18a18c4dba64cb8557aec5d427164a205865 0.0s
=> => exporting config sha256:5b6710aa0888ae70f084ad7911539cb362f4f919de995cf997898d37a9968baf 0.0s
=> => exporting attestation manifest sha256:9be9e0e07eacaece4ad72b53c87a2a7f84b4fa99af726bf98a33229fbbf9e4eb 0.0s
=> => exporting manifest list sha256:af4d78aa254eab0a55dd440f1e220ed73d0a5141343d9ae0fe19890e8471cd04 0.0s
=> => naming to docker.io/library/my-site:latest 0.0s
=> => unpacking to docker.io/library/my-site:latest 0.0s
$ docker run -d --name site -p 8080:80 my-site
d614148de5a6c2243074f865a0c03266a8e81ba069733546412d4abe9ad64fa0
$ curl -s http://localhost:8080/ | grep "<h1>"
<h1>My Static Site</h1>
$ docker images my-site
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
my-site:latest af4d78aa254e 92.8MB 26.1MB U
镜像只比 nginx:1.30-alpine(93.7 MB)大一点点。对于需要先构建的前端项目(React、Vue 等),请参阅多阶段构建:第一阶段使用 Node.js 运行 npm run build,第二阶段将输出文件夹复制到 Nginx 镜像中。
反向代理:一个入口,多个后端
常见架构是只对外开放 Nginx 的端口,后端的应用容器都不开放:
用户 ──▶ nginx:80 ──┬──▶ app:3000 (/ → Express)
└──▶ api:8000 (/api/ → FastAPI)
这里直接使用前两节准备好的项目作为后端:Node.js 应用 的 Express,以及 Python 应用 的 FastAPI。
proxy-demo/
├── compose.yaml
├── nginx.conf
├── app/ ← Node.js 那一节的 Express 项目
└── api/ ← Python 那一节的 FastAPI 项目
nginx.conf:
server {
listen 80;
location / {
proxy_pass http://app: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/ {
proxy_pass http://api: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;
}
client_max_body_size 20m;
}
| 设置 | 作用 |
|---|---|
proxy_pass http://app:3000 | 转发给 Compose 服务 app 的 3000 端口(容器内的端口) |
Host | 保留用户请求的域名,否则后端看到的是 app:3000 |
X-Real-IP、X-Forwarded-For | 传递用户的真实 IP,否则后端看到的都是 Nginx 容器的 IP |
X-Forwarded-Proto | 告诉后端原始请求是 http 还是 https |
client_max_body_size 20m | 允许上传的大小,默认只有 1 MB |
compose.yaml:
services:
nginx:
image: nginx:1.30-alpine
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
- api
restart: unless-stopped
app:
build: ./app
restart: unless-stopped
api:
build: ./api
restart: unless-stopped
只有 nginx 有 ports。启动:
$ docker compose up -d --build
[+] Building 2.6s (23/23) FINISHED
=> [internal] load local bake definitions 0.0s
=> => reading from stdin 885B 0.0s
=> [app internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 263B 0.0s
=> [api internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 290B 0.0s
=> [api internal] load metadata for docker.io/library/python:3.14-slim 0.0s
=> [app internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [app internal] load .dockerignore 0.0s
=> => transferring context: 82B 0.0s
=> [api internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [app 1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.1s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.1s
=> [app internal] load build context 0.1s
=> => transferring context: 30.70kB 0.0s
=> [api 1/5] FROM docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 0.1s
=> => resolve docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 0.1s
=> [api internal] load build context 0.1s
=> => transferring context: 655B 0.0s
=> CACHED [app 2/5] WORKDIR /app 0.0s
=> CACHED [app 3/5] COPY package.json package-lock.json ./ 0.0s
=> CACHED [app 4/5] RUN npm ci --omit=dev && npm cache clean --force 0.0s
=> [app 5/5] COPY --chown=node:node . . 0.0s
=> CACHED [api 2/5] WORKDIR /app 0.0s
=> CACHED [api 3/5] COPY requirements.txt . 0.0s
=> CACHED [api 4/5] RUN pip install -r requirements.txt 0.0s
=> CACHED [api 5/5] COPY . . 0.0s
=> [app] exporting to image 0.6s
=> => exporting layers 0.1s
=> => exporting manifest sha256:30fc2b30e552f64db78c5b43bafd2ada5445c202a13517f1413cfbbf94b39fd3 0.0s
=> => exporting config sha256:ec00425160f63ab1fc89cf3ffb5cd5c91034dfe42b069922b6db604c6512dc94 0.0s
=> => exporting attestation manifest sha256:bcd908d6b45f083d6373facdb8103d3cad7f5c8855744d68614f3c983fe2a3be 0.0s
=> => exporting manifest list sha256:ec1d3f8403286973e156b78f0db4566b8815c8e885f10c176b64c2c6cb2a75c5 0.0s
=> => naming to docker.io/library/proxy-demo-app:latest 0.0s
=> => unpacking to docker.io/library/proxy-demo-app:latest 0.4s
=> [api] exporting to image 2.1s
=> => exporting layers 0.0s
=> => exporting manifest sha256:89889ca77d8a1fe2e05952bd565d6f4b777e87d2343df4d846ad16bec3752caa 0.0s
=> => exporting config sha256:6bc4e896d1388da575d77436bebb27a4b9941ed84896155384aef74eb4c6bf7c 0.0s
=> => exporting attestation manifest sha256:c4130d12dd13346aa1cc105159cb8ff35177f368269cca237584c9e648fd2da7 0.0s
=> => exporting manifest list sha256:104bdf1ed74105f0bd21955d695d87d5225e50e4df7ab5715ac86eb551aac340 0.0s
=> => naming to docker.io/library/proxy-demo-api:latest 0.0s
=> => unpacking to docker.io/library/proxy-demo-api:latest 2.1s
=> [app] resolving provenance for metadata file 0.0s
=> [api] resolving provenance for metadata file 0.0s
[+] up 6/6
✔ Image proxy-demo-api Built 2.7s
✔ Image proxy-demo-app Built 2.7s
✔ Network proxy-demo_default Created 0.0s
✔ Container proxy-demo-app-1 Started 0.4s
✔ Container proxy-demo-api-1 Started 0.7s
✔ Container proxy-demo-nginx-1 Started 1.2s
测试各个路径:
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
proxy-demo-api-1 proxy-demo-api "uvicorn main:app --…" api 7 seconds ago Up 6 seconds 8000/tcp
proxy-demo-app-1 proxy-demo-app "docker-entrypoint.s…" app 7 seconds ago Up 6 seconds 3000/tcp
proxy-demo-nginx-1 nginx:1.30-alpine "/docker-entrypoint.…" nginx 7 seconds ago Up 5 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
$ curl -s http://localhost:8080/
{"message":"Hello from Express","node":"v24.21.0"}
$ curl -s http://localhost:8080/api/
{"message":"Hello from FastAPI"}
$ curl -s "http://localhost:8080/api/items/7?q=proxy"
{"item_id":7,"q":"proxy"}
$ curl -s http://localhost:8080/api/docs | grep -o "<title>.*</title>"
<title>Todo API - Swagger UI</title>
ps中app和api的 PORTS 只有3000/tcp、8000/tcp,没有映射到主机,外部只能通过 Nginx 访问。/转发给 Express;/api/、/api/items/7转发给 FastAPI。- FastAPI 的
/docs也可以通过/api/docs访问。(实际使用时,页面中引用的/openapi.json路径需要通过 FastAPI 的root_path配置进行调整。)
实测:proxy_pass 末尾的斜杠
proxy_pass http://api:8000/; 末尾有 /,Nginx 会把 location 匹配到的 /api/ 替换为 /,因此 /api/items/7 转发给后端时会变成 /items/7。如果末尾没有 /,则会原样转发为 /api/items/7,后端没有这个路径就会返回 404。这是最常见的配置错误之一,来实际试试看。
删除末尾的 /,然后重新加载。这里还会遇到另一个坑:
$ sed -i 's#http://api:8000/;#http://api:8000;#' nginx.conf
$ grep "api:8000" nginx.conf
proxy_pass http://api:8000;
$ docker compose exec nginx grep "api:8000" /etc/nginx/conf.d/default.conf
proxy_pass http://api:8000/;
$ docker compose restart nginx
Container proxy-demo-nginx-1 Restarting
Container proxy-demo-nginx-1 Started
$ docker compose exec nginx grep "api:8000" /etc/nginx/conf.d/default.conf
proxy_pass http://api:8000;
$ curl -s "http://localhost:8080/api/items/7?q=proxy"
{"detail":"Not Found"}
$ docker compose logs --tail 1 api
api-1 | INFO: 172.19.0.4:44428 - "GET /api/items/7?q=proxy HTTP/1.1" 404 Not Found
依次说明:
- 使用
sed -i修改主机上的nginx.conf,并用grep确认修改成功。 - 但容器内的
/etc/nginx/conf.d/default.conf还是旧的。原因是sed -i(以及 vim 等许多编辑器)保存时会创建一个新文件来替换旧文件,而单个文件挂载绑定的是原来的文件,因此容器看不到新文件。这时即使执行nginx -s reload也没用。 - 执行
docker compose restart nginx重启容器,重新挂载后才能看到新内容。 - 去掉末尾的斜杠后,
/api/items/7?q=proxy返回{"detail":"Not Found"}。 - FastAPI 日志证实它收到的是
GET /api/items/7,而不是/items/7。
避免单个文件挂载的问题:改为挂载整个文件夹,例如
./nginx:/etc/nginx/conf.d:ro,这样文件夹中的文件被替换时容器也能看到;或者修改配置后都重启容器。
502 Bad Gateway
502 表示 Nginx 收到了请求,但无法连接到后端。我们实测几种常见情况。
后端端口写错(将 app:3000 改为 app:3001 后重启 Nginx):
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/
502 0.001010s
$ docker compose logs nginx 2>&1 | grep "\[error\]" | tail -n 1
nginx-1 | 2026/10/13 00:43:32 [error] 21#21: *1 connect() failed (111: Connection refused) while connecting to upstream, client: 172.19.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.19.0.2:3001/", host: "localhost:8080"
请求立即返回 502,错误日志为 Connection refused:找到了名为 app 的主机,但 3001 端口没有程序在监听。后端程序只绑定到 127.0.0.1 或仍在启动时,也会出现相同的错误。

后端容器已停止:
$ docker compose stop app
Container proxy-demo-app-1 Stopping
Container proxy-demo-app-1 Stopped
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/
502 3.062307s
$ docker compose logs nginx 2>&1 | grep "\[error\]" | tail -n 1
nginx-1 | 2026/10/13 00:43:39 [error] 20#20: *1 connect() failed (113: Host is unreachable) while connecting to upstream, client: 172.19.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.19.0.2:3000/", host: "localhost:8080"
约 3 秒后返回 502,错误为 Host is unreachable。Nginx 启动时已经将 app 解析为 IP 并记住了这个地址,容器停止后该 IP 没有响应。如果网络没有立即报告无法连接,Nginx 会一直等待直到超时(默认 60 秒),此时返回的就是 504 Gateway Timeout。
后端服务根本不存在(只启动 Nginx,不启动 app 和 api):
$ docker compose up -d --no-deps nginx
Container proxy-demo-nginx-1 Started
$ docker compose ps -a nginx
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
proxy-demo-nginx-1 nginx:1.30-alpine "/docker-entrypoint.…" nginx 3 seconds ago Restarting (1) Less than a second ago
$ docker compose logs nginx 2>&1 | grep emerg | head -n 2
nginx-1 | 2026/10/13 00:43:48 [emerg] 1#1: host not found in upstream "app" in /etc/nginx/conf.d/default.conf:5
nginx-1 | nginx: [emerg] host not found in upstream "app" in /etc/nginx/conf.d/default.conf:5
这次 Nginx 连启动都失败了:host not found in upstream "app"。Nginx 启动时就会解析 proxy_pass 中的主机名,无法解析时便拒绝启动;由于设置了 restart: unless-stopped,容器会不断重启(Restarting (1))。
排查 502 的顺序:
- 检查后端容器是否正在运行:
docker compose ps - 检查
proxy_pass中的服务名称和端口是否正确(使用的是容器内的端口,而不是ports映射到的主机端口) - 检查后端是否监听
0.0.0.0,而不是127.0.0.1 - 检查 Nginx 和后端是否在同一个网络,参考网络与容器互连
- 查看 Nginx 错误日志:
docker compose logs nginx | grep error
HTTPS:使用 Caddy 自动获取证书
在 Nginx 上使用 Let's Encrypt 证书,需要另外配置 certbot、定期更新并重新加载。如果只想快速启用 HTTPS,Caddy 会自动申请、安装和更新证书,配置也精简得多。
Caddyfile:
example.com {
reverse_proxy app:3000
}
compose.yaml:
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
app:
build: ./app
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
使用真实域名的条件:
- 域名的 DNS 已指向这台主机。
- 可以从互联网连接 80 和 443 端口(Let's Encrypt 需要通过这两个端口验证)。
caddy_data用于保存证书,不要删除,否则每次重建都要重新申请,可能触发 Let's Encrypt 的频率限制。
在本地试用
本地没有真实域名时,可以把 Caddyfile 中的域名写成 localhost。Caddy 会自行创建本地证书颁发机构并签发证书:
localhost {
reverse_proxy app:3000
}
$ docker compose up -d
[+] up 5/5
✔ Network caddy-demo_default Created 0.0s
✔ Volume caddy-demo_caddy_config Created 0.0s
✔ Volume caddy-demo_caddy_data Created 0.0s
✔ Container caddy-demo-caddy-1 Started 0.3s
✔ Container caddy-demo-app-1 Started 0.6s
$ curl -sk https://localhost/
{"message":"Hello from Express","node":"v24.21.0"}
$ curl -sI http://localhost/ | head -n 3
HTTP/1.1 308 Permanent Redirect
Connection: close
Location: https://localhost/
$ docker compose logs caddy 2>&1 | grep -oE '"msg":"(enabling automatic[^"]*|obtaining certificate|certificate obtained successfully)"'
"msg":"enabling automatic HTTP->HTTPS redirects"
"msg":"enabling automatic TLS certificate management"
"msg":"obtaining certificate"
"msg":"certificate obtained successfully"
$ curl -svk https://localhost/ 2>&1 | grep -E "issuer|subject:"
* subject:
* issuer: CN=Caddy Local Authority - ECC Intermediate
https://localhost/成功转发到 Express。由于证书不是由公开证书颁发机构签发的,curl需要加上-k跳过验证,浏览器也会显示警告。- 对
http://的请求会自动收到308 Permanent Redirect并转到https://,无需另外配置。 - 日志显示 Caddy 自动启用了 HTTP→HTTPS 重定向和证书管理,并成功获取了证书。
- 证书的颁发者是
Caddy Local Authority。换成真实域名时,就会改为向 Let's Encrypt 申请。
Caddy 的 reverse_proxy 默认会传递 X-Forwarded-For、X-Forwarded-Proto 等标头,无需像 Nginx 一样逐一配置。
重点回顾
- 官方镜像会加载
/etc/nginx/conf.d/*.conf,也支持对templates/*.conf.template进行环境变量替换。 - SPA 使用
try_files $uri $uri/ /index.html;不要同时使用expires和add_header Cache-Control。 proxy_pass末尾有/时会去掉 location 前缀。- 只向外开放反向代理的端口。
动手练习
练习 1:将反向代理示例中 FastAPI 的路径从 /api/ 改为 /backend/,需要修改哪里?
答案
只需将 nginx.conf 中的 location /api/ 改为 location /backend/,proxy_pass http://api:8000/; 保持以 / 结尾,然后运行 docker compose exec nginx nginx -s reload(单文件挂载时改用 docker compose restart nginx)。
练习 2:怎样只用一条命令检查 nginx 配置文件是否有语法错误,而不启动网站?
答案
docker run --rm -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
常见问题
修改 nginx.conf 后需要重启容器吗?
先运行 docker exec 容器 nginx -t 检查,再执行 nginx -s reload 即可,无需重启。不过,挂载单个文件时,编辑器保存文件可能导致容器看不到新文件,实测见本章。
502 Bad Gateway 是什么意思?
Nginx 无法连接到后端。检查后端容器是否正在运行、proxy_pass 中的服务名称和容器内端口,以及后端是否监听 0.0.0.0。
应该选择 Nginx 还是 Caddy?
需要精细控制,且团队熟悉 Nginx,就用 Nginx;如果只想快速启用 HTTPS,Caddy 的配置简短得多,还会自动更新证书。
更新于 2026年10月14日