用 Tailscale 和双层 Nginx 把本地 Boker 发布到公网
云服务器负责域名与 HTTPS,通过 Tailscale 直连本机;两层 Nginx 正确传递主机和协议头,让 Boker 同时支持公网域名与 Tailnet 地址访问。
Boker 不一定要连数据库和上传目录一起搬到云上。这次发布里,云服务器只负责域名、HTTPS 和公网流量入口;Boker、PostgreSQL 与上传文件仍然由本机承载。两端通过 Tailscale 直连,不必把家庭宽带的入站端口暴露到公网。
这篇记录最终链路、两层 Nginx 如何传递主机与协议,以及一次证书路径看似合理却导致 Nginx 无法启动的故障。其中 Tailnet 地址都用语义化占位符表示,复用配置时应替换为自己的地址。
最终链路:云端管入口,本机管应用
公网浏览器
-> 云服务器 Nginx :443
-> Tailscale 加密链路
-> 本机 Nginx :80
-> Boker 127.0.0.1:3000
云端 Nginx 终止 TLS,把请求发到本机的 Tailscale 地址。本机 Nginx 是内部边界:它只允许云服务器、本机 Tailnet 地址与 loopback 进入,然后转发给只监听 127.0.0.1:3000 的 Boker。
这种拆分保留了本机数据库和上传文件的现有运行方式,也让云服务器只需维护 DNS、证书和反向代理。代价是两端都要正确配置,而且本机与 Tailnet 必须保持在线。
云端 Nginx:统一入口并覆盖代理头
云端同时接收根域名与 www 域名。所有 HTTP 请求、以及根域名的 HTTPS 请求,都跳转到 https://www.jinzhao.net.cn;只有 www 的 HTTPS 入口代理到本机。
server {
listen 80;
listen [::]:80;
server_name jinzhao.net.cn www.jinzhao.net.cn;
return 301 https://www.jinzhao.net.cn$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name jinzhao.net.cn;
ssl_certificate <证书地址路径>/*.pem;
ssl_certificate_key <证书地址路径>/*.pem;
return 301 https://www.jinzhao.net.cn$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.jinzhao.net.cn;
ssl_certificate <证书地址路径>/*.pem;
ssl_certificate_key <证书地址路径>/*.pem;
client_max_body_size 11m;
location / {
proxy_pass http://<本机 Tailscale IP>:80;
proxy_http_version 1.1;
proxy_set_header Host www.jinzhao.net.cn;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
}
proxy_set_header 在公网边界上是覆盖写入,不盲信访客自带的同名请求头。Host 固定为规范域名,X-Real-IP 记录公网客户端,X-Forwarded-For 也用 $remote_addr 覆盖来访头,只向下一跳传递 Nginx 实际看到的客户端地址;X-Forwarded-Proto 则明确表示浏览器使用 HTTPS。11m 比应用允许的 10 MiB 图片略大,给 multipart 边界和其他表单字段留出余量。
本机 Nginx:限制来源并保留外部协议
第二层代理不能用自己看到的 $scheme 无条件覆盖协议。云端到本机这一段是 HTTP,但浏览器到云端是 HTTPS;如果覆盖成 http,Boker 会还原出错误的外部来源。
map $http_x_forwarded_proto $boker_forwarded_proto {
default $scheme;
http http;
https https;
}
server {
listen 80;
listen [::]:80;
server_name _;
allow <云服务器 Tailscale IP>;
allow <本机 Tailscale IP>;
allow 127.0.0.1;
allow ::1;
deny all;
client_max_body_size 11m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $boker_forwarded_proto;
}
}
map 只接受明确的 http 或 https,其他值和缺失值都回退到当前连接的 $scheme。因为入口已用 allow/deny 限制为可信的 Tailnet 节点和 loopback,云端写入的 X-Real-IP 与 X-Forwarded-For 会由 Nginx 继续传给上游,不再被本机地址替换。
Boker 环境:规范地址与本机管理入口
Boker 的规范站点地址是公网 HTTPS 域名,用于 canonical、sitemap 等对外链接。站长仍可在 Tailnet 内通过本机 HTTP 地址管理站点,因此把该来源单独列入写入白名单:
SITE_URL=https://www.jinzhao.net.cn
ADMIN_HTTP_ORIGINS=http://<本机 Tailscale IP>
SITE_URL 不是“所有后台请求只能来自这里”的开关。后台保存、登录与图片上传还会将浏览器的 Origin 与代理还原出的 Host 和 X-Forwarded-Proto 比较。这正是两层代理必须保留规范主机、并把外部 HTTPS 传到应用层的原因。
故障:证书域名不等于证书目录名
第一版配置把证书路径写成了 <证书地址路径>/*.pem。这个名字看起来与公网主机匹配,但它并不存在,nginx -t 因此报 BIO_new_file() failed。实际有效目录是 <证书地址路径>。
先用只读命令对照 Nginx 引用与 Certbot 实际创建的目录,再修正配置:
sudo grep -RIn '<证书地址路径>' /etc/nginx
sudo ls -la <证书地址路径>
sudo nginx -t
不要为了迎合错误配置,将私钥复制到一个新目录。证书的续期和符号链接都由 Certbot 管理;手工副本不会随续期更新,还会扩大私钥暴露面。应修正 Nginx 路径,并且每次 reload 前都先执行 nginx -t。
分层验收:从上游到真实写入
验收按数据流逐层向外推进,这样出错时能立即知道问题在哪一段:
- 在云端通过 Tailnet 请求本机 Nginx,确认上游可达、本机访问规则命中,并能获得 Boker 响应。
- 从公网请求根域名和 HTTP 入口,确认它们都跳转到
https://www.jinzhao.net.cn,证书链和公开文章页正常。 - 在公网 HTTPS 入口完成后台登录,编辑并保存一篇测试文章,确认主机、协议、来源校验和 Cookie 都正常。
- 分别上传 1 MiB 与 10 MiB 图片,再打开正文中的图片,确认两层
client_max_body_size和媒体读取链路都通过。 - 重启 Boker、本机 Nginx 与云端 Nginx,必要时重启本机,再重复公开文章、后台登录、保存和图片读取,确认服务会自动恢复。
只检查首页的 200 状态码不能代替这些步骤。公开读取、受保护写入、大请求体与重启恢复覆盖的是不同故障面。
上线后还要守住的边界
Tailscale 让两台机器之间的传输加密,但它不会自动代替所有访问控制。本机 Nginx 仍应只允许需要的 Tailnet 节点,Boker 继续只监听 loopback,云服务器的公网防火墙只开放必要端口。如果 Tailnet 还有其他成员,再用 Tailscale ACL 限制谁能访问本机 80 端口。
数据仍在本机,因此 PostgreSQL 与上传目录的定期备份比迁移前更重要,且备份要实际演练恢复。监控也不应只盯云端 Nginx:还要观察 Tailnet 连通性、本机 Nginx、Boker 进程、数据库与磁盘空间。双层代理的目标是把责任分开,验收和日常运维也要按同样的层次进行。