From 9176545ec7536718d15997548dc98afd3b9c638a Mon Sep 17 00:00:00 2001 From: fidelis05 <100060822+fidelis05@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:24:34 -0300 Subject: [PATCH 1/2] feat(preview): support ${prNumber} variable in preview wildcard domain Preview deployments generate their domain by substituting the app's "Wildcard Domain" setting. Previously the only supported token was the "*" wildcard, which is replaced with a hash that always includes a random suffix, so the resulting domain is not predictable ahead of time. Add a ${prNumber} template variable, already available from the pull request that triggers the deployment, so users can build a domain that is deterministic per PR (e.g. frontend-pr${prNumber}.example.com). This lets a sibling service in the same PR reference this app's preview URL before it has been deployed. ${prNumber} can be combined with "*" to keep per-app uniqueness (e.g. *.pr-${prNumber}.example.com). The wildcard generator is renamed to generatePreviewWildcardDomain and exported (it previously collided with an unrelated generateWildcardDomain in services/domain.ts) so its pure substitution logic can be unit tested. Co-Authored-By: Claude Opus 4.8 --- .../preview/generate-wildcard-domain.test.ts | 111 ++++++++++++++++++ .../show-preview-settings.tsx | 27 ++++- .../server/src/services/preview-deployment.ts | 30 +++-- 3 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 apps/dokploy/__test__/preview/generate-wildcard-domain.test.ts diff --git a/apps/dokploy/__test__/preview/generate-wildcard-domain.test.ts b/apps/dokploy/__test__/preview/generate-wildcard-domain.test.ts new file mode 100644 index 0000000000..22daaeb4af --- /dev/null +++ b/apps/dokploy/__test__/preview/generate-wildcard-domain.test.ts @@ -0,0 +1,111 @@ +import { generatePreviewWildcardDomain } from "@dokploy/server/index"; +import { describe, expect, it } from "vitest"; + +const appName = "preview-app-abc123"; + +describe("generatePreviewWildcardDomain", () => { + describe("${prNumber} templating", () => { + it("substitutes ${prNumber} into a deterministic domain (no wildcard)", async () => { + const domain = await generatePreviewWildcardDomain( + "pr-${prNumber}.example.com", + appName, + "", + "", + "42", + ); + expect(domain).toBe("pr-42.example.com"); + }); + + it("replaces every ${prNumber} occurrence", async () => { + const domain = await generatePreviewWildcardDomain( + "pr-${prNumber}.env-${prNumber}.example.com", + appName, + "", + "", + "42", + ); + expect(domain).toBe("pr-42.env-42.example.com"); + }); + + it("combines ${prNumber} with the * wildcard (PR scope + app hash)", async () => { + const domain = await generatePreviewWildcardDomain( + "*.pr-${prNumber}.example.com", + appName, + "", + "", + "42", + ); + expect(domain).toBe(`${appName}.pr-42.example.com`); + }); + + it("produces distinct domains for two apps sharing a wildcard pattern", async () => { + const pattern = "*.pr-${prNumber}.example.com"; + const frontend = await generatePreviewWildcardDomain( + pattern, + "preview-frontend-aaa", + "", + "", + "7", + ); + const backend = await generatePreviewWildcardDomain( + pattern, + "preview-backend-bbb", + "", + "", + "7", + ); + expect(frontend).toBe("preview-frontend-aaa.pr-7.example.com"); + expect(backend).toBe("preview-backend-bbb.pr-7.example.com"); + expect(frontend).not.toBe(backend); + }); + }); + + describe("legacy wildcard behavior (backward compatibility)", () => { + it("substitutes the app hash and slugified IP for *.sslip.io", async () => { + const domain = await generatePreviewWildcardDomain( + "*.sslip.io", + appName, + "1.2.3.4", + "", + "42", + ); + expect(domain).toBe(`${appName}-1-2-3-4.sslip.io`); + }); + + it("ignores the pull request number when the wildcard has no ${prNumber} token", async () => { + const domain = await generatePreviewWildcardDomain( + "*.sslip.io", + appName, + "1.2.3.4", + "", + "42", + ); + expect(domain).toBe(`${appName}-1-2-3-4.sslip.io`); + }); + + it("substitutes the app hash for a non-sslip wildcard domain", async () => { + const domain = await generatePreviewWildcardDomain( + "*.example.com", + appName, + "1.2.3.4", + "", + "42", + ); + expect(domain).toBe(`${appName}.example.com`); + }); + }); + + describe("validation errors", () => { + it("throws when the domain has neither * nor ${prNumber}", async () => { + await expect( + generatePreviewWildcardDomain("static.example.com", appName, "", "", "42"), + ).rejects.toThrow(/must include "\*" or the "\$\{prNumber\}" variable/); + }); + + it("throws when a wildcard domain does not start with *.", async () => { + await expect( + generatePreviewWildcardDomain("foo*.example.com", appName, "", "", "42"), + ).rejects.toThrow(/must start with "\*\."/); + }); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx index 1d8dba4821..a3eb179783 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -190,7 +190,32 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { name="wildcardDomain" render={({ field }) => ( - Wildcard Domain +
+ Wildcard Domain + + + + + + +

+ Use ${"{prNumber}"} to build a + domain that's deterministic per pull request + instead of a random one, e.g.{" "} + frontend-pr${"{prNumber}"}.example.com + . Useful when another service in the same PR + needs to reference this app's preview URL + ahead of time. Give each application its own + prefix (like frontend- /{" "} + backend- above) — two apps using + the same ${"{prNumber}"} pattern + will collide on the same domain for a given + PR. +

+
+
+
+
diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 20f64259bc..55ae4343a2 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -135,11 +135,12 @@ export const createPreviewDeployment = async ( const org = await db.query.organization.findFirst({ where: eq(organization.id, application.environment.project.organizationId), }); - const generateDomain = await generateWildcardDomain( + const generateDomain = await generatePreviewWildcardDomain( application.previewWildcard || "*.sslip.io", appName, application.server?.ipAddress || "", org?.ownerId || "", + schema.pullRequestNumber, ); const octokit = authGithub(application?.github as Github); @@ -228,17 +229,32 @@ export const findPreviewDeploymentByApplicationId = async ( return previewDeploymentResult; }; -const generateWildcardDomain = async ( +export const generatePreviewWildcardDomain = async ( baseDomain: string, appName: string, serverIp: string, _userId: string, + pullRequestNumber: string, ): Promise => { - if (!baseDomain.startsWith("*.")) { - throw new Error('The base domain must start with "*."'); + if (!baseDomain.includes("*") && !baseDomain.includes("${prNumber}")) { + throw new Error( + 'The domain must include "*" or the "${prNumber}" variable, otherwise every pull request would collide on the same domain.', + ); + } + + const domain = baseDomain.replaceAll("${prNumber}", pullRequestNumber); + + if (!domain.includes("*")) { + return domain; + } + + if (!domain.startsWith("*.")) { + throw new Error( + 'The base domain must start with "*." or use the "${prNumber}" variable without a wildcard.', + ); } const hash = `${appName}`; - if (baseDomain.includes("sslip.io")) { + if (domain.includes("sslip.io")) { let ip = ""; if (process.env.NODE_ENV === "development") { @@ -255,11 +271,11 @@ const generateWildcardDomain = async ( } const slugIp = ip.replaceAll(".", "-"); - return baseDomain.replace( + return domain.replace( "*", `${hash}${slugIp === "" ? "" : `-${slugIp}`}`, ); } - return baseDomain.replace("*", hash); + return domain.replace("*", hash); }; From b258e6474ae678d38c0cd5923f28a89b6fd64176 Mon Sep 17 00:00:00 2001 From: fidelis05 <100060822+fidelis05@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:26:02 -0300 Subject: [PATCH 2/2] feat(preview): resolve ${{preview.prNumber}} in preview env and build args When a preview deployment is built, previewEnv / previewBuildArgs / previewBuildSecrets are copied onto the application before the build runs. Add resolution of a ${{preview.prNumber}} token in those three strings, matching the existing ${{project.*}} / ${{environment.*}} double-brace convention. Combined with the ${prNumber} wildcard-domain support, this lets one service point at another's deterministic preview URL for the same PR, e.g. VITE_API_URL=https://backend-pr${{preview.prNumber}}.example.com on a frontend, without knowing the URL in advance. The substitution is applied in deployPreviewApplication / rebuildPreviewApplication before prepareEnvironmentVariables runs, rather than extending that shared resolver, since prepareEnvironmentVariables is also used by non-preview deployments, compose and databases and does not know this preview-only token. --- .../show-preview-settings.tsx | 16 ++++++++- packages/server/src/services/application.ts | 35 +++++++++++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx index a3eb179783..550fafd20c 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -491,7 +491,21 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { + You can add environment variables to your + resource. Use{" "} + {"${{preview.prNumber}}"} to + reference the pull request number, e.g. to + point at another service's deterministic + preview domain ( + + VITE_API_URL=https://backend-pr$ + {"{{preview.prNumber}}"}.example.com + + ). + + } placeholder={[ "NODE_ENV=production", "PORT=3000", diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index c8ebb3be77..3abcb04df9 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -345,6 +345,11 @@ export const rebuildApplication = async ({ return true; }; +const resolvePreviewTemplateVariables = ( + value: string, + pullRequestNumber: string, +) => value.replaceAll("${{preview.prNumber}}", pullRequestNumber); + export const deployPreviewApplication = async ({ applicationId, titleLog = "Preview Deployment", @@ -411,9 +416,18 @@ export const deployPreviewApplication = async ({ body: `### Dokploy Preview Deployment\n\n${buildingComment}`, }); application.appName = previewDeployment.appName; - application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; - application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; - application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; + application.env = resolvePreviewTemplateVariables( + `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`, + previewDeployment.pullRequestNumber, + ); + application.buildArgs = resolvePreviewTemplateVariables( + `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`, + previewDeployment.pullRequestNumber, + ); + application.buildSecrets = resolvePreviewTemplateVariables( + `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`, + previewDeployment.pullRequestNumber, + ); application.rollbackActive = false; application.buildRegistry = null; application.rollbackRegistry = null; @@ -530,9 +544,18 @@ export const rebuildPreviewApplication = async ({ // Set application properties for preview deployment application.appName = previewDeployment.appName; - application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; - application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; - application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; + application.env = resolvePreviewTemplateVariables( + `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`, + previewDeployment.pullRequestNumber, + ); + application.buildArgs = resolvePreviewTemplateVariables( + `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`, + previewDeployment.pullRequestNumber, + ); + application.buildSecrets = resolvePreviewTemplateVariables( + `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`, + previewDeployment.pullRequestNumber, + ); application.rollbackActive = false; application.buildRegistry = null; application.rollbackRegistry = null;