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
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -555,6 +580,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
)}
/>

{isWildcardHost && (
<AlertBlock type="info">
<strong>Wildcard Domain:</strong> This domain will match all
subdomains (e.g., <code>app.{host?.slice(2)}</code>,{" "}
<code>api.{host?.slice(2)}</code>).
</AlertBlock>
)}

<FormField
control={form.control}
name="path"
Expand Down Expand Up @@ -697,6 +730,26 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}}
/>

{isWildcardHost && certificateType === "letsencrypt" && (
<AlertBlock type="warning">
<div className="flex items-start gap-2">
<AlertTriangle className="size-4 mt-0.5 shrink-0" />
<div>
<strong>DNS Challenge Required:</strong> Wildcard SSL
certificates require DNS challenge validation.
Make sure you have configured your DNS provider
credentials in{" "}
<Link
href="/dashboard/settings/server?traefikEnv=true"
className="text-primary underline"
>
Settings → Web Server → Traefik Environment
</Link>{" "}
</div>
</div>
</AlertBlock>
)}

{certificateType === "custom" && (
<FormField
control={form.control}
Expand Down
37 changes: 28 additions & 9 deletions apps/dokploy/components/dashboard/settings/web-server.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ServerIcon } from "lucide-react";
import { useRouter } from "next/router";
import {
Card,
CardContent,
Expand All @@ -12,16 +13,42 @@ import { ShowStorageActions } from "./servers/actions/show-storage-actions";
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
import { UpdateServer } from "./web-server/update-server";
import { EditTraefikEnv } from "./web-server/edit-traefik-env";

export const WebServer = () => {
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();

const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();

return (
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
{showTraefikEnv && (
<EditTraefikEnv
autoOpen
showDnsGuide
onOpenChange={handleTraefikEnvClose}
/>
)}

<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
Expand All @@ -31,14 +58,6 @@ export const WebServer = () => {
</CardTitle>
<CardDescription>Reload or clean the web server.</CardDescription>
</CardHeader>
{/* <CardHeader>
<CardTitle className="text-xl">
Web Server
</CardTitle>
<CardDescription>
Reload or clean the web server.
</CardDescription>
</CardHeader> */}
<CardContent className="space-y-6 py-6 border-t">
<div className="grid md:grid-cols-2 gap-4">
<ShowDokployActions />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,30 @@ type Schema = z.infer<typeof schema>;
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,
});
Expand Down Expand Up @@ -100,17 +119,33 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
}, [form, onSubmit, isPending, canEdit]);

return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Update Traefik Environment</DialogTitle>
<DialogDescription>
Update the traefik environment variables
Update the traefik environment variables. For wildcard
SSL certificates, configure your DNS provider credentials
below.
</DialogDescription>
Comment on lines 126 to 131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dialog description unconditionally references wildcard certificates

The updated DialogDescription always reads "For wildcard SSL certificates, configure your DNS provider credentials below." This text is rendered every time the Traefik Env dialog is opened — including in the existing, non-wildcard flows where users open it from the regular server settings page. It is misleading for users who are not configuring wildcard certificates.

Consider making the description conditional on showDnsGuide:

Suggested change
<DialogTitle>Update Traefik Environment</DialogTitle>
<DialogDescription>
Update the traefik environment variables
Update the traefik environment variables. For wildcard
SSL certificates, configure your DNS provider credentials
below.
</DialogDescription>
<DialogDescription>
{showDnsGuide
? "Update the traefik environment variables. For wildcard SSL certificates, configure your DNS provider credentials below."
: "Update the traefik environment variables."}
</DialogDescription>

</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}

{showDnsGuide && (
<AlertBlock type="info">
<strong>DNS Challenge for Wildcard Certificates:</strong>{" "}
To use wildcard domains (e.g., *.example.com) with HTTPS,
add your DNS provider API credentials here. Common providers:
<ul className="mt-1 ml-4 list-disc text-xs space-y-0.5">
<li><strong>Cloudflare:</strong> <code>CF_DNS_API_TOKEN=your_token</code></li>
<li><strong>Route53:</strong> <code>AWS_ACCESS_KEY_ID</code> + <code>AWS_SECRET_ACCESS_KEY</code></li>
<li><strong>DigitalOcean:</strong> <code>DO_AUTH_TOKEN=your_token</code></li>
<li><strong>Hetzner:</strong> <code>HETZNER_API_KEY=your_key</code></li>
</ul>
</AlertBlock>
)}
Comment on lines +135 to +147
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DNS guide omits the required TRAEFIK_DNS_PROVIDER variable

The guide correctly shows per-provider API credentials (CF_DNS_API_TOKEN, AWS_ACCESS_KEY_ID, DO_AUTH_TOKEN, HETZNER_API_KEY), but these are Traefik environment variables. The provider name embedded in traefik.yml is controlled by a separate environment variable — TRAEFIK_DNS_PROVIDER — that must be set in Dokploy's own environment (not Traefik's), because it is read at config-generation time in getDefaultTraefikConfig/getDefaultServerTraefikConfig:

provider: process.env.TRAEFIK_DNS_PROVIDER || "cloudflare",

A user who follows the Route 53, DigitalOcean, or Hetzner rows, sets the correct API credentials in the Traefik Env dialog, but never sets TRAEFIK_DNS_PROVIDER=route53 (or digitalocean / hetzner) in Dokploy's environment will end up with provider: "cloudflare" in traefik.yml — their DNS challenge will call the Cloudflare API with the wrong credentials and silently fail.

The guide should mention that TRAEFIK_DNS_PROVIDER also needs to be set, along with where (Dokploy env vs. Traefik env).


<Form {...form}>
<form
id="hook-form-update-server-traefik-config"
Expand All @@ -128,14 +163,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
<CodeEditor
language="properties"
wrapperClassName="h-[35rem] font-mono"
placeholder={`TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=test@localhost.com
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_STORAGE=/etc/dokploy/traefik/dynamic/acme.json
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE=true
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_PRETTY=true
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_ENTRYPOINT=web
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_CHALLENGE=true
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
`}
placeholder={
showDnsGuide
? `# DNS Challenge credentials for wildcard certificates
CF_DNS_API_TOKEN=your_cloudflare_api_token
CF_API_EMAIL=your_cloudflare_email`
: `TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=test@localhost.com
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_STORAGE=/etc/dokploy/traefik/dynamic/acme.json
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE=true
TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE_ENTRYPOINT=web`
}
{...field}
/>
</FormControl>
Expand Down
50 changes: 50 additions & 0 deletions packages/server/src/db/validations/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
18 changes: 18 additions & 0 deletions packages/server/src/setup/traefik-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
},
},
}),
};
Expand Down Expand Up @@ -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",
},
},
},
},
};

Expand Down
10 changes: 8 additions & 2 deletions packages/server/src/utils/docker/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;";
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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(
Expand Down
Loading