这篇文章想解决一个很实际的问题:家里的服务器上已经有一套在用的项目,现在想搬进 Dokploy,怎样把旧数据一起带过去,再让以后的更新省一点事。
如果你也正好卡在这里,希望读完之后,你可以自己完成这几件事:搬走项目和数据库;把域名接进 Traefik;让 Gitea 的 main 分支推送触发部署;最后,验证新环境真的接手了,才关掉旧的。
我之前写过一篇 Cloudflare Tunnel 的配置笔记,这次算是接着往下折腾。
之前我的做法很直接:项目跑在旧 Server,Tunnel 对着它的端口。能用,没什么问题。但项目一多,每次改完都要上服务器处理,还是有一点烦。
所以这次我想把部署交给 Dokploy。理想情况就是:代码写好,push 上去,后面的构建和发布让它自己做。
(当然,旧的数据也要在。总不能搬完家,把家具全部留在原地。)
隐私说明:本文的项目名、仓库、域名、服务器地址、端口与路径均为匿名示例。配图也重新绘制,不含真实项目界面、业务内容或部署凭据。请按自己的环境替换示例值。
1. 先看清楚旧服务器到底在跑什么
我没有一上来就在 Dokploy 按 Deploy。先检查旧环境,把运行中的版本和数据位置记下来。
这里尤其要分清楚:Repo 里的代码,和服务器正在运行的代码,不一定是同一个版本。
可以先看这些:
# 在旧项目目录中查看
git status --short
git branch --show-current
git rev-parse HEAD
# 查看正在运行的容器,以及实际使用的镜像
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
再确认数据库版本、挂载的数据卷、上传文件位置,以及有哪些后台任务会写数据。环境变量也要核对,但不要把包含密码的完整输出贴进 Blog 或聊天。
这次数据库是 PostgreSQL 15,新环境也继续用 PostgreSQL 15,先把迁移范围控制住。换机器和升级数据库大版本,没有必要挤在同一次操作里。
另外,资料不一定都在项目目录下。有些附件存在数据库里,有些项目把文件放在 volume,还有一些会把状态放在独立目录。只 clone Repo,拿到的通常只是程序。
2. 我为什么拆成两个 service
最开始看 Dokploy 的时候,很容易觉得:一个项目,放一个 Compose 不就好了?
如果只是要跑起来,确实可以。但我还希望以后更新网站时,数据库和后台任务继续工作,所以最后分成这样:
| Dokploy 服务 | 放什么 | 什么时候更新 |
|---|---|---|
demo-web,Application |
网站和 API | main 推送后自动构建、滚动发布 |
demo-infrastructure,Compose |
PostgreSQL 和后台 Worker | 需要升级这些组件时单独处理 |

上图是重新绘制的示意界面。两个 service 各有职责;Web 的两个副本,属于同一个 Application。
这里的重点是:网站发布不需要顺便重启数据库。数据库用独立的持久卷保存数据,Worker 则先保留单实例,避免一个任务被重复执行。
这次后台组件先迁移了经过验证的运行镜像。它没有跟着 Web 的 push 自动升级,以后改到 Worker 时,再单独构建、测试和发布。
3. 搬数据库,先演练,再做最终切换
我先做了一份在线备份,在新服务器恢复到临时数据库里。确认数据库能起来、应用能读取原有资料之后,才安排最后的切换。
PostgreSQL 这里使用逻辑备份:
# 旧服务器;容器名、数据库用户和库名都要换成自己的
umask 077
docker exec demo-db-old \
pg_dump -U demo_user -d demo_db -Fc > final.dump
# 记录备份校验值
shasum -a 256 final.dump
-Fc 生成的是给 pg_restore 使用的备份格式。把文件传到新服务器后,再比一次 SHA-256,先确认传输前后是同一份文件。
最终切换时,我做了一个短暂的停写窗口:停止旧应用和会写库的 Worker,再重新导出最终备份。否则旧站一边接收新资料,新站一边恢复,恢复完成的那一刻就可能已经落后了。
新数据库准备好以后,再导入。下面这段只适用于首次导入的空数据库,已经上线的数据库不要重复执行:
docker exec -i demo-db-new \
pg_restore -U demo_user -d demo_db \
--no-owner --no-privileges \
--single-transaction --exit-on-error < final.dump
恢复时还没有启动新网站和 Worker。这样就有一个比较干净的时间点,能拿旧库和新库逐项对照。
我这次检查了每张业务表的记录数、内容摘要,以及序列值。附件的二进制内容也参与核对。不能只看首页打开了,就当成数据搬完了。
还有一个容易漏掉的地方:数据库之外的密钥,也可能是数据的一部分。 如果旧资料需要某个加密 Key 才能读取,就要把原 Key 安全带过去。随手生成一个新的,数据库虽然在,内容却可能解不开。
数据库密码可以重新配置,但应用和 Worker 的连接信息要同步更新。真正的值放在 Dokploy 的运行环境或权限受控的配置文件中,不放进 Git,也不带进 Docker 构建。
4. 先把新站跑通,再接域名
新服务器上,我先让基础 Compose 启动 PostgreSQL 和 Worker,再部署 Web Application。
示例网络叫 demo-network,数据库在里面有一个固定别名 demo-db,应用用这个名字连接数据库:
DATABASE_URL=postgresql://demo_user:REPLACE_WITH_PASSWORD@demo-db:5432/demo_db
HOST=0.0.0.0
PORT=3000
PUBLIC_BASE_URL=https://app.example.com
实际密码只填在运行环境中。上面这段是格式示例,密码里有特殊字符时,也要按连接 URL 的规则处理。
数据库端口没有发布到宿主机。Web 和 Worker 从内部网络连接它就够了。
为了方便在家里检查,我另外保留一个 LAN 入口。示例配置如下:
| 配置 | 示例值 |
|---|---|
| Published port | 8080 |
| Target port | 3000 |
| Publish mode | Ingress |
| Replicas | 2 |
这样内网访问 http://新服务器地址:8080,就能到网站。
如果你还需要在这个 HTTP 地址登录,记得检查 Cookie 的 Secure 设置:带 Secure 标记的 Cookie 需要 HTTPS。我的环境因为要兼顾 LAN HTTP,作了对应设置;如果你的所有访问都走 HTTPS,就应该保持 Secure 开启。
5. Tunnel 接 80,Traefik 再按域名分流
之前的做法,是每个项目对应一个 Tunnel 上游端口。这次我想把入口统一到 Traefik。

