一台云服务器,多个 Web 项目:我的 Docker 化部署实践
我有一台国内的云服务器,要在上面跑好几个互不相干的 Web 项目: 一个游戏自动化管理平台、这个个人站,以后还会加。约束很典型:
- 一个公网 IP,一个域名;
- 每个项目有自己的前端、后端和数据库;
- 各项目独立构建、独立部署、独立回滚,互不影响。
这篇文章记录最终落地的结构,以及几个关键的取舍。
总体结构:三层
浏览器
↓ HTTPS
Caddy 网关(唯一暴露 80/443 的容器)
↓ HTTP(docker 内网 webnet)
各项目的前端 / 后端容器
↓
共享 PostgreSQL(每项目一个独立 database)
三条最重要的原则:
- 只有网关暴露公网端口。所有业务容器只挂在内部网络
webnet上,用expose而不是ports。 安全组里除了 22/80/443 什么都不开。 - 每个项目一个独立的 compose 编排,通过 external 网络
webnet互通。任何一个项目compose down都不会 影响别的项目,网络本身由各项目共同引用、不被任何一方拥有。 - 数据库共享实例、按项目分库。单机场景没必要每个项目 起一个 PostgreSQL 容器,一个实例加每项目一个 database 就够了。
按路径分发,而不是按端口
多个项目共用一个域名时,每个项目挂一个路径前缀,例如管理平台挂在
/game-auto/ 下。网关配置的骨架:
# API:剥掉 /game-auto 前缀再转发,后端不需要知道自己部署在哪个路径下
@gas_api path /game-auto/api/*
handle @gas_api {
uri strip_prefix /game-auto
reverse_proxy server:8080
}
# 前端:同样剥前缀,SPA 的深链接由前端容器里的 nginx fallback 接管
@gas path /game-auto/*
handle @gas {
uri strip_prefix /game-auto
reverse_proxy web-admin:80
}
「剥掉前缀再转发」这条很重要:后端代码和前端容器内部都按根路径工作, 路径前缀只是网关层的部署细节。哪天要把某个项目拆到独立域名或独立机器, 只需要改网关,业务容器一行不动。
最容易踩的坑:前端的 base 路径
SPA 挪到子路径下,光改网关不够。构建产物里的资源引用、路由器的
history base 默认都是 /,直接挪过去会出现「页面能开、JS/CSS 全 404」
或者「首页能开、点链接跳到根路径」。
正规做法是构建时设好 base(Vite 的 base、vue-router 的
createWebHistory(base))。我这次遇到一个变体:前端源码不在这台
部署机上,只有构建好的镜像。好在产物里路由创建时没有显式传 base——
vue-router 在这种情况下会读文档的 <base href>——于是用
docker 的 bind mount 把一个加了 <base href="/game-auto/">、
资源路径改过前缀的 index.html 盖进容器,等下一次正常发版时再用构建参数
根治。这是个临时手段,但比改镜像里的 minified JS 干净得多。
TLS 与限流集中在网关
证书只在网关这一层处理。域名备案下来之后,Caddy 自动向 Let's Encrypt 签发和续期,业务容器全程只见明文 HTTP。限流同理—— 哪些端点需要、限多少,全部在 Caddyfile 里一处声明,理由写在 另一篇里。
这套结构放弃了什么
- 单机毕竟是单机,没有高可用。但这些都是个人项目,挂一小时的代价 远低于维护集群的代价。
- 路径前缀方案对前端构建有要求。如果某个项目的前端完全无法控制 base 路径,就只能给它单独配子域名。
- 共享 PostgreSQL 意味着它挂了全部项目都挂。接受它的理由同上, 而且备份按实例做反而简单。
对个人服务器来说,这套「一个网关 + 每项目一个 compose + 共享数据库」
的结构在简单性和隔离性之间取得了不错的平衡。新增一个网站的边际成本是:
写一个小 compose、在 Caddyfile 里加两段 handle、跑一条
docker compose up -d。