Nginx Proxy Manager 部署与踩坑

把内网几台机器的 HTTPS 反代统一收到 Nginx Proxy Manager (NPM)。之前手写 nginx + acme.sh 续期用了四年,撑到 5+ 域名开始漏续期,换成 NPM。

部署

  • 主机:Debian 12 + Docker,数据根 /vol1/1000/docker/npm/
  • 镜像:jc21/nginx-proxy-manager:2.15.1
  • 端口:HTTP {http-port}→80 / HTTPS {https-port}→443 / Admin {admin-port}→81
  • 数据库:外部 PostgreSQL(专用账号 npm/库 npm)
  • 证书:DNSPod DNS-01 签 wildcard

端口偏移 {offset} 是有意的,80/443 留给将来可能要直连的服务,混部的时候一眼分得清。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
services:
app:
image: jc21/nginx-proxy-manager:2.15.1
restart: always
ports:
- '{http-port}:80'
- '{https-port}:443'
- '{admin-port}:81'
environment:
TZ: 'Asia/Shanghai'
DB_POSTGRES_HOST: '{postgres-host}'
DB_POSTGRES_PORT: '5432'
DB_POSTGRES_USER: 'npm'
DB_POSTGRES_PASSWORD: '{password}'
DB_POSTGRES_NAME: 'npm'
# 不开这个 backend 启动会卡死(见坑 2)
IP_RANGES_FETCH_ENABLED: 'false'
volumes:
- /vol1/1000/docker/npm/data:/data
- /vol1/1000/docker/npm/letsencrypt:/etc/letsencrypt

几点说明:

  1. 外部 postgres 比容器自带的 SQLite/MariaDB 好用。密码走密码管理工具,备份走统一 postgres 流程,跨机器迁移不用倒 sqlite 文件。
  2. 镜像必须 2.15.1+,旧版配 DB_POSTGRES_* 会回退到 SQLite。这是 #4389,2.15.1 修的。
  3. 持久化目录 owner 用 1000:1001(app uid),否则容器起来写不进数据。

初始化

建库建用户(一次性):

1
2
3
CREATE ROLE npm LOGIN PASSWORD '{generated}';
CREATE DATABASE npm OWNER npm;
GRANT ALL PRIVILEGES ON DATABASE npm TO npm;

不要图省事直接用 postgres admin 账号连库,专用账号权限有限,被拖库损失小,审计也清晰。

首次登录 Admin Web:http://{host}:{admin-port},默认账号 admin@example.com / changeme,登录后马上改密码和邮箱。

踩坑

下面这几个坑是这次实际部署花时间最多的,坑 1 和坑 2 最容易被新手踩。

坑 1:跨 docker 网络反代超时

最早在 proxy host 里填后端地址 {host-ip}:{backend-port},报 502。换容器 IP 也 502,换 127.0.0.1:{backend-port} 也不行。

三个地址都不通,原因不一样:

  • 宿主机 IP:docker-proxy NAT 在宿主机本机会回环,容器访问走不通
  • 后端容器 IP:默认情况下不同 docker bridge 网络间容器 IP 不可达
  • 127.0.0.1:是 NPM 容器自己,不是被反代服务

后来换成 docker 主机网关 172.28.0.1(被反代服务所在 bridge 网络的 gateway IP),通了。

排查可以用这个命令起临时容器共享目标网络:

1
2
docker run --rm --network {后端bridge} alpine:3.20 \
sh -c "apk add curl && curl -v http://172.28.0.1:{backend-port}/"

如果这个通,NPM 用同样的 172.28.0.1:{backend-port} 就稳。

坑 2:/api/ 502 + IP Ranges 卡死

WebUI 起来后所有 API 返回 502,日志最后一行停在 Fetching https://ip-ranges.amazonaws.com/ip-ranges.json,backend 进程根本没 listen 3000。

翻了下源码和 issue,NPM 启动时从 AWS 拉 IP ranges 段(用于访问控制),https.get 没设 timeout,出网挂起后 Promise 永不 resolve,app.listen(3000) 永不执行。前端 nginx 起来了,后面的 node 没起来。

compose 里加 IP_RANGES_FETCH_ENABLED: 'false'(NPM 官方支持这个开关)就好。

判断启动完成:

1
2
docker logs {npm容器名} 2>&1 | grep "Backend PID"
# 看到 "Backend PID xxx listening on port 3000" 才算真起来