请求经过的路径就是:
app.example.com
→ Cloudflare Tunnel
→ 新服务器的 localhost:80
→ Traefik 按域名匹配
→ demo-web 的容器端口 3000
在 Dokploy 的 Web Application 里添加 Domain:
| 配置 | 示例值 |
|---|---|
| Host | app.example.com |
| Path / Internal Path | / |
| Container port | 3000 |
| Traefik 入口 | HTTP / web |
然后让这个域名进入新服务器对应的 Tunnel,上游填 http://localhost:80。公网访问仍然用 HTTPS,这里的 HTTP 是 cloudflared 到本机 Traefik 的连接。
有两个小细节,我这次也踩到了。
第一,localhost 是相对于 cloudflared 所在的环境。 这里 cloudflared 跑在宿主机,所以 localhost:80 是宿主机的 80。如果它跑在另外一个容器里,就要确认那个容器实际能连接到 Traefik,不能原样照抄。
第二,Web 要同时加入两个网络。
demo-network:让 Web 连接数据库。dokploy-network:让 Dokploy 的 Traefik 连接 Web。
我一开始只接了项目网络,结果内网端口可以打开,域名却是 502。补上 dokploy-network,再把设置保存进 Dokploy,域名入口才正常。
另外,Traefik 是靠请求里的 Host 来分流的。Tunnel 不要把所有项目的 Host 强行改成同一个值。域名也要有对应的 Tunnel/DNS 路由,单单填一个 localhost:80,不会自动接管所有子域名。
本地管理的 Tunnel 可以参考 Cloudflare 的配置文件说明;在 Dashboard 管理的 Tunnel,就在对应的已发布应用中设置域名和 Service URL。
6. 私密 Gitea 仓库,也可以自动部署
仓库改成 private,不影响这个流程。关键是 Dokploy 要有读取代码的权限。
我给这个仓库单独配了一把只读 Deploy Key:公钥放进 Gitea 仓库的 Deploy Keys,私钥交给 Dokploy 的 SSH Keys,再让 Web Application 选择这把 Key。
示例 Git 地址:
ssh://[email protected]:22/example/demo-app.git
SSH 地址和端口要填 Dokploy 服务器实际能到达的地址。浏览器能打开 Gitea 的 HTTPS 页面,不代表 Git SSH 也一定通。主机指纹也要先核对,再加入 known_hosts。
Application 里选择 main,按项目的 Dockerfile 设置构建路径。运行密钥留在运行环境,不要为了省事把整个 .env 复制进构建上下文。
接着设置自动部署:
- 在 Dokploy 的 Web Application 开启 Auto Deploy。
- 到 Deployments 复制 Webhook URL。
- 在 Gitea 的仓库 Settings → Webhooks 添加它。
- 选择 Push Events,分支过滤为
refs/heads/main。 - 发送 Test Push Event,检查投递响应,再到 Dokploy 看是否真的开始拉取和构建。
Webhook URL 本身带着触发部署的能力,所以这里不放截图,也不要把完整地址贴出来。
这部分的操作位置可以对照 Dokploy 的自动部署说明。我这次连起来的是自动构建和发布;还没有额外接一条 Gitea Actions 流水线去跑完整测试,提交前的检查仍然要做。
7. 自动部署之后,再处理 Zero Downtime
自动部署只是替我按了 Deploy。如果新容器还没有准备好,旧容器就退出,用户还是会遇到短暂的 Bad Gateway。
所以我给 Web 配了两个副本,再到 Advanced → Swarm Settings 设置更新策略和健康检查。
更新策略的关键配置:
{
"Parallelism": 1,
"Delay": 10000000000,
"FailureAction": "rollback",
"Monitor": 30000000000,
"MaxFailureRatio": 0,
"Order": "start-first"
}
意思是一次更新一个副本,先启动新版本;出现被检测到的更新故障时,按策略回滚。上面的时间单位是纳秒,例如 10000000000 就是 10 秒。
健康检查也要检查到真正的依赖。这个例子要求 /api/health 返回 JSON,并且 database_connected 为 true:
{
"Test": [
"CMD", "python", "-c",
"import json,urllib.request; d=json.load(urllib.request.urlopen('http://127.0.0.1:3000/api/health',timeout=3)); assert d.get('database_connected') is True"
],
"Interval": 5000000000,
"Timeout": 4000000000,
"StartPeriod": 30000000000,
"Retries": 3
}
这是带 Python 的镜像可以用的写法。换成其他镜像,要用镜像里确实存在的工具,也要按自己的接口改判断条件。
此外,我给旧容器留了 180 秒的停止宽限时间,让应用有机会处理退出时的请求。这个设置也需要应用本身正确处理停止信号,不能单靠一个数字包办。

