diff --git a/apps/dokploy/__test__/utils/hostname-validation.test.ts b/apps/dokploy/__test__/utils/hostname-validation.test.ts
new file mode 100644
index 000000000..c0e734282
--- /dev/null
+++ b/apps/dokploy/__test__/utils/hostname-validation.test.ts
@@ -0,0 +1,46 @@
+import { VALID_HOSTNAME_REGEX } from "@dokploy/server";
+import { describe, expect, it } from "vitest";
+
+describe("VALID_HOSTNAME_REGEX", () => {
+ it.each([
+ "example.com",
+ "sub.example.com",
+ "bbn-client.example.com",
+ "a.b.c.example.co",
+ "xn--80ak6aa92e.com",
+ "123.example.com",
+ ])("accepts valid hostname %s", (host) => {
+ expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
+ });
+
+ it.each([
+ "bbn_client.example.com",
+ "-example.com",
+ "example-.com",
+ "example",
+ "exa mple.com",
+ "example..com",
+ "",
+ `a${"a".repeat(63)}.com`,
+ ])("rejects invalid hostname %s", (host) => {
+ expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
+ });
+
+ // IDNs (Cyrillic, German umlauts, etc.) must be submitted in their
+ // ACME/punycode form ("xn--...") — that's what Let's Encrypt issues
+ // certificates for, so raw Unicode labels are rejected here.
+ it.each(["пример.рф", "bücher.de", "日本語.jp"])(
+ "rejects raw unicode IDN %s",
+ (host) => {
+ expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
+ },
+ );
+
+ it.each([
+ "xn--e1afmkfd.xn--p1ai", // punycode for пример.рф
+ "xn--bcher-kva.de", // punycode for bücher.de
+ "xn--wgv71a119e.jp", // punycode for 日本語.jp
+ ])("accepts punycode-encoded IDN %s", (host) => {
+ expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
+ });
+});
diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
index d7612701f..be8901921 100644
--- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
+++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
@@ -1,3 +1,7 @@
+import {
+ INVALID_HOSTNAME_MESSAGE,
+ VALID_HOSTNAME_REGEX,
+} from "@dokploy/server/utils/hostname-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
import Link from "next/link";
@@ -53,7 +57,10 @@ export const domain = z
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
- .transform((val) => val.trim()),
+ .transform((val) => val.trim())
+ .refine((val) => VALID_HOSTNAME_REGEX.test(val), {
+ message: INVALID_HOSTNAME_MESSAGE,
+ }),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx
index 41aac01d3..ef9cc6de3 100644
--- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx
@@ -195,9 +195,7 @@ export const CreateServer = ({ stepper }: Props) => {
{sshKey.name}
))}
-
- Registries ({sshKeys?.length})
-
+ SSH Keys ({sshKeys?.length})
diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx
index 4c88678a2..4f543a1be 100644
--- a/apps/dokploy/components/dashboard/settings/web-domain.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx
@@ -1,3 +1,7 @@
+import {
+ INVALID_HOSTNAME_MESSAGE,
+ VALID_HOSTNAME_REGEX,
+} from "@dokploy/server/utils/hostname-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { GlobeIcon } from "lucide-react";
import { useEffect } from "react";
@@ -35,7 +39,13 @@ import { api } from "@/utils/api";
const addServerDomain = z
.object({
- domain: z.string().trim().toLowerCase(),
+ domain: z
+ .string()
+ .trim()
+ .toLowerCase()
+ .refine((val) => VALID_HOSTNAME_REGEX.test(val), {
+ message: INVALID_HOSTNAME_MESSAGE,
+ }),
letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
diff --git a/apps/dokploy/server/db/validations/domain.ts b/apps/dokploy/server/db/validations/domain.ts
index 35de518c0..033a29303 100644
--- a/apps/dokploy/server/db/validations/domain.ts
+++ b/apps/dokploy/server/db/validations/domain.ts
@@ -1,3 +1,7 @@
+import {
+ INVALID_HOSTNAME_MESSAGE,
+ VALID_HOSTNAME_REGEX,
+} from "@dokploy/server/utils/hostname-validation";
import { z } from "zod";
export const domain = z
@@ -8,7 +12,10 @@ export const domain = z
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
- .transform((val) => val.trim()),
+ .transform((val) => val.trim())
+ .refine((val) => VALID_HOSTNAME_REGEX.test(val), {
+ message: INVALID_HOSTNAME_MESSAGE,
+ }),
path: z.string().min(1).optional(),
port: z
.number()
@@ -45,7 +52,10 @@ export const domainCompose = z
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
- .transform((val) => val.trim()),
+ .transform((val) => val.trim())
+ .refine((val) => VALID_HOSTNAME_REGEX.test(val), {
+ message: INVALID_HOSTNAME_MESSAGE,
+ }),
path: z.string().min(1).optional(),
port: z
.number()
diff --git a/packages/server/src/db/validations/domain.ts b/packages/server/src/db/validations/domain.ts
index 62447bee7..059e3cae2 100644
--- a/packages/server/src/db/validations/domain.ts
+++ b/packages/server/src/db/validations/domain.ts
@@ -1,4 +1,8 @@
import { z } from "zod";
+import {
+ INVALID_HOSTNAME_MESSAGE,
+ VALID_HOSTNAME_REGEX,
+} from "../../utils/hostname-validation";
export const domain = z
.object({
@@ -8,7 +12,10 @@ export const domain = z
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
- .transform((val) => val.trim()),
+ .transform((val) => val.trim())
+ .refine((val) => VALID_HOSTNAME_REGEX.test(val), {
+ message: INVALID_HOSTNAME_MESSAGE,
+ }),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -71,7 +78,10 @@ export const domainCompose = z
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
- .transform((val) => val.trim()),
+ .transform((val) => val.trim())
+ .refine((val) => VALID_HOSTNAME_REGEX.test(val), {
+ message: INVALID_HOSTNAME_MESSAGE,
+ }),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 7bda4615a..b8a2fcec7 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -104,6 +104,7 @@ export * from "./utils/filesystem/directory";
export * from "./utils/filesystem/ssh";
export * from "./utils/git-branch-validation";
export * from "./utils/gpu-setup";
+export * from "./utils/hostname-validation";
export * from "./utils/notifications/build-error";
export * from "./utils/notifications/build-success";
export * from "./utils/notifications/database-backup";
diff --git a/packages/server/src/utils/hostname-validation.ts b/packages/server/src/utils/hostname-validation.ts
new file mode 100644
index 000000000..be3407e5e
--- /dev/null
+++ b/packages/server/src/utils/hostname-validation.ts
@@ -0,0 +1,8 @@
+// Valid hostname per RFC 1123: labels of letters, digits and hyphens
+// (no leading/trailing hyphen), separated by dots. Underscores are rejected
+// because Let's Encrypt refuses to issue certificates for them.
+export const VALID_HOSTNAME_REGEX =
+ /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
+
+export const INVALID_HOSTNAME_MESSAGE =
+ "Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";