diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 00eb622727..c279fc4fbf 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; +import { AlertTriangle, DatabaseZap, Dices, RefreshCw } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -92,6 +92,30 @@ export const domain = z }); } + if (input.host.startsWith("*.")) { + const baseDomain = input.host.slice(2); + if (!baseDomain || baseDomain.includes("*")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["host"], + message: + "Invalid wildcard domain format. Use *.example.com", + }); + } + + if ( + input.https && + input.certificateType === "none" + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: + "Wildcard domains require Let's Encrypt (DNS Challenge) or a Custom certificate resolver for HTTPS", + }); + } + } + // Validate stripPath requires a valid path if (input.stripPath && (!input.path || input.path === "/")) { ctx.addIssue({ @@ -210,6 +234,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const domainType = form.watch("domainType"); const host = form.watch("host"); const isTraefikMeDomain = host?.includes("traefik.me") || false; + const isWildcardHost = host?.startsWith("*.") || false; useEffect(() => { if (data) { @@ -555,6 +580,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { )} /> + {isWildcardHost && ( + + Wildcard Domain: This domain will match all + subdomains (e.g., app.{host?.slice(2)},{" "} + api.{host?.slice(2)}). + + )} + { }} /> + {isWildcardHost && certificateType === "letsencrypt" && ( + +
+ +
+ DNS Challenge Required: Wildcard SSL + certificates require DNS challenge validation. + Make sure you have configured your DNS provider + credentials in{" "} + + Settings → Web Server → Traefik Environment + {" "} +
+
+
+ )} + {certificateType === "custom" && ( { + const router = useRouter(); + const showTraefikEnv = router.query.traefikEnv === "true"; + + const handleTraefikEnvClose = (open: boolean) => { + if (!open && showTraefikEnv) { + const query = { ...router.query }; + delete query.traefikEnv; + router.replace( + { + pathname: router.pathname, + query, + }, + undefined, + { shallow: true }, + ); + } + }; + const { data: webServerSettings } = api.settings.getWebServerSettings.useQuery(); @@ -21,7 +41,14 @@ export const WebServer = () => { return (
- {/* */} + {showTraefikEnv && ( + + )} +
@@ -31,14 +58,6 @@ export const WebServer = () => { Reload or clean the web server. - {/* - - Web Server - - - Reload or clean the web server. - - */}
diff --git a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx index 3f63fbc90d..82aeb8da39 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx @@ -35,11 +35,30 @@ type Schema = z.infer; interface Props { children?: React.ReactNode; serverId?: string; + autoOpen?: boolean; + showDnsGuide?: boolean; + onOpenChange?: (open: boolean) => void; } -export const EditTraefikEnv = ({ children, serverId }: Props) => { +export const EditTraefikEnv = ({ + children, + serverId, + autoOpen = false, + showDnsGuide = false, + onOpenChange: onOpenChangeProp, +}: Props) => { + const [isOpen, setIsOpen] = useState(autoOpen); const [canEdit, setCanEdit] = useState(true); + useEffect(() => { + setIsOpen(autoOpen); + }, [autoOpen]); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + onOpenChangeProp?.(open); + }; + const { data } = api.settings.readTraefikEnv.useQuery({ serverId, }); @@ -100,17 +119,33 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => { }, [form, onSubmit, isPending, canEdit]); return ( - - {children} + + {children && {children}} Update Traefik Environment - Update the traefik environment variables + Update the traefik environment variables. For wildcard + SSL certificates, configure your DNS provider credentials + below. {isError && {error?.message}} + {showDnsGuide && ( + + DNS Challenge for Wildcard Certificates:{" "} + To use wildcard domains (e.g., *.example.com) with HTTPS, + add your DNS provider API credentials here. Common providers: +
    +
  • Cloudflare: CF_DNS_API_TOKEN=your_token
  • +
  • Route53: AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
  • +
  • DigitalOcean: DO_AUTH_TOKEN=your_token
  • +
  • Hetzner: HETZNER_API_KEY=your_key
  • +
+
+ )} +
{ diff --git a/packages/server/src/db/validations/domain.ts b/packages/server/src/db/validations/domain.ts index c032841e31..ab86a4d4a9 100644 --- a/packages/server/src/db/validations/domain.ts +++ b/packages/server/src/db/validations/domain.ts @@ -38,6 +38,30 @@ export const domain = z }); } + if (input.host.startsWith("*.")) { + const baseDomain = input.host.slice(2); + if (!baseDomain || baseDomain.includes("*")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["host"], + message: + "Invalid wildcard domain format. Use *.example.com", + }); + } + + if ( + input.https && + input.certificateType === "none" + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: + "Wildcard domains require Let's Encrypt (DNS Challenge) or a Custom certificate resolver for HTTPS", + }); + } + } + // Validate stripPath requires a valid path if (input.stripPath && (!input.path || input.path === "/")) { ctx.addIssue({ @@ -101,6 +125,32 @@ export const domainCompose = z }); } + // Validate wildcard domain format + if (input.host.startsWith("*.")) { + const baseDomain = input.host.slice(2); + if (!baseDomain || baseDomain.includes("*")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["host"], + message: + "Invalid wildcard domain format. Use *.example.com", + }); + } + + // Wildcard domains with HTTPS need letsencrypt (auto DNS challenge) or custom + if ( + input.https && + input.certificateType === "none" + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: + "Wildcard domains require Let's Encrypt (DNS Challenge) or a Custom certificate resolver for HTTPS", + }); + } + } + // Validate stripPath requires a valid path if (input.stripPath && (!input.path || input.path === "/")) { ctx.addIssue({ diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 9fcebe5408..a211943cbf 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -311,6 +311,15 @@ export const getDefaultTraefikConfig = () => { }, }, }, + "letsencrypt-dns": { + acme: { + email: "test@localhost.com", + storage: "/etc/dokploy/traefik/dynamic/acme-dns.json", + dnsChallenge: { + provider: process.env.TRAEFIK_DNS_PROVIDER || "cloudflare", + }, + }, + }, }, }), }; @@ -366,6 +375,15 @@ export const getDefaultServerTraefikConfig = () => { }, }, }, + "letsencrypt-dns": { + acme: { + email: "test@localhost.com", + storage: "/etc/dokploy/traefik/dynamic/acme-dns.json", + dnsChallenge: { + provider: process.env.TRAEFIK_DNS_PROVIDER || "cloudflare", + }, + }, + }, }, }; diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 230453e568..69a0eaf06b 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -19,6 +19,7 @@ import type { PropertiesNetworks, } from "./types"; import { encodeBase64 } from "./utils"; +import { buildHostRule, isWildcardDomain } from "../traefik/domain"; export const cloneCompose = async (compose: Compose) => { let command = "set -e;"; @@ -261,8 +262,10 @@ export const createDomainLabels = ( internalPath, } = domain; const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`; + const hostRule = buildHostRule(host); + const wildcardDomain = isWildcardDomain(host); const labels = [ - `traefik.http.routers.${routerName}.rule=Host(\`${host}\`)${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, + `traefik.http.routers.${routerName}.rule=${hostRule}${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, `traefik.http.routers.${routerName}.entrypoints=${entrypoint}`, `traefik.http.services.${routerName}.loadbalancer.server.port=${port}`, `traefik.http.routers.${routerName}.service=${routerName}`, @@ -310,8 +313,11 @@ export const createDomainLabels = ( // Add TLS configuration for websecure if (entrypoint === "websecure") { if (certificateType === "letsencrypt") { + const resolverName = wildcardDomain + ? "letsencrypt-dns" + : "letsencrypt"; labels.push( - `traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`, + `traefik.http.routers.${routerName}.tls.certresolver=${resolverName}`, ); } else if (certificateType === "custom" && customCertResolver) { labels.push( diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts index 6a328a1d92..a38e9a5f3b 100644 --- a/packages/server/src/utils/traefik/domain.ts +++ b/packages/server/src/utils/traefik/domain.ts @@ -118,6 +118,20 @@ const toPunycode = (host: string): string => { } }; +export const isWildcardDomain = (host: string): boolean => { + return host.startsWith("*."); +}; + +export const buildHostRule = (host: string): string => { + if (isWildcardDomain(host)) { + const baseDomain = host.slice(2); + const escapedDomain = baseDomain.replace(/\./g, "\\."); + return `HostRegexp(\`^.+\\.${escapedDomain}$\`)`; + } + const punycodeHost = toPunycode(host); + return `Host(\`${punycodeHost}\`)`; +}; + export const createRouterConfig = async ( app: ApplicationNested, domain: Domain, @@ -128,9 +142,10 @@ export const createRouterConfig = async ( const { host, path, https, uniqueConfigKey, internalPath, stripPath } = domain; - const punycodeHost = toPunycode(host); + const hostRule = buildHostRule(host); + const wildcardDomain = isWildcardDomain(host); const routerConfig: HttpRouter = { - rule: `Host(\`${punycodeHost}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, + rule: `${hostRule}${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, service: `${appName}-service-${uniqueConfigKey}`, middlewares: [], entryPoints: [entryPoint], @@ -176,7 +191,10 @@ export const createRouterConfig = async ( if (entryPoint === "websecure") { if (certificateType === "letsencrypt") { - routerConfig.tls = { certResolver: "letsencrypt" }; + const resolverName = wildcardDomain + ? "letsencrypt-dns" + : "letsencrypt"; + routerConfig.tls = { certResolver: resolverName }; } else if (certificateType === "custom" && domain.customCertResolver) { routerConfig.tls = { certResolver: domain.customCertResolver }; } else if (certificateType === "none") {