双机博客架构:DDNS 后端 + 固定 IP 前端

2026 年 4 月 3 日 星期五
/ , , , , ,
9
摘要
博客主要服务跑在家庭服务器,但备案域名需要稳定固定公网 IP。方案是 DDNS 主机跑 mx-server、MongoDB、Redis 和一份前端,固定 IP 主机只跑无状态 Shiro 前端,后端数据保持一份,前端入口可以多份。

阅读此文章之前,你可能需要首先阅读以下的文章才能更好的理解上下文。

双机博客架构: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.cn

example.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.target

Cloudflare Tunnel 的 Public Hostname 可以都打到本机 NPM:

blog.example.net  -> http://localhost:80
api.example.net   -> http://localhost:80

NPM 再根据 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=false

ALLOWED_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 处理,不单独反代到后端
SSLCloudflare 域名由 Cloudflare 处理,固定 IP 域名由 NPM 处理

后续优化

如果固定 IP 主机到 API 的公网路径延迟明显,可以在两台机器之间加 WireGuard:

但作为起步方案,公网 API 路径更简单,配置成本低,也更容易排查。

结论

双机博客架构的关键不是“两台机器都跑完整博客”,而是把有状态和无状态拆开:

放置位置
数据库、缓存、后端 APIDDNS 主机
前端页面DDNS 主机 + 固定 IP 主机
HTTPS 入口Cloudflare / NPM
备案域名固定 IP 主机

这样可以用家庭服务器承载主要能力,同时满足固定 IP 入口和备案域名的要求。后端只有一份,数据不会分裂;前端可以多份,入口更灵活。

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...