坑 3:NPM 容器访问宿主物理 IP

proxy host 配 {host-ip}:端口 也是超时。

和坑 1 是同一个 docker 网络问题,但容易误以为是网络通的问题。ping {host-ip} 通,但 TCP 业务层不通。

正解还是 172.28.0.1

坑 4:DB_POSTGRES_* 配了却用 SQLite(Issue #4389)

明明在 compose 填了 PG 变量,日志看却是 SQLite。

NPM 旧版 bug,识别不到 DB_POSTGRES_* 时 fallback 到 SQLite,升到 2.15.1+ 就好。

坑 5:Let’s Encrypt HTTP-01 失败

UI 里点 “Issue Certificate” 选 HTTP-01,签名失败。

NPM 容器对外暴露的 80 端口({http-port})只有走到公网时才被 Let’s Encrypt 流量找到。如果机器不在公网、或者 DNSPod 配的记录还没生效,HTTP-01 一定失败。

wildcard 证书走 DNS-01。腾讯云 DNSPod 配 dns_tencentcloud_secret_id / secret_key,签 *.{yourdomain}.com,一次覆盖所有子域,不需要 80 端口。

坑 6:端口冲突

docker compose upbind: address already in use。见上面的端口偏移约定,{http-port}/{https-port}/{admin-port} 这套基本不和别的服务撞。

502 排障速查

排 502 一般几步:

  1. curl http://{host}:{admin-port}/api/ 看返回啥。502 是 backend 没起来(nginx 起来但 node 没 listen 3000),200 是 backend 正常,问题在前端反代配置
  2. 看日志最后一行。停在 Fetching https://ip-ranges.amazonaws.com/ip-ranges.json 是坑 2,加 IP_RANGES_FETCH_ENABLED=false。停在 Error: connect ECONNREFUSED 是坑 1,检查后端地址是不是 172.28.0.1:端口
  3. 正常标志:Backend PID xxx listening on port 3000 + /api/ 返回 {"status":"OK","setup":true,...}

DNS-01 证书配置(DNSPod 实践)

UI 里建证书的步骤:

  1. Add SSL CertificateLet’s Encrypt
  2. Domain Names: *.{yourdomain}.com(泛域)
  3. DNS Challenge: 勾选
  4. DNS Provider: 选 tencentcloud
  5. Credentials
    1
    2
    dns_tencentcloud_secret_id = {SecretId}
    dns_tencentcloud_secret_key = {SecretKey}
  6. Agree to Let’s Encrypt ToS + Email(用于过期提醒)

签发成功后自动带 SAN 覆盖所有子域。续期 90 天,NPM 自动跑。

反代配置(API 方式)

UI 点点点也能配,但 API 更适合批量或自动化。

创建 proxy host:

1
2
3
4
5
6
7
8
9
10
11
POST /api/nginx/proxy-hosts
{
"domain_names": ["portainer.{yourdomain}.com"],
"forward_scheme": "http",
"forward_host": "{host-ip}",
"forward_port": {service-port},
"ssl_forced": true,
"certificate_id": 2,
"allow_websocket_upgrade": true,
"http2_support": true
}

证书 ID 从 GET /api/nginx/certificates 拿。

注意:API 创建证书有时会报 socket.timeout,但点 UI 一定能成功。签证书走 UI,配 proxy host 走 API。

验收

1
2
3
4
5
6
7
8
9
10
# 1. 后端服务起来
curl -sk https://{host}:{admin-port}/api/ | jq

# 2. 证书签发成功
openssl s_client -connect portainer.{yourdomain}.com:{https-port} \
-servername portainer.{yourdomain}.com < /dev/null 2>/dev/null \
| openssl x509 -noout -text | grep -A1 "Subject Alternative Name"

# 3. 反代业务能跑
curl -sk https://portainer.{yourdomain}.com:{https-port}/ | head -5

三条都通过才算真上线。

收尾

坑 1 和坑 2 是这次最大的坎。前一个是 docker 网络模型没想清楚,跨 bridge 网络时容器之间靠宿主机网关走;后一个是 NPM 启动顺序里 AWS 那次 fetch 没设超时,扯出一串 promise 链全挂死。没踩过这两个的人第一次配估计得一晚上。

接的是密码管理、DNSPod、外部 postgres。从部署到今天差不多半年,没出过问题。