From e7755d8ae76d211b29aa0f5295865473911462b1 Mon Sep 17 00:00:00 2001 From: kopandante Date: Mon, 23 Feb 2026 18:38:00 +0900 Subject: [PATCH 1/2] feat: support buildServerId and docker source type for preview deployments Preview deployments now correctly use `buildServerId` (falling back to `serverId`) for build execution, log paths and deployment records -- matching the existing production `deployApplication` behavior. Additionally, preview deployments can now be created from pre-built Docker images via the new `deployFromImage` tRPC procedure, enabling external CI/CD pipelines (GitHub Actions, etc.) to push images and trigger preview deployments without building on the Dokploy server. Changes: - deployment.ts: resolve buildServerId for paths/exec/record - application.ts: handle docker sourceType in deploy/rebuild preview - preview-deployment.ts: extract preparePreview/insertAndConfigurePreview helpers, add createPreviewDeploymentFromImage - preview-deployments schema: add dockerImage column and Zod schema - router: add deployFromImage procedure with auth + BullMQ queue --- .../server/api/routers/preview-deployment.ts | 49 ++++++++- .../src/db/schema/preview-deployments.ts | 11 +- packages/server/src/services/application.ts | 25 ++++- packages/server/src/services/deployment.ts | 16 ++- .../server/src/services/preview-deployment.ts | 103 +++++++++++++----- 5 files changed, 162 insertions(+), 42 deletions(-) diff --git a/apps/dokploy/server/api/routers/preview-deployment.ts b/apps/dokploy/server/api/routers/preview-deployment.ts index 0c325a9c68..769aa20e18 100644 --- a/apps/dokploy/server/api/routers/preview-deployment.ts +++ b/apps/dokploy/server/api/routers/preview-deployment.ts @@ -1,4 +1,5 @@ import { + createPreviewDeploymentFromImage, findApplicationById, findPreviewDeploymentById, findPreviewDeploymentsByApplicationId, @@ -7,7 +8,10 @@ import { } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { apiFindAllByApplication } from "@/server/db/schema"; +import { + apiFindAllByApplication, + apiCreatePreviewDeploymentFromImage, +} from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; import { myQueue } from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; @@ -115,4 +119,47 @@ export const previewDeploymentRouter = createTRPCRouter({ ); return true; }), + deployFromImage: protectedProcedure + .input(apiCreatePreviewDeploymentFromImage) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + + const previewDeployment = await createPreviewDeploymentFromImage(input); + + const jobData: DeploymentJob = { + applicationId: input.applicationId, + titleLog: `Deploy Docker image: ${input.dockerImage}`, + descriptionLog: "", + type: "deploy", + applicationType: "application-preview", + previewDeploymentId: previewDeployment.previewDeploymentId, + server: !!application.serverId, + }; + + if (IS_CLOUD && application.serverId) { + jobData.serverId = application.serverId; + deploy(jobData).catch((error) => { + console.error("Background deployment failed:", error); + }); + return previewDeployment; + } + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + return previewDeployment; + }), }); diff --git a/packages/server/src/db/schema/preview-deployments.ts b/packages/server/src/db/schema/preview-deployments.ts index 3bdab2c250..54e62e5d12 100644 --- a/packages/server/src/db/schema/preview-deployments.ts +++ b/packages/server/src/db/schema/preview-deployments.ts @@ -33,6 +33,7 @@ export const previewDeployments = pgTable("preview_deployments", { domainId: text("domainId").references(() => domains.domainId, { onDelete: "cascade", }), + dockerImage: text("dockerImage"), createdAt: text("createdAt") .notNull() .$defaultFn(() => new Date().toISOString()), @@ -67,8 +68,16 @@ export const apiCreatePreviewDeployment = createSchema pullRequestNumber: true, pullRequestURL: true, pullRequestTitle: true, + dockerImage: true, }) .extend({ applicationId: z.string().min(1), - // deploymentId: z.string().min(1), }); + +export const apiCreatePreviewDeploymentFromImage = z.object({ + applicationId: z.string().min(1), + dockerImage: z.string().min(1), + pullRequestNumber: z.string().optional(), + pullRequestTitle: z.string().optional(), + pullRequestURL: z.string().optional(), +}); diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 458ab34f5b..f8551b73ec 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -422,6 +422,8 @@ export const deployPreviewApplication = async ({ application.rollbackRegistry = null; application.registry = null; + const buildServerId = + application.buildServerId || application.serverId; let command = "set -e;"; if (application.sourceType === "github") { command += await cloneGithubRepository({ @@ -430,10 +432,17 @@ export const deployPreviewApplication = async ({ branch: previewDeployment.branch, }); command += await getBuildCommand(application); + } else if (application.sourceType === "docker") { + if (previewDeployment.dockerImage) { + application.dockerImage = previewDeployment.dockerImage; + } + command += await buildRemoteDocker(application); + } + if (application.sourceType === "github" || application.sourceType === "docker") { const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; - if (application.serverId) { - await execAsyncRemote(application.serverId, commandWithLog); + if (buildServerId) { + await execAsyncRemote(buildServerId, commandWithLog); } else { await execAsync(commandWithLog); } @@ -541,10 +550,16 @@ export const rebuildPreviewApplication = async ({ application.rollbackRegistry = null; application.registry = null; - const serverId = application.serverId; + const serverId = application.buildServerId || application.serverId; let command = "set -e;"; - // Only rebuild, don't clone repository - command += await getBuildCommand(application); + if (application.sourceType === "docker") { + if (previewDeployment.dockerImage) { + application.dockerImage = previewDeployment.dockerImage; + } + command += await buildRemoteDocker(application); + } else { + command += await getBuildCommand(application); + } const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; if (serverId) { await execAsyncRemote(serverId, commandWithLog); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 24e1590a98..0b44d928df 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -161,6 +161,10 @@ export const createDeploymentPreview = async ( deployment.previewDeploymentId, ); try { + const buildServerId = + previewDeployment?.application?.buildServerId || + previewDeployment?.application?.serverId; + await removeLastTenDeployments( deployment.previewDeploymentId, "previewDeployment", @@ -168,15 +172,13 @@ export const createDeploymentPreview = async ( ); const appName = `${previewDeployment.appName}`; - const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId); + const { LOGS_PATH } = paths(!!buildServerId); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const fileName = `${appName}-${formattedDateTime}.log`; const logFilePath = path.join(LOGS_PATH, appName, fileName); - if (previewDeployment?.application?.serverId) { - const server = await findServerById( - previewDeployment?.application?.serverId, - ); + if (buildServerId) { + const server = await findServerById(buildServerId); const command = ` mkdir -p ${LOGS_PATH}/${appName}; @@ -200,6 +202,10 @@ export const createDeploymentPreview = async ( description: deployment.description || "", previewDeploymentId: deployment.previewDeploymentId, startedAt: new Date().toISOString(), + ...(previewDeployment?.application?.buildServerId && { + buildServerId: + previewDeployment.application.buildServerId, + }), }) .returning(); if (deploymentCreate.length === 0 || !deploymentCreate[0]) { diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 1ece3bc539..1f63a73371 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -1,6 +1,7 @@ import { db } from "@dokploy/server/db"; import { type apiCreatePreviewDeployment, + type apiCreatePreviewDeploymentFromImage, deployments, organization, previewDeployments, @@ -97,7 +98,7 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => { }); } }; -// testing-tesoitnmg-ddq0ul-preview-ihl44o + export const updatePreviewDeployment = async ( previewDeploymentId: string, previewDeploymentData: Partial, @@ -129,44 +130,39 @@ export const findPreviewDeploymentsByApplicationId = async ( return deploymentsList; }; -export const createPreviewDeployment = async ( - schema: typeof apiCreatePreviewDeployment._type, -) => { - const application = await findApplicationById(schema.applicationId); +/** + * Generates appName, resolves organization and wildcard domain for a preview. + * Returns all data needed before DB insert (callers may need `host` for GitHub comments). + */ +const preparePreview = async (applicationId: string) => { + const application = await findApplicationById(applicationId); const appName = `preview-${application.appName}-${generatePassword(6)}`; const org = await db.query.organization.findFirst({ where: eq(organization.id, application.environment.project.organizationId), }); - const generateDomain = await generateWildcardDomain( + const host = await generateWildcardDomain( application.previewWildcard || "*.traefik.me", appName, application.server?.ipAddress || "", org?.ownerId || "", ); - const octokit = authGithub(application?.github as Github); - - const runningComment = getIssueComment( - application.name, - "initializing", - `${application.previewHttps ? "https" : "http"}://${generateDomain}`, - ); - - const issue = await octokit.rest.issues.createComment({ - owner: application?.owner || "", - repo: application?.repository || "", - issue_number: Number.parseInt(schema.pullRequestNumber), - body: `### Dokploy Preview Deployment\n\n${runningComment}`, - }); + return { application, appName, host }; +}; +/** + * Inserts a preview deployment record and sets up domain + Traefik routing. + */ +const insertAndConfigurePreview = async ( + application: Awaited>, + appName: string, + host: string, + values: typeof previewDeployments.$inferInsert, +) => { const previewDeployment = await db .insert(previewDeployments) - .values({ - ...schema, - appName: appName, - pullRequestCommentId: `${issue.data.id}`, - }) + .values(values) .returning() .then((value) => value[0]); @@ -178,7 +174,7 @@ export const createPreviewDeployment = async ( } const newDomain = await createDomain({ - host: generateDomain, + host, path: application.previewPath, port: application.previewPort, https: application.previewHttps, @@ -189,14 +185,11 @@ export const createPreviewDeployment = async ( }); application.appName = appName; - await manageDomain(application, newDomain); await db .update(previewDeployments) - .set({ - domainId: newDomain.domainId, - }) + .set({ domainId: newDomain.domainId }) .where( eq( previewDeployments.previewDeploymentId, @@ -207,6 +200,56 @@ export const createPreviewDeployment = async ( return previewDeployment; }; +export const createPreviewDeployment = async ( + schema: typeof apiCreatePreviewDeployment._type, +) => { + const { application, appName, host } = await preparePreview( + schema.applicationId, + ); + + const octokit = authGithub(application?.github as Github); + + const runningComment = getIssueComment( + application.name, + "initializing", + `${application.previewHttps ? "https" : "http"}://${host}`, + ); + + const issue = await octokit.rest.issues.createComment({ + owner: application?.owner || "", + repo: application?.repository || "", + issue_number: Number.parseInt(schema.pullRequestNumber), + body: `### Dokploy Preview Deployment\n\n${runningComment}`, + }); + + return insertAndConfigurePreview(application, appName, host, { + ...schema, + appName, + pullRequestCommentId: `${issue.data.id}`, + }); +}; + +export const createPreviewDeploymentFromImage = async ( + schema: typeof apiCreatePreviewDeploymentFromImage._type, +) => { + const { application, appName, host } = await preparePreview( + schema.applicationId, + ); + + return insertAndConfigurePreview(application, appName, host, { + applicationId: schema.applicationId, + appName, + branch: "main", + pullRequestId: schema.pullRequestNumber || "", + pullRequestNumber: schema.pullRequestNumber || "", + pullRequestURL: schema.pullRequestURL || "", + pullRequestTitle: + schema.pullRequestTitle || `Docker image: ${schema.dockerImage}`, + pullRequestCommentId: "", + dockerImage: schema.dockerImage, + }); +}; + export const findPreviewDeploymentsByPullRequestId = async ( pullRequestId: string, ) => { From 23fcb3719fa0661536a4f1002a4c0280de67ed83 Mon Sep 17 00:00:00 2001 From: kopandante Date: Tue, 24 Feb 2026 13:02:29 +0900 Subject: [PATCH 2/2] fix: preserve buildRegistry for preview deployments with external build server When an application uses a separate build server (buildServerId != serverId), the build registry is needed to push the built image so the deploy server can pull it. Preview deployments were unconditionally nulling buildRegistry, which broke the image push and Swarm auth for external build server setups. Now buildRegistry is only nulled when there is no separate build server (matching how regular deployApplication works). Also fix dockerode createService auth: pass authconfig as first argument since createService(opts) doesn't auto-extract it (unlike service.update). --- packages/server/src/services/application.ts | 8 ++++++-- packages/server/src/utils/builders/index.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index f8551b73ec..8232beb8ff 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -418,7 +418,9 @@ export const deployPreviewApplication = async ({ application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.rollbackActive = false; - application.buildRegistry = null; + if (!application.buildServerId || application.buildServerId === application.serverId) { + application.buildRegistry = null; + } application.rollbackRegistry = null; application.registry = null; @@ -546,7 +548,9 @@ export const rebuildPreviewApplication = async ({ application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.rollbackActive = false; - application.buildRegistry = null; + if (!application.buildServerId || application.buildServerId === application.serverId) { + application.buildRegistry = null; + } application.rollbackRegistry = null; application.registry = null; diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index 5bf3a790e0..e32a9cc479 100644 --- a/packages/server/src/utils/builders/index.ts +++ b/packages/server/src/utils/builders/index.ts @@ -182,7 +182,7 @@ export const mechanizeDockerContainer = async ( }); } catch (error) { console.log(error); - await docker.createService(settings); + await docker.createService(settings.authconfig, settings); } };