这里留一个边界:这次说的 Zero Downtime,是兼容版本的 Web 滚动更新。
第一次搬数据库时,我安排了停写;以后如果要删除旧代码还在用的字段、升级数据库或修改 Worker,也要单独安排。两份 Web 还在同一台机器上,机器本身关机,它们一样会一起离线。
相关设置可以参考 Dokploy 的 Zero Downtime 文档。具体能不能平滑更新,最后还是要测自己的应用。
8. 真的发一次版本,边发边测
配置填好了,我还是不太放心。所以这次通过 Gitea 的 Test Push Event,实际触发了私密仓库拉取、Docker 构建和滚动发布。
发布期间持续检查四个目标:内网首页、内网健康接口,以及经过 Traefik 的首页和健康接口。
最终检测了 1,408 次请求,持续约 227 秒,失败为 0。
这代表这一次更新没有在探测中发现请求失败。以后代码改坏、数据库迁移不兼容,还是可能出问题,不能把一次通过当成永久保证。
除了检测 HTTP,我也确认了旧资料可读取、后台任务有新的活动记录,并在新服务器的日志里找到公网测试请求。这样才能确定访问的确实是新环境。
如果想单独检查 Traefik,示例命令是:
curl -fsS -H 'Host: app.example.com' \
http://新服务器地址/api/health
这里特意带上 Host,因为直接访问 IP 时,可能根本没有匹配到这个项目的路由。
9. 新环境接手之后,旧站才下线
迁移期间,我暂时留了一个旧入口代理,把旧地址的请求转到新服务器,方便切换时过渡。
等新域名和 LAN 入口都验证正常后,再关闭这个代理、停掉旧应用、旧 Worker 和旧数据库,并取消旧容器的自动重启。
随后再访问新站、检查数据库连接,确认它没有偷偷依赖旧环境。新站开始接收资料以后,也补了一份新的数据库备份。
最后清理掉演练容器、临时数据库卷、演练网络和多余的环境配置。正式数据卷、旧库备份和必要的恢复资料继续保留。
旧服务器上还有其他项目,所以共享的 cloudflared 和 Gitea 都继续运行。这次只停用了旧应用入口,没有删除它原来的 Tunnel/DNS 路由,因此旧域名当时返回 502。需要彻底退役这个域名时,再单独清理对应路由,不要把整条 Tunnel 一起关掉。
还有一点要记住:新站开始有新数据以后,就不能直接启动旧快照当作回滚了。应用版本可以退,新增的数据也要一起考虑。
折腾完之后,日常更新总算可以回到比较简单的流程:本地改代码,检查,push main,再看一下部署结果。
数据库和后台任务各自待着,网站需要更新的时候再换网站。以后再搬一次,也不用靠记忆去猜当时到底配了什么。
(写下来,主要也是怕未来的自己忘记。这个概率还蛮高的。)
