一台云服务器,多个 Web 项目:我的 Docker 化部署实践

我有一台国内的云服务器,要在上面跑好几个互不相干的 Web 项目: 一个游戏自动化管理平台、这个个人站,以后还会加。约束很典型:

这篇文章记录最终落地的结构,以及几个关键的取舍。

总体结构:三层

浏览器
  ↓ HTTPS
Caddy 网关(唯一暴露 80/443 的容器)
  ↓ HTTP(docker 内网 webnet)
各项目的前端 / 后端容器
  ↓
共享 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 里一处声明,理由写在 另一篇里。

这套结构放弃了什么

对个人服务器来说,这套「一个网关 + 每项目一个 compose + 共享数据库」 的结构在简单性和隔离性之间取得了不错的平衡。新增一个网站的边际成本是: 写一个小 compose、在 Caddyfile 里加两段 handle、跑一条 docker compose up -d