From 824789b39ba024b6fad2092555e7a6c816b2c638 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 4 May 2026 16:20:52 +0200 Subject: [PATCH 01/15] feat: add template variable interpolation for preview deployment subdomains Support ${prNumber}, ${branchName}, ${appName}, and ${uniqueId} variables in the preview wildcard domain field. When ${prNumber} or ${branchName} is present, no random suffix is appended since these already provide uniqueness. Falls back to existing behavior when no template variables are used. Closes #4283 --- .../server/src/services/preview-deployment.ts | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 1556afbc4a..ccdeca1b8c 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -126,21 +126,67 @@ export const findPreviewDeploymentsByApplicationId = async ( return deploymentsList; }; +const slugify = (value: string): string => { + return value + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 63); +}; + +export const interpolateSubdomainTemplate = ( + template: string, + vars: { + appName: string; + prNumber: string; + branchName: string; + uniqueId: string; + }, +): string => { + return template + .replace(/\$\{appName\}/g, vars.appName) + .replace(/\$\{prNumber\}/g, vars.prNumber) + .replace(/\$\{branchName\}/g, slugify(vars.branchName)) + .replace(/\$\{uniqueId\}/g, vars.uniqueId); +}; + export const createPreviewDeployment = async ( schema: z.infer, ) => { const application = await findApplicationById(schema.applicationId); - const appName = `preview-${application.appName}-${generatePassword(6)}`; + const uniqueId = generatePassword(6); + const domainTemplate = application.previewWildcard || "*.traefik.me"; - const org = await db.query.organization.findFirst({ - where: eq(organization.id, application.environment.project.organizationId), - }); - const generateDomain = await generateWildcardDomain( - application.previewWildcard || "*.traefik.me", - appName, - application.server?.ipAddress || "", - org?.ownerId || "", - ); + const hasIdentifier = + domainTemplate.includes("${prNumber}") || + domainTemplate.includes("${branchName}") || + domainTemplate.includes("${uniqueId}"); + + let appName: string; + let generateDomain: string; + + if (hasIdentifier) { + const interpolated = interpolateSubdomainTemplate(domainTemplate, { + appName: application.appName, + prNumber: schema.pullRequestNumber, + branchName: schema.branch, + uniqueId, + }); + generateDomain = interpolated.replace("*", application.appName); + appName = `preview-${application.appName}-${uniqueId}`; + } else { + appName = `preview-${application.appName}-${uniqueId}`; + const org = await db.query.organization.findFirst({ + where: eq(organization.id, application.environment.project.organizationId), + }); + generateDomain = await generateWildcardDomain( + domainTemplate, + appName, + application.server?.ipAddress || "", + org?.ownerId || "", + ); + } const octokit = authGithub(application?.github as Github); From 26440e8fa1e556f7400fd7e41a652e34e2d22922 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 4 May 2026 16:21:00 +0200 Subject: [PATCH 02/15] feat: add preview domain template UI with live preview and tests Replace the Wildcard Domain field with a Preview Domain Template field that shows available template variables and a live example of the resulting URL. Add unit tests for the interpolation logic covering slugification, DNS length limits, and variable replacement. --- .../preview-deployment/template.test.ts | 83 ++++++++++++++++++ .../show-preview-settings.tsx | 85 ++++++++++++++----- 2 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 apps/dokploy/__test__/preview-deployment/template.test.ts diff --git a/apps/dokploy/__test__/preview-deployment/template.test.ts b/apps/dokploy/__test__/preview-deployment/template.test.ts new file mode 100644 index 0000000000..38e993c566 --- /dev/null +++ b/apps/dokploy/__test__/preview-deployment/template.test.ts @@ -0,0 +1,83 @@ +import { interpolateSubdomainTemplate } from "../../../../packages/server/src/services/preview-deployment"; +import { expect, test } from "vitest"; + +const baseVars = { + appName: "my-app", + prNumber: "123", + branchName: "feature/login-page", + uniqueId: "abc123", +}; + +test("replaces ${prNumber} variable", () => { + const result = interpolateSubdomainTemplate( + "${prNumber}.previews.example.com", + baseVars, + ); + expect(result).toBe("123.previews.example.com"); +}); + +test("replaces ${branchName} with slugified value", () => { + const result = interpolateSubdomainTemplate( + "${branchName}.previews.example.com", + baseVars, + ); + expect(result).toBe("feature-login-page.previews.example.com"); +}); + +test("replaces ${appName} variable", () => { + const result = interpolateSubdomainTemplate( + "${appName}-${prNumber}.example.com", + baseVars, + ); + expect(result).toBe("my-app-123.example.com"); +}); + +test("replaces ${uniqueId} variable", () => { + const result = interpolateSubdomainTemplate( + "preview-${uniqueId}.example.com", + baseVars, + ); + expect(result).toBe("preview-abc123.example.com"); +}); + +test("replaces all variables in a complex template", () => { + const result = interpolateSubdomainTemplate( + "${appName}-pr${prNumber}-${branchName}.example.com", + baseVars, + ); + expect(result).toBe("my-app-pr123-feature-login-page.example.com"); +}); + +test("leaves template unchanged when no variables present", () => { + const result = interpolateSubdomainTemplate( + "*.traefik.me", + baseVars, + ); + expect(result).toBe("*.traefik.me"); +}); + +test("slugifies branch names with special characters", () => { + const result = interpolateSubdomainTemplate( + "${branchName}.example.com", + { ...baseVars, branchName: "feat/SOME_THING@v2.0" }, + ); + expect(result).toBe("feat-some-thing-v2-0.example.com"); +}); + +test("truncates slugified branch to 63 chars for DNS compliance", () => { + const longBranch = "a".repeat(100); + const result = interpolateSubdomainTemplate( + "${branchName}.example.com", + { ...baseVars, branchName: longBranch }, + ); + const subdomain = result.split(".")[0]!; + expect(subdomain.length).toBeLessThanOrEqual(63); +}); + +test("handles multiple occurrences of the same variable", () => { + const result = interpolateSubdomainTemplate( + "${prNumber}-${prNumber}.example.com", + baseVars, + ); + expect(result).toBe("123-123.example.com"); +}); 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 d2840cd67d..6f63a5e33e 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 @@ -1,6 +1,6 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { HelpCircle, Plus, Settings2, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -101,8 +101,33 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { }); const previewHttps = form.watch("previewHttps"); - const wildcardDomain = form.watch("wildcardDomain"); - const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false; + const watchedWildcard = form.watch("wildcardDomain"); + const isTraefikMeDomain = watchedWildcard?.includes("traefik.me") || false; + + const templatePreview = useMemo(() => { + const template = watchedWildcard || "*.traefik.me"; + const appName = data?.appName || "my-app"; + const exampleVars: Record = { + appName, + prNumber: "42", + branchName: "feature-login", + uniqueId: "a1b2c3", + }; + let result = template.replace( + /\$\{(appName|prNumber|branchName|uniqueId)\}/g, + (_match: string, key: string) => exampleVars[key] ?? "", + ); + const hasUniqueVar = + template.includes("${prNumber}") || + template.includes("${branchName}") || + template.includes("${uniqueId}"); + if (!hasUniqueVar) { + result = result.replace("*", `${appName}-a1b2c3`); + } else { + result = result.replace("*", appName); + } + return result; + }, [watchedWildcard, data?.appName]); useEffect(() => { setIsEnabled(data?.isPreviewDeploymentsActive || false); @@ -185,22 +210,44 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { className="grid w-full gap-4" >
- ( - - Wildcard Domain - - - - - - )} - /> - ( + + Preview Domain Template + + + + + + Use *. for auto-generated subdomains, + or template variables for custom patterns. + If the template includes{" "} + {"${prNumber}"} or{" "} + {"${branchName}"}, + no random suffix is appended. + + + {"${appName}"} + {"${prNumber}"} + {"${branchName}"} + {"${uniqueId}"} + + + Example: {templatePreview} + + + + + )} + /> + ( Preview Path From 709eb0c99b55e04a82a4b01d899fa35f44f25ad6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 14:25:12 +0000 Subject: [PATCH 03/15] [autofix.ci] apply automated fixes --- .../preview-deployment/template.test.ts | 21 +++-- .../show-preview-settings.tsx | 76 +++++++++---------- .../server/src/services/preview-deployment.ts | 5 +- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/apps/dokploy/__test__/preview-deployment/template.test.ts b/apps/dokploy/__test__/preview-deployment/template.test.ts index 38e993c566..85388b4ce5 100644 --- a/apps/dokploy/__test__/preview-deployment/template.test.ts +++ b/apps/dokploy/__test__/preview-deployment/template.test.ts @@ -49,27 +49,24 @@ test("replaces all variables in a complex template", () => { }); test("leaves template unchanged when no variables present", () => { - const result = interpolateSubdomainTemplate( - "*.traefik.me", - baseVars, - ); + const result = interpolateSubdomainTemplate("*.traefik.me", baseVars); expect(result).toBe("*.traefik.me"); }); test("slugifies branch names with special characters", () => { - const result = interpolateSubdomainTemplate( - "${branchName}.example.com", - { ...baseVars, branchName: "feat/SOME_THING@v2.0" }, - ); + const result = interpolateSubdomainTemplate("${branchName}.example.com", { + ...baseVars, + branchName: "feat/SOME_THING@v2.0", + }); expect(result).toBe("feat-some-thing-v2-0.example.com"); }); test("truncates slugified branch to 63 chars for DNS compliance", () => { const longBranch = "a".repeat(100); - const result = interpolateSubdomainTemplate( - "${branchName}.example.com", - { ...baseVars, branchName: longBranch }, - ); + const result = interpolateSubdomainTemplate("${branchName}.example.com", { + ...baseVars, + branchName: longBranch, + }); const subdomain = result.split(".")[0]!; expect(subdomain.length).toBeLessThanOrEqual(63); }); 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 6f63a5e33e..01b6163cae 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 @@ -210,44 +210,44 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { className="grid w-full gap-4" >
- ( - - Preview Domain Template - - - - - - Use *. for auto-generated subdomains, - or template variables for custom patterns. - If the template includes{" "} - {"${prNumber}"} or{" "} - {"${branchName}"}, - no random suffix is appended. - - - {"${appName}"} - {"${prNumber}"} - {"${branchName}"} - {"${uniqueId}"} - - - Example: {templatePreview} - - - - - )} - /> - ( + + Preview Domain Template + + + + + + Use *. for + auto-generated subdomains, or template variables for + custom patterns. If the template includes{" "} + {"${prNumber}"} or{" "} + {"${branchName}"}, + no random suffix is appended. + + + {"${appName}"} + {"${prNumber}"} + {"${branchName}"} + {"${uniqueId}"} + + + Example: {templatePreview} + + + + + )} + /> + ( Preview Path diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index ccdeca1b8c..829080f271 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -178,7 +178,10 @@ export const createPreviewDeployment = async ( } else { appName = `preview-${application.appName}-${uniqueId}`; const org = await db.query.organization.findFirst({ - where: eq(organization.id, application.environment.project.organizationId), + where: eq( + organization.id, + application.environment.project.organizationId, + ), }); generateDomain = await generateWildcardDomain( domainTemplate, From 9f10f0f4e979978f753ff42fc6440d6b0a0ce619 Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Tue, 12 May 2026 21:35:02 +0300 Subject: [PATCH 04/15] fix(migrate-auth-secret): exit cleanly when there are no 2FA records The empty-records branch of `main()` returned without calling `process.exit(0)`, leaving the Drizzle Postgres connection pool holding the event loop open. The `migrate-auth-secret` process then hangs indefinitely after printing "No 2FA records found, nothing to migrate." causing the upstream `0.29.3.sh` security migration script (which calls this via `docker exec`) to never reach its final `docker service update` step that mounts the new Docker Secret. Operators end up with the new secret created but the dokploy service still configured with the hardcoded `BETTER_AUTH_SECRET`, while believing the migration completed. Match the success branch a few lines below which already does `process.exit(0)`, and the pattern used in sibling scripts `reset-password.ts` and `reset-2fa.ts`. Closes #4392 --- apps/dokploy/scripts/migrate-auth-secret.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/scripts/migrate-auth-secret.ts b/apps/dokploy/scripts/migrate-auth-secret.ts index 5a71678d9a..302612c6d8 100644 --- a/apps/dokploy/scripts/migrate-auth-secret.ts +++ b/apps/dokploy/scripts/migrate-auth-secret.ts @@ -46,7 +46,7 @@ async function main() { if (records.length === 0) { console.log("✅ No 2FA records found, nothing to migrate."); - return; + process.exit(0); } console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`); From 754774ea02d07b02cda9cfbf4e581b230f7ebe98 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 12 May 2026 13:12:14 -0600 Subject: [PATCH 05/15] feat(compose): add import from base64 in create service dropdown Adds an "Import" option to the Create Service dropdown that lets users paste a base64-encoded compose export, preview the template (compose YAML, domains, envs, mounts) before confirming, and create the service only on confirm. Adds a `previewTemplate` tRPC procedure that processes the base64 without touching the DB, with server access validation via session. --- .../dashboard/project/add-import.tsx | 489 ++++++++++++++++++ .../environment/[environmentId].tsx | 5 + apps/dokploy/server/api/routers/compose.ts | 70 +++ 3 files changed, 564 insertions(+) create mode 100644 apps/dokploy/components/dashboard/project/add-import.tsx diff --git a/apps/dokploy/components/dashboard/project/add-import.tsx b/apps/dokploy/components/dashboard/project/add-import.tsx new file mode 100644 index 0000000000..cdf1acbe4f --- /dev/null +++ b/apps/dokploy/components/dashboard/project/add-import.tsx @@ -0,0 +1,489 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Code2, FileInput, Globe2, HardDrive, HelpCircle } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { slugify } from "@/lib/slug"; +import { api } from "@/utils/api"; +import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema"; + +const AddImportSchema = z.object({ + name: z.string().min(1, { message: "Name is required" }), + appName: z + .string() + .min(1, { message: "App name is required" }) + .regex(APP_NAME_REGEX, { message: APP_NAME_MESSAGE }), + base64: z.string().min(1, { message: "Base64 content is required" }), + serverId: z.string().optional(), +}); + +type AddImport = z.infer; + +type TemplateInfo = { + compose: string; + template: { + domains: Array<{ + serviceName: string; + port: number; + path?: string; + host?: string; + }>; + envs: string[]; + mounts: Array<{ filePath: string; content: string }>; + }; +}; + +interface Props { + environmentId: string; + projectName?: string; +} + +export const AddImport = ({ environmentId, projectName }: Props) => { + const utils = api.useUtils(); + const [visible, setVisible] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + const [mountOpen, setMountOpen] = useState(false); + const [selectedMount, setSelectedMount] = useState<{ + filePath: string; + content: string; + } | null>(null); + const [templateInfo, setTemplateInfo] = useState(null); + + const slug = slugify(projectName); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const shouldShowServerDropdown = !!(servers && servers.length > 0); + + const { mutateAsync: previewTemplate, isPending: isProcessing } = + api.compose.previewTemplate.useMutation(); + const { mutateAsync: createCompose, isPending: isCreating } = + api.compose.create.useMutation(); + const { mutateAsync: importCompose, isPending: isImporting } = + api.compose.import.useMutation(); + + const form = useForm({ + defaultValues: { name: "", appName: `${slug}-`, base64: "" }, + resolver: zodResolver(AddImportSchema), + }); + + const resetAll = () => { + form.reset({ name: "", appName: `${slug}-`, base64: "" }); + setTemplateInfo(null); + setPreviewOpen(false); + setMountOpen(false); + setSelectedMount(null); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) resetAll(); + setVisible(open); + }; + + const handleLoad = async (data: AddImport) => { + try { + const result = await previewTemplate({ + appName: data.appName, + base64: data.base64.trim(), + serverId: data.serverId === "dokploy" ? undefined : data.serverId, + }); + setTemplateInfo(result); + setPreviewOpen(true); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error processing template", + ); + } + }; + + const handleImport = async () => { + const data = form.getValues(); + try { + const compose = await createCompose({ + name: data.name, + appName: data.appName, + environmentId, + composeType: "docker-compose", + serverId: data.serverId === "dokploy" ? undefined : data.serverId, + }); + await importCompose({ + composeId: compose.composeId, + base64: data.base64.trim(), + }); + toast.success("Compose imported successfully"); + await utils.environment.one.invalidate({ environmentId }); + resetAll(); + setVisible(false); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error importing compose", + ); + } + }; + + const handleCancelPreview = () => { + setPreviewOpen(false); + setTemplateInfo(null); + }; + + return ( + <> + + + e.preventDefault()} + > + + Import + + + + + Import Compose + + Paste a base64-encoded compose export to preview and import it + + + +
+ + ( + + Name + + { + const val = e.target.value || ""; + form.setValue( + "appName", + `${slug}-${slugify(val.trim())}`, + ); + field.onChange(val); + }} + /> + + + + )} + /> + + {shouldShowServerDropdown && ( + ( + + + + + + Select a Server{" "} + {!isCloud ? "(Optional)" : ""} + + + + + + If no server is selected, the compose will be + deployed on the server where the user is logged in. + + + + + + + + )} + /> + )} + + ( + + App Name + + + + + + )} + /> + + ( + + Configuration (Base64) + +