Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/dokploy/__test__/preview/generate-wildcard-domain.test.ts
Original file line number Diff line number Diff line change
@@ -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 "\*\."/);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,32 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
name="wildcardDomain"
render={({ field }) => (
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<div className="flex items-center gap-2">
<FormLabel>Wildcard Domain</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Use <code>${"{prNumber}"}</code> to build a
domain that's deterministic per pull request
instead of a random one, e.g.{" "}
<code>frontend-pr${"{prNumber}"}.example.com</code>
. 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 <code>frontend-</code> /{" "}
<code>backend-</code> above) — two apps using
the same <code>${"{prNumber}"}</code> pattern
will collide on the same domain for a given
PR.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input placeholder="*.sslip.io" {...field} />
</FormControl>
Expand Down Expand Up @@ -466,7 +491,21 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<Secrets
name="env"
title="Environment Settings"
description="You can add environment variables to your resource."
description={
<span>
You can add environment variables to your
resource. Use{" "}
<code>{"${{preview.prNumber}}"}</code> to
reference the pull request number, e.g. to
point at another service's deterministic
preview domain (
<code>
VITE_API_URL=https://backend-pr$
{"{{preview.prNumber}}"}.example.com
</code>
).
</span>
}
placeholder={[
"NODE_ENV=production",
"PORT=3000",
Expand Down
35 changes: 29 additions & 6 deletions packages/server/src/services/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 23 additions & 7 deletions packages/server/src/services/preview-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string> => {
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") {
Expand All @@ -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);
};