diff --git a/.github/sponsors/awesome.png b/.github/sponsors/awesome.png new file mode 100644 index 000000000..0753212ab Binary files /dev/null and b/.github/sponsors/awesome.png differ diff --git a/README.md b/README.md index d60962cff..23fcd0c9d 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,9 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). - + + + diff --git a/apps/dokploy/__test__/cluster/upload.test.ts b/apps/dokploy/__test__/cluster/upload.test.ts new file mode 100644 index 000000000..ca95cf663 --- /dev/null +++ b/apps/dokploy/__test__/cluster/upload.test.ts @@ -0,0 +1,209 @@ +import type { Registry } from "@dokploy/server"; +import { getRegistryTag } from "@dokploy/server"; +import { describe, expect, it } from "vitest"; + +describe("getRegistryTag", () => { + // Helper to create a mock registry + const createMockRegistry = (overrides: Partial = {}): Registry => { + return { + registryId: "test-registry-id", + registryName: "Test Registry", + username: "myuser", + password: "test-password", + registryUrl: "docker.io", + registryType: "cloud", + imagePrefix: null, + createdAt: new Date().toISOString(), + organizationId: "test-org-id", + ...overrides, + }; + }; + + describe("with username (no imagePrefix)", () => { + it("should handle simple image name without tag", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("docker.io/myuser/nginx"); + }); + + it("should handle image name with tag", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "nginx:latest"); + expect(result).toBe("docker.io/myuser/nginx:latest"); + }); + + it("should handle image name with username already present (no duplication)", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "myuser/myprivaterepo"); + // Should not duplicate username + expect(result).toBe("docker.io/myuser/myprivaterepo"); + }); + + it("should handle image name with username and tag already present", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "myuser/myprivaterepo:latest"); + // Should not duplicate username + expect(result).toBe("docker.io/myuser/myprivaterepo:latest"); + }); + + it("should handle complex image name with username", () => { + const registry = createMockRegistry({ username: "siumauricio" }); + const result = getRegistryTag( + registry, + "siumauricio/app-parse-multi-byte-port-e32uh7", + ); + // Should not duplicate username + expect(result).toBe( + "docker.io/siumauricio/app-parse-multi-byte-port-e32uh7", + ); + }); + + it("should handle image name with different username (should not duplicate)", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "otheruser/myprivaterepo"); + expect(result).toBe("docker.io/myuser/myprivaterepo"); + }); + + it("should handle image name with full registry URL (no username)", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "docker.io/nginx"); + // Should add username since imageName doesn't have one + expect(result).toBe("docker.io/myuser/nginx"); + }); + + it("should handle image name with custom registry URL and username", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "ghcr.io/myuser/repo"); + // Should not duplicate username even if registry URL is different + expect(result).toBe("docker.io/myuser/repo"); + }); + + it("should handle image name with custom registry URL (different username)", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "ghcr.io/otheruser/repo"); + // Should use registry username, not the one in imageName + expect(result).toBe("docker.io/myuser/repo"); + }); + }); + + describe("with imagePrefix", () => { + it("should use imagePrefix instead of username", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("docker.io/myorg/nginx"); + }); + + it("should use imagePrefix with image tag", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + }); + const result = getRegistryTag(registry, "nginx:latest"); + expect(result).toBe("docker.io/myorg/nginx:latest"); + }); + + it("should handle imagePrefix with username already in image name", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + }); + const result = getRegistryTag(registry, "myuser/myprivaterepo"); + expect(result).toBe("docker.io/myorg/myprivaterepo"); + }); + + it("should handle imagePrefix matching image name prefix", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + }); + const result = getRegistryTag(registry, "myorg/myprivaterepo"); + // Should not duplicate prefix + expect(result).toBe("docker.io/myorg/myprivaterepo"); + }); + }); + + describe("without registryUrl", () => { + it("should work without registryUrl", () => { + const registry = createMockRegistry({ + username: "myuser", + registryUrl: "", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("myuser/nginx"); + }); + + it("should work without registryUrl with imagePrefix", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + registryUrl: "", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("myorg/nginx"); + }); + + it("should handle username already present without registryUrl", () => { + const registry = createMockRegistry({ + username: "myuser", + registryUrl: "", + }); + const result = getRegistryTag(registry, "myuser/myprivaterepo"); + // Should not duplicate username + expect(result).toBe("myuser/myprivaterepo"); + }); + }); + + describe("with custom registryUrl", () => { + it("should handle custom registry URL", () => { + const registry = createMockRegistry({ + username: "myuser", + registryUrl: "ghcr.io", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("ghcr.io/myuser/nginx"); + }); + + it("should handle custom registry URL with imagePrefix", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + registryUrl: "ghcr.io", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("ghcr.io/myorg/nginx"); + }); + + it("should handle custom registry URL with username already present", () => { + const registry = createMockRegistry({ + username: "myuser", + registryUrl: "ghcr.io", + }); + const result = getRegistryTag(registry, "myuser/myprivaterepo"); + // Should not duplicate username + expect(result).toBe("ghcr.io/myuser/myprivaterepo"); + }); + }); + + describe("edge cases", () => { + it("should handle empty image name", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, ""); + expect(result).toBe("docker.io/myuser/"); + }); + + it("should handle image name with multiple slashes", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "org/suborg/repo"); + expect(result).toBe("docker.io/myuser/repo"); + }); + + it("should handle image name with username at different position", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "org/myuser/repo"); + expect(result).toBe("docker.io/myuser/repo"); + }); + }); +}); diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index cabc77d87..1c0a446a3 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -28,6 +28,7 @@ const baseApp: ApplicationNested = { railpackVersion: "0.2.2", applicationId: "", previewLabels: [], + createEnvFile: true, herokuVersion: "", giteaBranch: "", buildServerId: "", @@ -67,6 +68,7 @@ const baseApp: ApplicationNested = { previewWildcard: "", environment: { env: "", + isDefault: false, environmentId: "", name: "", createdAt: "", diff --git a/apps/dokploy/__test__/requests/request.test.ts b/apps/dokploy/__test__/requests/request.test.ts index 53ca8d777..3f58ac439 100644 --- a/apps/dokploy/__test__/requests/request.test.ts +++ b/apps/dokploy/__test__/requests/request.test.ts @@ -54,4 +54,22 @@ describe("processLogs", () => { const result = parseRawConfig(entryWithWhitespace); expect(result.data).toHaveLength(2); }); + + it("should filter out Dokploy dashboard requests", () => { + const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`; + + // Test with only Dokploy dashboard entry - should be filtered out + const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry); + expect(resultOnlyDokploy.data).toHaveLength(0); + expect(resultOnlyDokploy.totalCount).toBe(0); + + // Test with mixed entries - Dokploy should be filtered, others should remain + const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`; + const resultMixed = parseRawConfig(mixedEntries); + expect(resultMixed.data).toHaveLength(1); + expect(resultMixed.totalCount).toBe(1); + expect(resultMixed.data[0]?.ServiceName).not.toBe( + "dokploy-service-app@file", + ); + }); }); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 7854c5582..218b9ba1e 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -7,6 +7,7 @@ const baseApp: ApplicationNested = { rollbackActive: false, applicationId: "", previewLabels: [], + createEnvFile: true, herokuVersion: "", giteaRepository: "", giteaOwner: "", @@ -49,6 +50,7 @@ const baseApp: ApplicationNested = { environmentId: "", environment: { env: "", + isDefault: false, environmentId: "", name: "", createdAt: "", diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 730271861..a37f4959b 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -221,6 +221,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const useCustomEntrypoint = form.watch("useCustomEntrypoint"); const https = form.watch("https"); const domainType = form.watch("domainType"); + const host = form.watch("host"); + const isTraefikMeDomain = host?.includes("traefik.me") || false; useEffect(() => { if (data) { @@ -519,6 +521,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { to make your traefik.me domain work. )} + {isTraefikMeDomain && ( + + Note: traefik.me is a public HTTP + service and does not support SSL/HTTPS. HTTPS and + certificate options will not have any effect. + + )} Host
diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 48e978880..5de03c367 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -5,14 +5,23 @@ import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { Form } from "@/components/ui/form"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; import { Secrets } from "@/components/ui/secrets"; +import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; const addEnvironmentSchema = z.object({ env: z.string(), buildArgs: z.string(), buildSecrets: z.string(), + createEnvFile: z.boolean(), }); type EnvironmentSchema = z.infer; @@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: "", buildArgs: "", buildSecrets: "", + createEnvFile: true, }, resolver: zodResolver(addEnvironmentSchema), }); @@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const currentEnv = form.watch("env"); const currentBuildArgs = form.watch("buildArgs"); const currentBuildSecrets = form.watch("buildSecrets"); + const currentCreateEnvFile = form.watch("createEnvFile"); const hasChanges = currentEnv !== (data?.env || "") || currentBuildArgs !== (data?.buildArgs || "") || - currentBuildSecrets !== (data?.buildSecrets || ""); + currentBuildSecrets !== (data?.buildSecrets || "") || + currentCreateEnvFile !== (data?.createEnvFile ?? true); useEffect(() => { if (data) { @@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: data.env || "", buildArgs: data.buildArgs || "", buildSecrets: data.buildSecrets || "", + createEnvFile: data.createEnvFile ?? true, }); } }, [data, form]); @@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: formData.env, buildArgs: formData.buildArgs, buildSecrets: formData.buildSecrets, + createEnvFile: formData.createEnvFile, applicationId, }) .then(async () => { @@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: data?.env || "", buildArgs: data?.buildArgs || "", buildSecrets: data?.buildSecrets || "", + createEnvFile: data?.createEnvFile ?? true, }); }; @@ -167,6 +182,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => { placeholder="NPM_TOKEN=xyz" /> )} + {data?.buildType === "dockerfile" && ( + ( + +
+ Create Environment File + + When enabled, an .env file will be created during the + build process. Disable this if you don't want to generate + an environment file. + +
+ + + +
+ )} + /> + )}
{hasChanges && ( - - {canDeleteEnvironments && ( - - )} -
- )} + )} + {canDeleteEnvironments && !environment.isDefault && ( + + )} +
); })} diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index e9a78a9ba..d53fe0037 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -89,24 +89,26 @@ export const SearchCommand = () => { {data?.map((project) => { - const productionEnvironment = project.environments.find( - (environment) => environment.name === "production", - ); + // Find default environment, or fall back to first environment + const defaultEnvironment = + project.environments.find( + (environment) => environment.isDefault, + ) || project?.environments?.[0]; - if (!productionEnvironment) return null; + if (!defaultEnvironment) return null; return ( { router.push( - `/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`, + `/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`, ); setOpen(false); }} > - {project.name} / {productionEnvironment!.name} + {project.name} / {defaultEnvironment.name} ); })} diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx index 4321088f2..f751e262e 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx @@ -45,7 +45,34 @@ const AddRegistrySchema = z.object({ password: z.string().min(1, { message: "Password is required", }), - registryUrl: z.string(), + registryUrl: z + .string() + .optional() + .refine( + (val) => { + // If empty or undefined, skip validation (field is optional) + if (!val || val.trim().length === 0) { + return true; + } + // Validate that it's a valid hostname (no protocol, no path, optional port) + // Valid formats: example.com, registry.example.com, [::1], example.com:5000 + // Invalid: https://example.com, example.com/path + const trimmed = val.trim(); + // Check for protocol or path - these are not allowed + if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) { + return false; + } + // Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets + // Allow optional port at the end + const hostnameRegex = + /^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/; + return hostnameRegex.test(trimmed); + }, + { + message: + "Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.", + }, + ), imagePrefix: z.string(), serverId: z.string().optional(), }); @@ -99,6 +126,9 @@ export const HandleRegistry = ({ registryId }: Props) => { const registryName = form.watch("registryName"); const imagePrefix = form.watch("imagePrefix"); const serverId = form.watch("serverId"); + const selectedServer = servers?.find( + (server) => server.serverId === serverId, + ); useEffect(() => { if (registry) { @@ -125,7 +155,7 @@ export const HandleRegistry = ({ registryId }: Props) => { password: data.password, registryName: data.registryName, username: data.username, - registryUrl: data.registryUrl, + registryUrl: data.registryUrl || "", registryType: "cloud", imagePrefix: data.imagePrefix, serverId: data.serverId, @@ -261,6 +291,10 @@ export const HandleRegistry = ({ registryId }: Props) => { render={({ field }) => ( Registry URL + + Enter only the hostname (e.g., + aws_account_id.dkr.ecr.us-west-2.amazonaws.com). + { Server {!isCloud && "(Optional)"} - Select a server to test the registry. this will run the - following command on the server + {!isCloud ? ( + <> + {serverId && serverId !== "none" && selectedServer ? ( + <> + Authentication will be performed on{" "} + {selectedServer.name}. This + registry will be available on this server. + + ) : ( + <> + Choose where to authenticate with the registry. By + default, authentication occurs on the Dokploy + server. Select a specific server to authenticate + from that server instead. + + )} + + ) : ( + <> + {serverId && serverId !== "none" && selectedServer ? ( + <> + Authentication will be performed on{" "} + {selectedServer.name}. This + registry will be available on this server. + + ) : ( + <> + Select a server to authenticate with the registry. + The authentication will be performed from the + selected server. + + )} + + )}