#nginx #frp #HTTPS #反向代理 #Docker #服务器运维

背景:N100 上跑了若干 Docker 服务,一直通过 VPS:裸端口 访问。本文记录将它们改造为子域名 HTTPS 访问的完整过程,以及途中遇到的两个坑(certbot 依赖冲突、chicken-and-egg 证书问题)。


一、现状与目标

改造前:每个服务对外暴露一个裸端口,访问方式不统一,无法用标准 443 HTTPS。

服务 原访问方式
MT Photos https://x.x.x.x:18063
WebDAV https://x.x.x.x:4455
qBittorrent https://x.x.x.x:19090

改造后:每个服务对应一个子域名,统一走 443 HTTPS。

服务 新访问方式
MT Photos https://mtphotos.your.domain.example
WebDAV https://dav.your.domain.example
qBittorrent https://qbt.your.domain.example

二、架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户浏览器
│ HTTPS 443

Cloudflare(DNS 代理)


VPS nginx(x.x.x.x)
├── mtphotos.your.domain.example127.0.0.1:18064
├── dav.your.domain.example127.0.0.1:4456
└── qbt.your.domain.example127.0.0.1:19091

│(frp 隧道,各服务独立端口)

N100(192.168.x.14
├── mt-photos 容器 :8063
├── webdav 容器 :4455
└── qbittorrent 容器 :9090

关键前提:frp 隧道已存在,无需新增。每条隧道独立监听一个 VPS 端口,VPS nginx 直接 proxy_pass 过去,不需要 Host header 路由。


三、确认各服务端口

1
2
3
4
5
# 查容器端口绑定
docker ps --format 'table {{.Names}}\t{{.Ports}}'

# 查 frpc 配置确认隧道端口
cat /home/user/frp/frpc.ini

整理结果:

服务 N100 本地端口 frp 远程端口(VPS 侧)
mt-photos 8063 18064
webdav 4455 4456
qbittorrent WebUI 9090 19091

其中 openclaw 已绑定 127.0.0.1:18789(仅本机),已有独立子域名配置,无需处理。


四、VPS nginx 配置

新建 /etc/nginx/sites-available/home-services.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# HTTP → HTTPS
server {
listen 80;
server_name mtphotos.your.domain.example
dav.your.domain.example
qbt.your.domain.example;
return 301 https://$host$request_uri;
}

# MT Photos
server {
listen 443 ssl;
server_name mtphotos.your.domain.example;

ssl_certificate /etc/letsencrypt/live/mtphotos.your.domain.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mtphotos.your.domain.example/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;

client_max_body_size 1000m;

location / {
proxy_pass http://127.0.0.1:18064;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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 https;
proxy_read_timeout 300s;
}
}

# WebDAV
# 保留原有路径结构:/milin/ 和 /joplin/(Joplin 客户端依赖)
server {
listen 443 ssl;
server_name dav.your.domain.example;

ssl_certificate /etc/letsencrypt/live/dav.your.domain.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dav.your.domain.example/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 0;

location / {
proxy_pass http://127.0.0.1:4456/milin/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Depth $http_depth;
proxy_set_header Destination $http_destination;
proxy_set_header Overwrite $http_overwrite;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}

location /milin/ {
proxy_pass http://127.0.0.1:4456/milin/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Depth $http_depth;
proxy_set_header Destination $http_destination;
proxy_set_header Overwrite $http_overwrite;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}

location /joplin/ {
proxy_pass http://127.0.0.1:4456/joplin/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Depth $http_depth;
proxy_set_header Destination $http_destination;
proxy_set_header Overwrite $http_overwrite;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}

# qBittorrent
server {
listen 443 ssl;
server_name qbt.your.domain.example;

ssl_certificate /etc/letsencrypt/live/qbt.your.domain.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qbt.your.domain.example/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;

location / {
proxy_pass http://127.0.0.1:19091;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# qBittorrent 校验 Host header,固定为本地地址绕过校验
proxy_set_header Host "127.0.0.1:9090";
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
}
}

启用:

1
sudo ln -sf /etc/nginx/sites-available/home-services.conf /etc/nginx/sites-enabled/

五、申请 SSL 证书(两个坑)

坑一:系统 certbot 依赖冲突

Ubuntu 22.04 apt 源的 certbot 1.21.0 与新版 urllib3 不兼容,运行时报:

1
ImportError: cannot import name 'appengine' from 'urllib3.contrib'

修复:卸载 apt 版,改用 snap 版:

1
2
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbot

坑二:chicken-and-egg 证书问题

nginx 配置里引用了证书路径,但证书还没签发 → nginx -t 失败 → certbot --nginx 插件先跑 nginx -t → 也失败,拒绝继续。

解法:用 --standalone 模式,certbot 自己临时监听 80 端口,绕开 nginx 插件。需要先停 nginx:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 先移除新配置(让 nginx 能正常 reload)
sudo rm /etc/nginx/sites-enabled/home-services.conf
sudo systemctl stop nginx

DOMAIN="your.domain.example"
sudo certbot certonly --standalone -d mtphotos.${DOMAIN} --non-interactive --agree-tos --email admin@${DOMAIN}
sudo certbot certonly --standalone -d dav.${DOMAIN} --non-interactive --agree-tos --email admin@${DOMAIN}
sudo certbot certonly --standalone -d qbt.${DOMAIN} --non-interactive --agree-tos --email admin@${DOMAIN}

# 重新启用并启动
sudo ln -sf /etc/nginx/sites-available/home-services.conf /etc/nginx/sites-enabled/
sudo systemctl start nginx
sudo nginx -t && sudo systemctl reload nginx

nginx 停机约 30 秒。Cloudflare 会透传 /.well-known/acme-challenge/,无需关闭代理。


六、N100 局域网 nginx(可选)

局域网内直接用 IP:端口访问即可,若想用子域名(需配合路由器/Pi-hole DNS),可在 N100 部署 nginx 容器:

/home/user/dockerfile/nginx/docker-compose.yml

1
2
3
4
5
6
7
8
services:
nginx-proxy:
image: nginx:alpine
container_name: nginx-proxy
network_mode: host
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
restart: unless-stopped

nginx.conf 核心:server_name 对应子域名,proxy_pass 指向各容器本地端口(8063、4455、9090)。使用 network_mode: host 直接访问宿主机端口,无需额外网络配置。


七、Cloudflare DNS 记录

所有记录一律灰云(DNS only)。

类型 名称 内容 Proxy
A @ / www VPS IP ❌ 灰云
A mtphotos VPS IP ❌ 灰云
A dav VPS IP ❌ 灰云
A qbt VPS IP ❌ 灰云

为何全部灰云:VPS 在阿里云国内(中国大陆),Cloudflare 免费版橙云对国内用户连接质量极差,经常出现 ERR_CONNECTION_CLOSED(CF 节点在境外,国内访问不稳定)。境外爬虫(ClaudeBot 等)能走通橙云,但真实国内用户浏览器被直接断连。此外服务类子域名还有带宽限制和 CSRF 等问题。灰云下 Let’s Encrypt 证书照常提供 HTTPS,直连 VPS 对国内用户反而更快。


八、验证

1
2
3
4
5
6
7
8
9
10
11
DOMAIN="your.domain.example"
for sub in mtphotos dav qbt; do
echo -n "${sub}: "
curl -s -o /dev/null -w "%{http_code}" \
--resolve "${sub}.${DOMAIN}:443:VPS_IP" \
"https://${sub}.${DOMAIN}/"
echo
done
# mtphotos: 200
# dav: 401 ← WebDAV 正常,401 = 需认证
# qbt: 200

九、后续 TODO

  • 0.0.0.0127.0.0.1(mt-photos:8063、webdav:4455、qbt:9090)


延伸阅读