From 9c0848c2148caf7ee305f631bfb6ac58b341d494 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:44:50 -0400 Subject: [PATCH 1/2] fix(setup): create the panel Postgres at 18.6 and keep existing services as they are The setup script still created PostgreSQL 16 after the project moved to the 18.6 pin, and on every run it updated an existing service with the image and mounts from the script. Changing only the image would make an existing 16 data volume look empty to PostgreSQL 18, so: - a new service is created from postgres:18.6 with PGDATA=/var/lib/postgresql/data, so the mount path that existing volumes use stays the same - an existing service keeps its current image and mounts; only the other settings are refreshed, and no image is pulled - a failed image pull warns and continues, so an image that is already on the machine still works offline - a failed update, or a failed service lookup other than "not found", now stops the setup with the error instead of printing "already exists" Tests cover the new service spec, the existing-service path, and the offline pull fallback. Co-Authored-By: Claude Opus 5.5 --- .../__test__/setup/postgres-setup.test.ts | 128 ++++++++++++++++++ packages/server/src/setup/postgres-setup.ts | 86 ++++++++---- 2 files changed, 185 insertions(+), 29 deletions(-) create mode 100644 apps/dokploy/__test__/setup/postgres-setup.test.ts diff --git a/apps/dokploy/__test__/setup/postgres-setup.test.ts b/apps/dokploy/__test__/setup/postgres-setup.test.ts new file mode 100644 index 00000000000..df80b295413 --- /dev/null +++ b/apps/dokploy/__test__/setup/postgres-setup.test.ts @@ -0,0 +1,128 @@ +import { initializePostgres } from "@dokploy/server/setup/postgres-setup"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type ServiceOptions = { + version?: number; + TaskTemplate?: { + ContainerSpec?: { + Image?: string; + Env?: string[]; + Mounts?: Array<{ Type?: string; Source?: string; Target?: string }>; + }; + }; +}; + +const { + getServiceMock, + inspectMock, + updateMock, + createServiceMock, + pullImageMock, +} = vi.hoisted(() => { + const inspect = vi.fn<() => Promise>(); + const update = vi.fn<(options: ServiceOptions) => Promise>( + async () => undefined, + ); + const createService = vi.fn<(options: ServiceOptions) => Promise>( + async () => undefined, + ); + const getService = vi.fn(() => ({ inspect, update })); + const pullImage = vi.fn<(image: string) => Promise>( + async () => undefined, + ); + return { + getServiceMock: getService, + inspectMock: inspect, + updateMock: update, + createServiceMock: createService, + pullImageMock: pullImage, + }; +}); + +vi.mock("@dokploy/server/constants", () => ({ + docker: { + getService: getServiceMock, + createService: createServiceMock, + }, +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + pullImage: pullImageMock, +})); + +const serviceNotFound = () => + Object.assign(new Error("service not found"), { statusCode: 404 }); + +const existingMounts = [ + { + Type: "volume", + Source: "test-postgres-volume", + Target: "/var/lib/postgresql/data", + }, +]; + +describe("initializePostgres", () => { + beforeEach(() => { + vi.clearAllMocks(); + pullImageMock.mockResolvedValue(undefined); + inspectMock.mockRejectedValue(serviceNotFound()); + }); + + it("pulls postgres 18.6 and creates the service with PGDATA when none exists", async () => { + await initializePostgres(); + + expect(pullImageMock).toHaveBeenCalledWith("postgres:18.6"); + expect(updateMock).not.toHaveBeenCalled(); + expect(createServiceMock).toHaveBeenCalledTimes(1); + + const options = createServiceMock.mock.calls[0]?.[0]; + const spec = options?.TaskTemplate?.ContainerSpec; + expect(spec?.Image).toBe("postgres:18.6"); + expect(spec?.Env).toContain("PGDATA=/var/lib/postgresql/data"); + expect(spec?.Mounts).toEqual([ + expect.objectContaining({ Target: "/var/lib/postgresql/data" }), + ]); + }); + + it("creates the service from a local image when the pull fails", async () => { + pullImageMock.mockRejectedValue( + Object.assign(new Error("getaddrinfo ENOTFOUND registry.test"), { + code: "ENOTFOUND", + }), + ); + + await expect(initializePostgres()).resolves.toBeUndefined(); + + expect(pullImageMock).toHaveBeenCalledWith("postgres:18.6"); + expect(createServiceMock).toHaveBeenCalledTimes(1); + + const options = createServiceMock.mock.calls[0]?.[0]; + const spec = options?.TaskTemplate?.ContainerSpec; + expect(spec?.Image).toBe("postgres:18.6"); + expect(spec?.Env).toContain("PGDATA=/var/lib/postgresql/data"); + }); + + it("keeps the existing image and mounts when the service already exists", async () => { + inspectMock.mockResolvedValue({ + Version: { Index: 7 }, + Spec: { + TaskTemplate: { + ContainerSpec: { Image: "postgres:16", Mounts: existingMounts }, + }, + }, + }); + + await initializePostgres(); + + expect(pullImageMock).not.toHaveBeenCalled(); + expect(createServiceMock).not.toHaveBeenCalled(); + expect(updateMock).toHaveBeenCalledTimes(1); + + const options = updateMock.mock.calls[0]?.[0]; + const spec = options?.TaskTemplate?.ContainerSpec; + expect(options?.version).toBe(7); + expect(spec?.Image).toBe("postgres:16"); + expect(spec?.Mounts).toEqual(existingMounts); + expect(spec?.Env).toContain("PGDATA=/var/lib/postgresql/data"); + }); +}); diff --git a/packages/server/src/setup/postgres-setup.ts b/packages/server/src/setup/postgres-setup.ts index ddf5a3d5fd7..ca77a854335 100644 --- a/packages/server/src/setup/postgres-setup.ts +++ b/packages/server/src/setup/postgres-setup.ts @@ -1,4 +1,4 @@ -import type { CreateServiceOptions } from "dockerode"; +import type { ContainerSpec, CreateServiceOptions } from "dockerode"; import { docker } from "../constants"; import { POSTGRES_DB, @@ -6,28 +6,33 @@ import { resolvePostgresPassword, } from "../db/constants"; import { pullImage } from "../utils/docker/utils"; +import { PINNED_VERSIONS } from "./component-versions"; export const initializePostgres = async () => { - const imageName = "postgres:16"; + const imageName = `postgres:${PINNED_VERSIONS.postgres}`; const containerName = "dokploy-postgres"; const postgresPassword = resolvePostgresPassword({ allowDatabaseUrl: true }); + const containerSpec: ContainerSpec = { + Image: imageName, + Env: [ + `POSTGRES_USER=${POSTGRES_USER}`, + `POSTGRES_DB=${POSTGRES_DB}`, + `POSTGRES_PASSWORD=${postgresPassword}`, + // The PostgreSQL 18 image otherwise keeps data in a version directory + // under /var/lib/postgresql; this keeps the mount path existing volumes use. + "PGDATA=/var/lib/postgresql/data", + ], + Mounts: [ + { + Type: "volume", + Source: "dokploy-postgres", + Target: "/var/lib/postgresql/data", + }, + ], + }; const settings: CreateServiceOptions = { Name: containerName, TaskTemplate: { - ContainerSpec: { - Image: imageName, - Env: [ - `POSTGRES_USER=${POSTGRES_USER}`, - `POSTGRES_DB=${POSTGRES_DB}`, - `POSTGRES_PASSWORD=${postgresPassword}`, - ], - Mounts: [ - { - Type: "volume", - Source: "dokploy-postgres", - Target: "/var/lib/postgresql/data", - }, - ], - }, + ContainerSpec: containerSpec, Networks: [{ Target: "dokploy-network" }], Placement: { Constraints: ["node.role==manager"], @@ -51,25 +56,48 @@ export const initializePostgres = async () => { }, }), }; - try { - await pullImage(imageName); - const service = docker.getService(containerName); - const inspect = await service.inspect(); + const service = docker.getService(containerName); + const inspect = await service.inspect().catch((error: any) => { + if (error?.statusCode === 404) { + return undefined; + } + throw error; + }); + + if (inspect) { + const currentContainerSpec = inspect.Spec.TaskTemplate.ContainerSpec; await service.update({ version: Number.parseInt(inspect.Version.Index, 10), ...settings, + TaskTemplate: { + ...settings.TaskTemplate, + ContainerSpec: { + ...containerSpec, + Image: currentContainerSpec.Image, + Mounts: currentContainerSpec.Mounts, + }, + }, }); console.log("Postgres Started ✅"); + return; + } + + try { + await pullImage(imageName); } catch (_) { - try { - await docker.createService(settings); - } catch (error: any) { - if (error?.statusCode !== 409) { - throw error; - } - console.log("Postgres service already exists, continuing..."); + // Offline hosts can still run an image that is already present locally. + console.warn( + `Could not pull ${imageName}; a local copy will be used if one exists.`, + ); + } + try { + await docker.createService(settings); + } catch (error: any) { + if (error?.statusCode !== 409) { + throw error; } - console.log("Postgres Not Found: Starting ✅"); + console.log("Postgres service already exists, continuing..."); } + console.log("Postgres Not Found: Starting ✅"); }; From 6fcd7c4f73ae40470f65946c4a2ec8f6c7c8bfc9 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:53:51 -0400 Subject: [PATCH 2/2] fix(setup): leave an existing Postgres service unchanged Updating an existing service replaced its whole container spec. On a service created by the installer that dropped the Docker secret and the password file variable, and the PGDATA variable pointed outside the volume of a PostgreSQL 18 service mounted at /var/lib/postgresql, which would start an empty cluster. The setup script now only creates the service when it is missing. PGDATA is set only in the create spec. Existing services are not touched, so nothing restarts. Tests cover an existing service with a secret and the 18 mount path (no update, no create, no pull), a lookup error other than not found, and a conflict on create. Co-Authored-By: Claude Sonnet 5.5 --- .../__test__/setup/postgres-setup.test.ts | 55 +++++++++++++------ packages/server/src/setup/postgres-setup.ts | 15 +---- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/apps/dokploy/__test__/setup/postgres-setup.test.ts b/apps/dokploy/__test__/setup/postgres-setup.test.ts index df80b295413..72610f23932 100644 --- a/apps/dokploy/__test__/setup/postgres-setup.test.ts +++ b/apps/dokploy/__test__/setup/postgres-setup.test.ts @@ -53,18 +53,11 @@ vi.mock("@dokploy/server/utils/docker/utils", () => ({ const serviceNotFound = () => Object.assign(new Error("service not found"), { statusCode: 404 }); -const existingMounts = [ - { - Type: "volume", - Source: "test-postgres-volume", - Target: "/var/lib/postgresql/data", - }, -]; - describe("initializePostgres", () => { beforeEach(() => { vi.clearAllMocks(); pullImageMock.mockResolvedValue(undefined); + createServiceMock.mockResolvedValue(undefined); inspectMock.mockRejectedValue(serviceNotFound()); }); @@ -102,27 +95,53 @@ describe("initializePostgres", () => { expect(spec?.Env).toContain("PGDATA=/var/lib/postgresql/data"); }); - it("keeps the existing image and mounts when the service already exists", async () => { + it("leaves an existing service unchanged, including its secrets and mounts", async () => { inspectMock.mockResolvedValue({ Version: { Index: 7 }, Spec: { TaskTemplate: { - ContainerSpec: { Image: "postgres:16", Mounts: existingMounts }, + ContainerSpec: { + Image: "postgres:16", + Env: ["POSTGRES_PASSWORD_FILE=/run/secrets/pg"], + Secrets: [{ SecretName: "pg", SecretID: "test-secret-id" }], + Mounts: [ + { + Type: "volume", + Source: "test-postgres-volume", + Target: "/var/lib/postgresql", + }, + ], + }, }, }, }); - await initializePostgres(); + await expect(initializePostgres()).resolves.toBeUndefined(); + + expect(updateMock).not.toHaveBeenCalled(); + expect(createServiceMock).not.toHaveBeenCalled(); + expect(pullImageMock).not.toHaveBeenCalled(); + }); + + it("rethrows an inspect error that is not a 404", async () => { + inspectMock.mockRejectedValue( + Object.assign(new Error("connection refused"), { statusCode: 500 }), + ); + + await expect(initializePostgres()).rejects.toThrow("connection refused"); expect(pullImageMock).not.toHaveBeenCalled(); expect(createServiceMock).not.toHaveBeenCalled(); - expect(updateMock).toHaveBeenCalledTimes(1); + }); - const options = updateMock.mock.calls[0]?.[0]; - const spec = options?.TaskTemplate?.ContainerSpec; - expect(options?.version).toBe(7); - expect(spec?.Image).toBe("postgres:16"); - expect(spec?.Mounts).toEqual(existingMounts); - expect(spec?.Env).toContain("PGDATA=/var/lib/postgresql/data"); + it("resolves when createService reports the service already exists (409)", async () => { + createServiceMock.mockRejectedValueOnce( + Object.assign(new Error("service already exists"), { statusCode: 409 }), + ); + + await expect(initializePostgres()).resolves.toBeUndefined(); + + expect(pullImageMock).toHaveBeenCalledWith("postgres:18.6"); + expect(createServiceMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/server/src/setup/postgres-setup.ts b/packages/server/src/setup/postgres-setup.ts index ca77a854335..9a05e075f7b 100644 --- a/packages/server/src/setup/postgres-setup.ts +++ b/packages/server/src/setup/postgres-setup.ts @@ -66,20 +66,7 @@ export const initializePostgres = async () => { }); if (inspect) { - const currentContainerSpec = inspect.Spec.TaskTemplate.ContainerSpec; - await service.update({ - version: Number.parseInt(inspect.Version.Index, 10), - ...settings, - TaskTemplate: { - ...settings.TaskTemplate, - ContainerSpec: { - ...containerSpec, - Image: currentContainerSpec.Image, - Mounts: currentContainerSpec.Mounts, - }, - }, - }); - console.log("Postgres Started ✅"); + console.log("Postgres service already exists, leaving it unchanged"); return; }