双机博客架构:DDNS 后端 + 固定 IP 前端
编写时间:2026-04-03
博客跑起来以后,我又遇到一个现实约束:我希望主要服务放在自己的物理服务器上,但备案域名又需要一个稳定的固定公网 IP。两个需求叠在一起后,最自然的做法不是把完整服务复制两份,而是拆成两台机器:DDNS 主机跑有状态后端,固定 IP 主机只跑无状态前端。
域名、IP、密钥和路径只保留示例写法。
问题背景
这套架构解决的是两个约束:
| 约束 | 说明 |
|---|---|
| 家庭服务器性能更强 | 适合跑 mx-server、数据库、Redis、媒体和其他服务 |
| 家庭宽带 IP 会变化 | 可以用 DDNS 或 Cloudflare Tunnel,但不适合作为备案固定入口 |
| 备案域名需要固定 IP | 固定 IP 小机器可以负责备案域名和标准 HTTPS 入口 |
| 博客前端是无状态的 | Shiro 可以部署多份,指向同一个后端 API |
核心原则是:
后端只跑一份,前端可以跑多份。
mx-server、MongoDB、Redis 是数据源,只放在 DDNS 主机。Shiro 是 Next.js 前端,可以部署在 DDNS 主机,也可以部署在固定 IP 主机,只要环境变量指向同一个 API。
角色分配
| 机器 | 跑什么 | 不跑什么 |
|---|---|---|
| DDNS 主机 | mx-server、MongoDB、Redis、Shiro、NPM、cloudflared | 不依赖固定公网 IP |
| 固定 IP 主机 | Shiro、NPM、证书 | 不跑数据库、不跑 mx-server |
固定 IP 主机只是一个“前端壳”。它负责让备案域名稳定访问,但页面内容和 API 数据都来自 DDNS 主机上的同一份后端。
DNS 设计
域名可以拆成两组:
blog.example.net -> Cloudflare / Tunnel / DDNS 主机
api.example.net -> Cloudflare / Tunnel / DDNS 主机
example.cn -> [fixed-public-ip]
www.example.cn -> CNAME example.cnexample.net 走 Cloudflare,适合日常访问和后端 API;example.cn 指向固定 IP 主机,用于备案域名和境内标准入口。
Cloudflare Tunnel
DDNS 主机可以通过 cloudflared 主动连接 Cloudflare,不要求本机有固定 IP。
[Unit]
Description=cloudflared
After=network-online.target
[Service]
Type=notify
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel run --token [cloudflare-tunnel-token]
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.targetCloudflare Tunnel 的 Public Hostname 可以都打到本机 NPM:
blog.example.net -> http://localhost:80
api.example.net -> http://localhost:80NPM 再根据 Host header 分发到 Shiro 或 mx-server。这样 cloudflared 只负责把流量带进来,不承担具体路径分流。
DDNS 主机部署
目录结构可以保持清晰:
/home/[user]/compose/
├── mx-space/core/
│ ├── docker-compose.yml
│ ├── .env
│ └── data/
│ ├── mx-space/
│ ├── db/
│ └── redis/
├── shiro/
│ ├── docker-compose.yml
│ └── .env
└── nginx-proxy-manager/
├── docker-compose.yml
├── data/
└── letsencrypt/mx-server、MongoDB、Redis 在同一个 Compose 里:
services:
mx-server:
image: innei/mx-server:latest
environment:
- TZ=Asia/Shanghai
- NODE_ENV=production
- DB_HOST=mongo
- REDIS_HOST=redis
- ALLOWED_ORIGINS
- JWT_SECRET
volumes:
- ./data/mx-space:/root/.mx-space
ports:
- "2333:2333"
depends_on:
- mongo
- redis
networks:
- mx-space
- nginx
restart: unless-stopped
mongo:
image: mongo
volumes:
- ./data/db:/data/db
networks:
- mx-space
restart: unless-stopped
redis:
image: redis:alpine
volumes:
- ./data/redis:/data
networks:
- mx-space
restart: unless-stopped
networks:
mx-space:
nginx:
external: true后端 .env:
JWT_SECRET=[random-secret]
ALLOWED_ORIGINS=blog.example.net,api.example.net,example.cn,www.example.cn
ENCRYPT_ENABLE=falseALLOWED_ORIGINS 要列出所有前端访问域名,否则固定 IP 主机上的 Shiro 访问 API 时可能遇到 CORS 问题。
Shiro 前端
DDNS 主机和固定 IP 主机上的 Shiro 配置可以基本一致,重点是 API 地址指向同一个后端:
NEXT_PUBLIC_API_URL=https://api.example.net/api/v2
NEXT_PUBLIC_GATEWAY_URL=https://api.example.net
NEXT_PUBLIC_OAUTH_PROVIDER=github
NEXT_PUBLIC_AUTH_ENABLED=true如果使用 OAuth、Clerk 或其他登录方案,对应密钥也放在环境变量中,不写入文章和公开仓库。
Shiro Compose:
services:
shiro:
image: innei/shiro:latest
volumes:
- ./.env:/app/.env
ports:
- "2323:2323"
networks:
- nginx
restart: unless-stopped
networks:
nginx:
external: true这里的关键点是:Shiro 不保存业务数据。它只是渲染前端页面,通过 NEXT_PUBLIC_API_URL 访问 mx-server。
NPM 反向代理
DDNS 主机上的 NPM 负责两个 Host:
这里沿用的是当时的 Nginx Proxy Manager 方案。它适合快速把双机入口跑通;如果后续路径规则、缓存或 header 处理变复杂,可以把这一层替换成手写 Nginx 配置。
blog.example.net -> shiro:2323
api.example.net -> mx-server:2333如果外层已经由 Cloudflare 处理 HTTPS,NPM 内部可以只走 HTTP。
固定 IP 主机上的 NPM 只负责备案域名:
example.cn -> shiro:2323
www.example.cn -> shiro:2323固定 IP 主机不经过 Cloudflare 时,NPM 负责 Let's Encrypt 证书和 Force SSL。
不要直接代理 feed 到后端
一个容易踩的坑是给 /feed、/sitemap.xml、/atom.xml 写特殊 Nginx 规则,直接代理到 mx-server:
location ~* /(feed|sitemap|atom.xml) {
proxy_pass http://mx-server:2333/$1;
}这在双机架构下不合适,因为固定 IP 主机上没有 mx-server 容器。更重要的是,Shiro 自己就能处理这些路由:它会从远程 API 拉数据并返回页面或订阅内容。
固定 IP 主机上的 NPM 应该保持简单:
location / {
proxy_pass http://shiro:2323;
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;
}NPM 只把请求交给 Shiro,让 Shiro 自己完成路由处理。
请求路径
访问固定 IP 域名时:
访问 DDNS/Cloudflare 域名时:
固定 IP 入口多了一次“前端主机出站访问 API”的过程,但博客页面的数据量一般很小,这个延迟通常可以接受。
部署顺序
DDNS 主机:
docker network create nginx
cd /home/[user]/compose/nginx-proxy-manager
docker compose up -d
cd /home/[user]/compose/mx-space/core
docker compose up -d
cd /home/[user]/compose/shiro
docker compose up -d
curl http://localhost:2333/api/v2/ping
curl http://localhost:2323固定 IP 主机:
docker network create nginx
cd /home/[user]/compose/nginx-proxy-manager
docker compose up -d
cd /home/[user]/compose/shiro
docker compose up -d
curl http://localhost:2323然后分别在两台机器的 NPM 中添加代理规则。
检查清单
| 检查项 | 期望 |
|---|---|
api.example.net/api/v2/ping | 返回后端健康状态 |
blog.example.net | 能渲染博客前端 |
example.cn | 能渲染同一份博客内容 |
ALLOWED_ORIGINS | 包含所有前端域名 |
| NPM Docker 网络 | Shiro、mx-server 与 NPM 同网 |
/feed 和 /sitemap.xml | 交给 Shiro 处理,不单独反代到后端 |
| SSL | Cloudflare 域名由 Cloudflare 处理,固定 IP 域名由 NPM 处理 |
后续优化
如果固定 IP 主机到 API 的公网路径延迟明显,可以在两台机器之间加 WireGuard:
但作为起步方案,公网 API 路径更简单,配置成本低,也更容易排查。
结论
双机博客架构的关键不是“两台机器都跑完整博客”,而是把有状态和无状态拆开:
| 层 | 放置位置 |
|---|---|
| 数据库、缓存、后端 API | DDNS 主机 |
| 前端页面 | DDNS 主机 + 固定 IP 主机 |
| HTTPS 入口 | Cloudflare / NPM |
| 备案域名 | 固定 IP 主机 |
这样可以用家庭服务器承载主要能力,同时满足固定 IP 入口和备案域名的要求。后端只有一份,数据不会分裂;前端可以多份,入口更灵活。