From 0f77acc6d3f75783ede718d96dff320d44d22917 Mon Sep 17 00:00:00 2001 From: Daniel Amodu Date: Mon, 7 Sep 2026 13:11:19 +0100 Subject: [PATCH 1/3] fix(databases): preserve service-level network attachments on deploy and update (fixes #5174) Ensure Docker Swarm services retain and properly update network attachments at both the ServiceSpec root level (Networks) and TaskTemplate.Networks across database builders (Postgres, MySQL, MariaDB, Redis, Mongo, LibSQL), application deployments, and rollbacks. Fixes #5174 Closes DOK-688 --- .../databases/database-networks.test.ts | 259 ++++++++++++++++++ packages/server/src/services/rollbacks.ts | 3 + packages/server/src/utils/builders/index.ts | 3 + packages/server/src/utils/databases/libsql.ts | 7 + .../server/src/utils/databases/mariadb.ts | 3 + packages/server/src/utils/databases/mongo.ts | 3 + packages/server/src/utils/databases/mysql.ts | 3 + .../server/src/utils/databases/postgres.ts | 3 + packages/server/src/utils/databases/redis.ts | 3 + packages/server/src/utils/docker/utils.ts | 6 +- 10 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/databases/database-networks.test.ts diff --git a/apps/dokploy/__test__/databases/database-networks.test.ts b/apps/dokploy/__test__/databases/database-networks.test.ts new file mode 100644 index 000000000..355cecce9 --- /dev/null +++ b/apps/dokploy/__test__/databases/database-networks.test.ts @@ -0,0 +1,259 @@ +import { buildLibSql } from "@dokploy/server/utils/databases/libsql"; +import { buildMariaDB } from "@dokploy/server/utils/databases/mariadb"; +import { buildMongo } from "@dokploy/server/utils/databases/mongo"; +import { buildMysql } from "@dokploy/server/utils/databases/mysql"; +import { buildPostgres } from "@dokploy/server/utils/databases/postgres"; +import { buildRedis } from "@dokploy/server/utils/databases/redis"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + inspectMock, + updateMock, + getServiceMock, + createServiceMock, + getRemoteDockerMock, + resolveServiceNetworksMock, +} = vi.hoisted(() => { + const inspect = vi.fn(); + const update = vi.fn(); + const getService = vi.fn(() => ({ inspect, update })); + const createService = vi.fn(async () => undefined); + const getRemoteDocker = vi.fn(async () => ({ + getService, + createService, + })); + const resolveServiceNetworks = vi.fn(async () => [ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + return { + inspectMock: inspect, + updateMock: update, + getServiceMock: getService, + createServiceMock: createService, + getRemoteDockerMock: getRemoteDocker, + resolveServiceNetworksMock: resolveServiceNetworks, + }; +}); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: getRemoteDockerMock, +})); + +vi.mock("@dokploy/server/services/network", () => ({ + resolveServiceNetworks: resolveServiceNetworksMock, +})); + +vi.mock("@dokploy/server/utils/vault", () => ({ + withResolvedVaultRefs: vi.fn(async (arg) => arg), +})); + +const createBaseDb = (appName: string) => ({ + appName, + databaseName: "testdb", + databaseUser: "testuser", + databasePassword: "testpassword", + dockerImage: "postgres:16", + externalPort: 5432, + memoryLimit: null, + memoryReservation: null, + cpuLimit: null, + cpuReservation: null, + command: null, + args: null, + mounts: [], + environment: { + project: { env: null }, + env: null, + }, + serverId: null, + networkIds: ["custom-overlay-id"], + detachDokployNetwork: false, +}); + +describe("database networks configuration", () => { + beforeEach(() => { + inspectMock.mockReset(); + updateMock.mockReset(); + getServiceMock.mockClear(); + createServiceMock.mockClear(); + getRemoteDockerMock.mockClear(); + getRemoteDockerMock.mockResolvedValue({ + getService: getServiceMock, + createService: createServiceMock, + }); + getServiceMock.mockReturnValue({ + inspect: inspectMock, + update: updateMock, + }); + }); + + describe("buildPostgres", () => { + it("attaches networks at both root and TaskTemplate when creating service", async () => { + inspectMock.mockRejectedValue(new Error("service not found")); + const db = createBaseDb("postgres-test-1"); + + await buildPostgres(db as any); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const [settings] = createServiceMock.mock.calls[0] as [any]; + expect(settings.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + expect(settings.TaskTemplate.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + }); + + it("preserves root and TaskTemplate networks when updating service", async () => { + inspectMock.mockResolvedValue({ + Version: { Index: "42" }, + Spec: { + TaskTemplate: { ForceUpdate: 1 }, + }, + }); + const db = createBaseDb("postgres-test-2"); + + await buildPostgres(db as any); + + expect(updateMock).toHaveBeenCalledTimes(1); + const [updatePayload] = updateMock.mock.calls[0] as [any]; + expect(updatePayload.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + expect(updatePayload.TaskTemplate.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + expect(updatePayload.TaskTemplate.ForceUpdate).toBe(2); + }); + }); + + describe("buildMysql", () => { + it("attaches networks at both root and TaskTemplate when creating and updating service", async () => { + inspectMock.mockResolvedValue({ + Version: { Index: "10" }, + Spec: { + TaskTemplate: { ForceUpdate: 0 }, + }, + }); + const db = createBaseDb("mysql-test-1"); + + await buildMysql(db as any); + + expect(updateMock).toHaveBeenCalledTimes(1); + const [updatePayload] = updateMock.mock.calls[0] as [any]; + expect(updatePayload.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + expect(updatePayload.TaskTemplate.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + }); + }); + + describe("buildMariaDB", () => { + it("attaches networks at both root and TaskTemplate when creating and updating service", async () => { + inspectMock.mockResolvedValue({ + Version: { Index: "15" }, + Spec: { + TaskTemplate: { ForceUpdate: 3 }, + }, + }); + const db = createBaseDb("mariadb-test-1"); + + await buildMariaDB(db as any); + + expect(updateMock).toHaveBeenCalledTimes(1); + const [updatePayload] = updateMock.mock.calls[0] as [any]; + expect(updatePayload.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + expect(updatePayload.TaskTemplate.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + }); + }); + + describe("buildRedis", () => { + it("attaches networks at both root and TaskTemplate when creating and updating service", async () => { + inspectMock.mockResolvedValue({ + Version: { Index: "20" }, + Spec: { + TaskTemplate: { ForceUpdate: 5 }, + }, + }); + const db = createBaseDb("redis-test-1"); + + await buildRedis(db as any); + + expect(updateMock).toHaveBeenCalledTimes(1); + const [updatePayload] = updateMock.mock.calls[0] as [any]; + expect(updatePayload.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + expect(updatePayload.TaskTemplate.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + }); + }); + + describe("buildMongo", () => { + it("attaches networks at both root and TaskTemplate when creating and updating service", async () => { + inspectMock.mockResolvedValue({ + Version: { Index: "25" }, + Spec: { + TaskTemplate: { ForceUpdate: 2 }, + }, + }); + const db = createBaseDb("mongo-test-1"); + + await buildMongo(db as any); + + expect(updateMock).toHaveBeenCalledTimes(1); + const [updatePayload] = updateMock.mock.calls[0] as [any]; + expect(updatePayload.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + expect(updatePayload.TaskTemplate.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + }); + }); + + describe("buildLibSql", () => { + it("attaches networks at both root and TaskTemplate when creating and updating service", async () => { + inspectMock.mockResolvedValue({ + Version: { Index: "30" }, + Spec: { + TaskTemplate: { ForceUpdate: 1 }, + }, + }); + const db = createBaseDb("libsql-test-1"); + + await buildLibSql(db as any); + + expect(updateMock).toHaveBeenCalledTimes(1); + const [updatePayload] = updateMock.mock.calls[0] as [any]; + expect(updatePayload.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + expect(updatePayload.TaskTemplate.Networks).toEqual([ + { Target: "dokploy-network" }, + { Target: "custom-overlay" }, + ]); + }); + }); +}); diff --git a/packages/server/src/services/rollbacks.ts b/packages/server/src/services/rollbacks.ts index 8837ae912..be8bea178 100644 --- a/packages/server/src/services/rollbacks.ts +++ b/packages/server/src/services/rollbacks.ts @@ -286,6 +286,7 @@ const rollbackApplication = async ( serveraddress: rollbackRegistry?.registryUrl || "", }, Name: appName, + Networks: resolvedNetworks, TaskTemplate: { ContainerSpec: { HealthCheck, @@ -328,8 +329,10 @@ const rollbackApplication = async ( await service.update({ version: Number.parseInt(inspect.Version.Index), ...settings, + Networks: resolvedNetworks, TaskTemplate: { ...settings.TaskTemplate, + Networks: resolvedNetworks, ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, }, }); diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index 6c8448000..1bd954b42 100644 --- a/packages/server/src/utils/builders/index.ts +++ b/packages/server/src/utils/builders/index.ts @@ -134,6 +134,7 @@ export const mechanizeDockerContainer = async ( const settings: CreateServiceOptions = { authconfig: authConfig, Name: appName, + Networks: resolvedNetworks, TaskTemplate: { ContainerSpec: { HealthCheck, @@ -181,8 +182,10 @@ export const mechanizeDockerContainer = async ( await service.update({ version: Number.parseInt(inspect.Version.Index), ...settings, + Networks: resolvedNetworks, TaskTemplate: { ...settings.TaskTemplate, + Networks: resolvedNetworks, ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, }, }); diff --git a/packages/server/src/utils/databases/libsql.ts b/packages/server/src/utils/databases/libsql.ts index d3b2f9947..fad7f1d94 100644 --- a/packages/server/src/utils/databases/libsql.ts +++ b/packages/server/src/utils/databases/libsql.ts @@ -86,6 +86,7 @@ export const buildLibsql = async (rawLibsql: LibsqlNested) => { const settings: CreateServiceOptions = { Name: appName, + Networks: resolvedNetworks, TaskTemplate: { ContainerSpec: { HealthCheck, @@ -156,6 +157,12 @@ export const buildLibsql = async (rawLibsql: LibsqlNested) => { await service.update({ version: Number.parseInt(inspect.Version.Index), ...settings, + Networks: resolvedNetworks, + TaskTemplate: { + ...settings.TaskTemplate, + Networks: resolvedNetworks, + ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, + }, }); } catch { await docker.createService(settings); diff --git a/packages/server/src/utils/databases/mariadb.ts b/packages/server/src/utils/databases/mariadb.ts index ae564504e..1a97ba8df 100644 --- a/packages/server/src/utils/databases/mariadb.ts +++ b/packages/server/src/utils/databases/mariadb.ts @@ -73,6 +73,7 @@ export const buildMariadb = async (rawMariadb: MariadbNested) => { const settings: CreateServiceOptions = { Name: appName, + Networks: resolvedNetworks, TaskTemplate: { ContainerSpec: { HealthCheck, @@ -127,8 +128,10 @@ export const buildMariadb = async (rawMariadb: MariadbNested) => { await service.update({ version: Number.parseInt(inspect.Version.Index), ...settings, + Networks: resolvedNetworks, TaskTemplate: { ...settings.TaskTemplate, + Networks: resolvedNetworks, ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, }, }); diff --git a/packages/server/src/utils/databases/mongo.ts b/packages/server/src/utils/databases/mongo.ts index 67496aeaf..b0ffb4608 100644 --- a/packages/server/src/utils/databases/mongo.ts +++ b/packages/server/src/utils/databases/mongo.ts @@ -121,6 +121,7 @@ ${command ?? "wait $MONGOD_PID"}`; const settings: CreateServiceOptions = { Name: appName, + Networks: resolvedNetworks, TaskTemplate: { ContainerSpec: { HealthCheck, @@ -184,8 +185,10 @@ ${command ?? "wait $MONGOD_PID"}`; await service.update({ version: Number.parseInt(inspect.Version.Index), ...settings, + Networks: resolvedNetworks, TaskTemplate: { ...settings.TaskTemplate, + Networks: resolvedNetworks, ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, }, }); diff --git a/packages/server/src/utils/databases/mysql.ts b/packages/server/src/utils/databases/mysql.ts index 56bb7ea4d..ece4d325f 100644 --- a/packages/server/src/utils/databases/mysql.ts +++ b/packages/server/src/utils/databases/mysql.ts @@ -79,6 +79,7 @@ export const buildMysql = async (rawMysql: MysqlNested) => { const settings: CreateServiceOptions = { Name: appName, + Networks: resolvedNetworks, TaskTemplate: { ContainerSpec: { HealthCheck, @@ -133,8 +134,10 @@ export const buildMysql = async (rawMysql: MysqlNested) => { await service.update({ version: Number.parseInt(inspect.Version.Index), ...settings, + Networks: resolvedNetworks, TaskTemplate: { ...settings.TaskTemplate, + Networks: resolvedNetworks, ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, }, }); diff --git a/packages/server/src/utils/databases/postgres.ts b/packages/server/src/utils/databases/postgres.ts index 18392b7b5..0860b6746 100644 --- a/packages/server/src/utils/databases/postgres.ts +++ b/packages/server/src/utils/databases/postgres.ts @@ -72,6 +72,7 @@ export const buildPostgres = async (rawPostgres: PostgresNested) => { const settings: CreateServiceOptions = { Name: appName, + Networks: resolvedNetworks, TaskTemplate: { ContainerSpec: { HealthCheck, @@ -125,8 +126,10 @@ export const buildPostgres = async (rawPostgres: PostgresNested) => { await service.update({ version: Number.parseInt(inspect.Version.Index), ...settings, + Networks: resolvedNetworks, TaskTemplate: { ...settings.TaskTemplate, + Networks: resolvedNetworks, ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, }, }); diff --git a/packages/server/src/utils/databases/redis.ts b/packages/server/src/utils/databases/redis.ts index 64c16657b..0ec3e09bb 100644 --- a/packages/server/src/utils/databases/redis.ts +++ b/packages/server/src/utils/databases/redis.ts @@ -70,6 +70,7 @@ export const buildRedis = async (rawRedis: RedisNested) => { const settings: CreateServiceOptions = { Name: appName, + Networks: resolvedNetworks, TaskTemplate: { ContainerSpec: { HealthCheck, @@ -132,8 +133,10 @@ export const buildRedis = async (rawRedis: RedisNested) => { await service.update({ version: Number.parseInt(inspect.Version.Index), ...settings, + Networks: resolvedNetworks, TaskTemplate: { ...settings.TaskTemplate, + Networks: resolvedNetworks, ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, }, }); diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index e342eb450..e6b072248 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -950,7 +950,11 @@ export const waitForSwarmServiceConvergence = async ( let lastState = "unknown"; while (true) { const info = await service.inspect(); - const desiredTasksCount = info.Spec?.Mode?.Replicated?.Replicas ?? 1; + const configuredReplicas = info.Spec?.Mode?.Replicated?.Replicas; + const desiredTasksCount = + configuredReplicas !== undefined && configuredReplicas > 0 + ? configuredReplicas + : 1; const tasks = await remoteDocker.listTasks({ filters: JSON.stringify({ service: [appName] }), From 0957c875fb6bc15f14d85446661bb21f43ed245f Mon Sep 17 00:00:00 2001 From: Daniel Amodu Date: Mon, 7 Sep 2026 13:19:20 +0100 Subject: [PATCH 2/3] fix(test): correct function casing for buildLibsql/buildMariadb and guard ForceUpdate --- .../__test__/databases/database-networks.test.ts | 12 ++++++------ packages/server/src/utils/databases/libsql.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/dokploy/__test__/databases/database-networks.test.ts b/apps/dokploy/__test__/databases/database-networks.test.ts index 355cecce9..1278ec5ec 100644 --- a/apps/dokploy/__test__/databases/database-networks.test.ts +++ b/apps/dokploy/__test__/databases/database-networks.test.ts @@ -1,5 +1,5 @@ -import { buildLibSql } from "@dokploy/server/utils/databases/libsql"; -import { buildMariaDB } from "@dokploy/server/utils/databases/mariadb"; +import { buildLibsql } from "@dokploy/server/utils/databases/libsql"; +import { buildMariadb } from "@dokploy/server/utils/databases/mariadb"; import { buildMongo } from "@dokploy/server/utils/databases/mongo"; import { buildMysql } from "@dokploy/server/utils/databases/mysql"; import { buildPostgres } from "@dokploy/server/utils/databases/postgres"; @@ -157,7 +157,7 @@ describe("database networks configuration", () => { }); }); - describe("buildMariaDB", () => { + describe("buildMariadb", () => { it("attaches networks at both root and TaskTemplate when creating and updating service", async () => { inspectMock.mockResolvedValue({ Version: { Index: "15" }, @@ -167,7 +167,7 @@ describe("database networks configuration", () => { }); const db = createBaseDb("mariadb-test-1"); - await buildMariaDB(db as any); + await buildMariadb(db as any); expect(updateMock).toHaveBeenCalledTimes(1); const [updatePayload] = updateMock.mock.calls[0] as [any]; @@ -232,7 +232,7 @@ describe("database networks configuration", () => { }); }); - describe("buildLibSql", () => { + describe("buildLibsql", () => { it("attaches networks at both root and TaskTemplate when creating and updating service", async () => { inspectMock.mockResolvedValue({ Version: { Index: "30" }, @@ -242,7 +242,7 @@ describe("database networks configuration", () => { }); const db = createBaseDb("libsql-test-1"); - await buildLibSql(db as any); + await buildLibsql(db as any); expect(updateMock).toHaveBeenCalledTimes(1); const [updatePayload] = updateMock.mock.calls[0] as [any]; diff --git a/packages/server/src/utils/databases/libsql.ts b/packages/server/src/utils/databases/libsql.ts index fad7f1d94..ec54116f7 100644 --- a/packages/server/src/utils/databases/libsql.ts +++ b/packages/server/src/utils/databases/libsql.ts @@ -161,7 +161,7 @@ export const buildLibsql = async (rawLibsql: LibsqlNested) => { TaskTemplate: { ...settings.TaskTemplate, Networks: resolvedNetworks, - ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1, + ForceUpdate: (inspect.Spec.TaskTemplate?.ForceUpdate ?? 0) + 1, }, }); } catch { From 0f4f6f057e0ee4bec63677486650db896fff8ec2 Mon Sep 17 00:00:00 2001 From: Daniel Amodu Date: Mon, 7 Sep 2026 13:20:16 +0100 Subject: [PATCH 3/3] fix(docker): preserve zero-replica convergence for scaled-down services --- packages/server/src/utils/docker/utils.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index e6b072248..e342eb450 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -950,11 +950,7 @@ export const waitForSwarmServiceConvergence = async ( let lastState = "unknown"; while (true) { const info = await service.inspect(); - const configuredReplicas = info.Spec?.Mode?.Replicated?.Replicas; - const desiredTasksCount = - configuredReplicas !== undefined && configuredReplicas > 0 - ? configuredReplicas - : 1; + const desiredTasksCount = info.Spec?.Mode?.Replicated?.Replicas ?? 1; const tasks = await remoteDocker.listTasks({ filters: JSON.stringify({ service: [appName] }),