From a86994ef0d9bfee3cf44b4ed5a5fe5abfe45bf01 Mon Sep 17 00:00:00 2001 From: Jay Simons Date: Fri, 21 Aug 2026 20:37:48 -0500 Subject: [PATCH 1/6] feat(compose): support registry images and prebuilt compose deploys. Adds compose registry configuration, build pipeline updates, and migration for stored image metadata. Co-authored-by: Cursor --- .../compose/build-compose-command.test.ts | 29 ++ .../compose/build-registry-images.test.ts | 86 +++++ .../compose/create-command-prebuilt.test.ts | 35 ++ .../advanced/show-build-server.tsx | 113 ++++-- .../deployments/show-deployments-table.tsx | 11 +- apps/dokploy/drizzle/0191_sharp_arclight.sql | 4 + ...{0188_snapshot.json => 0191_snapshot.json} | 51 ++- apps/dokploy/drizzle/meta/_journal.json | 7 + .../services/application/[applicationId].tsx | 5 +- .../services/compose/[composeId].tsx | 5 + apps/dokploy/server/api/routers/compose.ts | 24 +- packages/server/src/db/schema/compose.ts | 21 ++ packages/server/src/db/schema/registry.ts | 4 + packages/server/src/services/compose.ts | 332 ++++++++++++------ packages/server/src/services/deployment.ts | 18 +- packages/server/src/utils/builders/compose.ts | 164 ++++++++- packages/server/src/utils/docker/domain.ts | 48 ++- 17 files changed, 786 insertions(+), 171 deletions(-) create mode 100644 apps/dokploy/__test__/compose/build-registry-images.test.ts create mode 100644 apps/dokploy/__test__/compose/create-command-prebuilt.test.ts create mode 100644 apps/dokploy/drizzle/0191_sharp_arclight.sql rename apps/dokploy/drizzle/meta/{0188_snapshot.json => 0191_snapshot.json} (99%) diff --git a/apps/dokploy/__test__/compose/build-compose-command.test.ts b/apps/dokploy/__test__/compose/build-compose-command.test.ts index 0aa2bc2d9..7a8a389bf 100644 --- a/apps/dokploy/__test__/compose/build-compose-command.test.ts +++ b/apps/dokploy/__test__/compose/build-compose-command.test.ts @@ -7,6 +7,17 @@ vi.mock("@dokploy/server/utils/docker/domain", () => ({ writeDomainsToCompose: vi.fn().mockResolvedValue(""), })); +vi.mock("@dokploy/server/services/registry", () => ({ + findRegistryByIdWithCredentials: vi.fn().mockResolvedValue({ + registryUrl: "registry.example.com", + username: "user", + password: "pass", + }), + safeDockerLoginCommand: vi + .fn() + .mockReturnValue('echo "docker login stub"'), +})); + const baseCompose = { appName: "my-app", sourceType: "raw", @@ -17,6 +28,8 @@ const baseCompose = { randomize: false, suffix: "", serverId: null, + buildServerId: null, + buildRegistryId: null, env: "", mounts: [], domains: [], @@ -49,4 +62,20 @@ describe("getBuildComposeCommand registry auth (#4401)", () => { expect(command).toContain("compose -p my-app"); expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"'); }); + + it("uses prebuilt pull/up when buildServerId is set", async () => { + const command = await getBuildComposeCommand( + { + ...baseCompose, + composeType: "docker-compose", + buildServerId: "build-server-1", + buildRegistryId: "registry-1", + }, + { prebuilt: true }, + ); + + expect(command).toContain("pull"); + expect(command).toContain("--no-build"); + expect(command).toContain("docker login stub"); + }); }); diff --git a/apps/dokploy/__test__/compose/build-registry-images.test.ts b/apps/dokploy/__test__/compose/build-registry-images.test.ts new file mode 100644 index 000000000..726d10b26 --- /dev/null +++ b/apps/dokploy/__test__/compose/build-registry-images.test.ts @@ -0,0 +1,86 @@ +import { + applyBuildRegistryImages, + hasBuildableServices, +} from "@dokploy/server/utils/docker/domain"; +import type { ComposeSpecification } from "@dokploy/server/utils/docker/types"; +import type { Registry } from "@dokploy/server/services/registry"; +import { describe, expect, it } from "vitest"; + +const registry = { + registryId: "reg-1", + registryName: "Test Registry", + registryUrl: "registry.example.com", + username: "user", + password: "pass", + imagePrefix: "myorg", + registryType: "cloud" as const, + organizationId: "org-1", + createdAt: new Date().toISOString(), +}; + +describe("applyBuildRegistryImages", () => { + it("rewrites image tags for services with build:", () => { + const spec: ComposeSpecification = { + services: { + api: { + build: "./api", + image: "local-api:latest", + }, + db: { + image: "postgres:16", + }, + }, + }; + + const result = applyBuildRegistryImages(spec, registry, "my-compose"); + + expect(result.services?.api?.image).toBe( + "registry.example.com/myorg/my-compose-api:latest", + ); + expect(result.services?.api?.build).toBe("./api"); + expect(result.services?.db?.image).toBe("postgres:16"); + }); + + it("lowercases and sanitizes service names in image tags", () => { + const spec: ComposeSpecification = { + services: { + "MyService": { + build: ".", + }, + }, + }; + + const result = applyBuildRegistryImages( + spec, + registry, + "App-Name", + ); + + expect(result.services?.MyService?.image).toBe( + "registry.example.com/myorg/app-name-myservice:latest", + ); + }); +}); + +describe("hasBuildableServices", () => { + it("returns true when at least one service has build:", () => { + expect( + hasBuildableServices({ + services: { + web: { image: "nginx" }, + api: { build: "." }, + }, + }), + ).toBe(true); + }); + + it("returns false when no service has build:", () => { + expect( + hasBuildableServices({ + services: { + db: { image: "postgres:16" }, + }, + }), + ).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/compose/create-command-prebuilt.test.ts b/apps/dokploy/__test__/compose/create-command-prebuilt.test.ts new file mode 100644 index 000000000..3faf1e240 --- /dev/null +++ b/apps/dokploy/__test__/compose/create-command-prebuilt.test.ts @@ -0,0 +1,35 @@ +import { createCommand } from "@dokploy/server/utils/builders/compose"; +import { describe, expect, it } from "vitest"; + +const base = { + composeType: "docker-compose" as const, + appName: "my-app", + sourceType: "raw" as const, + command: "", + composePath: "docker-compose.yml", + buildServerId: null, +} as unknown as Parameters[0]; + +describe("createCommand prebuilt remote build", () => { + it("uses --build when not prebuilt", () => { + const command = createCommand(base, { prebuilt: false }); + expect(command).toContain("up -d --build --remove-orphans"); + expect(command).not.toContain("--no-build"); + }); + + it("uses pull and --no-build when prebuilt", () => { + const command = createCommand(base, { prebuilt: true }); + expect(command).toContain("pull"); + expect(command).toContain("up -d --no-build --remove-orphans"); + expect(command).not.toContain("--build"); + }); + + it("keeps stack deploy with registry auth when prebuilt", () => { + const command = createCommand( + { ...base, composeType: "stack" }, + { prebuilt: true }, + ); + expect(command).toContain("stack deploy"); + expect(command).toContain("--with-registry-auth"); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx b/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx index eaeafde1a..8ba02c95b 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-build-server.tsx @@ -34,9 +34,17 @@ import { } from "@/components/ui/select"; import { api } from "@/utils/api"; -interface Props { +type ApplicationProps = { + serviceType: "application"; applicationId: string; -} +}; + +type ComposeProps = { + serviceType: "compose"; + composeId: string; +}; + +type Props = ApplicationProps | ComposeProps; const schema = z .object({ @@ -45,13 +53,11 @@ const schema = z }) .refine( (data) => { - // Both empty/none is valid const buildServerIsNone = !data.buildServerId || data.buildServerId === "none"; const buildRegistryIsNone = !data.buildRegistryId || data.buildRegistryId === "none"; - // Both should be either filled or empty if (buildServerIsNone && buildRegistryIsNone) return true; if (!buildServerIsNone && !buildRegistryIsNone) return true; @@ -60,21 +66,42 @@ const schema = z { message: "Both Build Server and Build Registry must be selected together, or both set to None", - path: ["buildServerId"], // Show error on buildServerId field + path: ["buildServerId"], }, ); type Schema = z.infer; -export const ShowBuildServer = ({ applicationId }: Props) => { - const { data, refetch } = api.application.one.useQuery( - { applicationId }, - { enabled: !!applicationId }, +export const ShowBuildServer = (props: Props) => { + const isCompose = props.serviceType === "compose"; + const serviceId = isCompose ? props.composeId : props.applicationId; + + const applicationQuery = api.application.one.useQuery( + { applicationId: props.serviceType === "application" ? props.applicationId : "" }, + { enabled: props.serviceType === "application" && !!props.applicationId }, ); + const composeQuery = api.compose.one.useQuery( + { composeId: props.serviceType === "compose" ? props.composeId : "" }, + { enabled: props.serviceType === "compose" && !!props.composeId }, + ); + + const data = + props.serviceType === "application" + ? applicationQuery.data + : composeQuery.data; + const refetch = + props.serviceType === "application" + ? applicationQuery.refetch + : composeQuery.refetch; + const { data: buildServers } = api.server.buildServers.useQuery(); const { data: registries } = api.registry.all.useQuery(); - const { mutateAsync, isPending } = api.application.update.useMutation(); + const updateApplication = api.application.update.useMutation(); + const updateCompose = api.compose.update.useMutation(); + const isPending = isCompose + ? updateCompose.isPending + : updateApplication.isPending; const form = useForm({ defaultValues: { @@ -94,24 +121,34 @@ export const ShowBuildServer = ({ applicationId }: Props) => { }, [form, form.reset, data]); const onSubmit = async (formData: Schema) => { - await mutateAsync({ - applicationId, - buildServerId: - formData?.buildServerId === "none" || !formData?.buildServerId - ? null - : formData?.buildServerId, - buildRegistryId: - formData?.buildRegistryId === "none" || !formData?.buildRegistryId - ? null - : formData?.buildRegistryId, - }) - .then(async () => { - toast.success("Build Server Settings Updated"); - await refetch(); - }) - .catch(() => { - toast.error("Error updating build server settings"); - }); + const buildServerId = + formData?.buildServerId === "none" || !formData?.buildServerId + ? null + : formData?.buildServerId; + const buildRegistryId = + formData?.buildRegistryId === "none" || !formData?.buildRegistryId + ? null + : formData?.buildRegistryId; + + try { + if (isCompose) { + await updateCompose.mutateAsync({ + composeId: serviceId, + buildServerId, + buildRegistryId, + }); + } else { + await updateApplication.mutateAsync({ + applicationId: serviceId, + buildServerId, + buildRegistryId, + }); + } + toast.success("Build Server Settings Updated"); + await refetch(); + } catch { + toast.error("Error updating build server settings"); + } }; return ( @@ -122,7 +159,8 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
Build Server - Configure a dedicated server for building your application. + Configure a dedicated server for building your{" "} + {isCompose ? "compose service" : "application"}.
@@ -130,10 +168,19 @@ export const ShowBuildServer = ({ applicationId }: Props) => { Build servers offload the build process from your deployment servers. - Select a build server and registry to use for building your - application. + Select a build server and registry to use for building your service. + {isCompose ? ( + + For compose services, only services with a{" "} + build: section are built on the + build server and pushed to the registry. Image-only services are + unchanged. Custom deploy commands should not include{" "} + --build when using a build server. + + ) : null} + 📊 Important: Once the build finishes, you'll need to wait a few seconds for the deployment server to download the image. @@ -175,7 +222,6 @@ export const ShowBuildServer = ({ applicationId }: Props) => { Select a build server to handle the build process for this - application. + service. @@ -231,7 +277,6 @@ export const ShowBuildServer = ({ applicationId }: Props) => {