目录

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:

由 Nginx 容器提供的静态网站
Nginx 提供的静态网页,CSS 和 JavaScript 均已正常加载

最后一条命令请求不存在的路径 /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,前端路由就能据此显示对应的页面:

SPA 路径由 index.html 处理
不存在的路径也会返回 index.html,前端读取到的路径是 /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

依次说明:

  1. 使用 sed -i 修改主机上的 nginx.conf,并用 grep 确认修改成功。
  2. 但容器内的 /etc/nginx/conf.d/default.conf 还是旧的。原因是 sed -i(以及 vim 等许多编辑器)保存时会创建一个新文件来替换旧文件,而单个文件挂载绑定的是原来的文件,因此容器看不到新文件。这时即使执行 nginx -s reload 也没用。
  3. 执行 docker compose restart nginx 重启容器,重新挂载后才能看到新内容。
  4. 去掉末尾的斜杠后,/api/items/7?q=proxy 返回 {"detail":"Not Found"}。
  5. 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 或仍在启动时,也会出现相同的错误。

Nginx 502 Bad Gateway 错误页面
浏览器中显示的 502 Bad Gateway 错误页面

后端容器已停止:

$ 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 的顺序:

  1. 检查后端容器是否正在运行:docker compose ps
  2. 检查 proxy_pass 中的服务名称和端口是否正确(使用的是容器内的端口,而不是 ports 映射到的主机端口)
  3. 检查后端是否监听 0.0.0.0,而不是 127.0.0.1
  4. 检查 Nginx 和后端是否在同一个网络,参考网络与容器互连
  5. 查看 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日