Merge pull request #1442 from Dokploy/996-allow-customisation-of-a-domains-certresolver

feat(domains): add custom certificate resolver support
This commit is contained in:
Mauricio Siu
2025-03-08 21:22:59 -06:00
committed by GitHub
24 changed files with 15714 additions and 83 deletions

View File

@@ -9,6 +9,7 @@ describe("createDomainLabels", () => {
port: 8080, port: 8080,
https: false, https: false,
uniqueConfigKey: 1, uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none", certificateType: "none",
applicationId: "", applicationId: "",
composeId: "", composeId: "",

View File

@@ -37,6 +37,7 @@ const baseApp: ApplicationNested = {
isPreviewDeploymentsActive: false, isPreviewDeploymentsActive: false,
previewBuildArgs: null, previewBuildArgs: null,
previewCertificateType: "none", previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null, previewEnv: null,
previewHttps: false, previewHttps: false,
previewPath: "/", previewPath: "/",

View File

@@ -23,6 +23,7 @@ const baseApp: ApplicationNested = {
previewPath: "/", previewPath: "/",
previewPort: 3000, previewPort: 3000,
previewLimit: 0, previewLimit: 0,
previewCustomCertResolver: null,
previewWildcard: "", previewWildcard: "",
project: { project: {
env: "", env: "",
@@ -103,6 +104,7 @@ const baseDomain: Domain = {
port: null, port: null,
serviceName: "", serviceName: "",
composeId: "", composeId: "",
customCertResolver: null,
domainType: "application", domainType: "application",
uniqueConfigKey: 1, uniqueConfigKey: 1,
previewDeploymentId: "", previewDeploymentId: "",

View File

@@ -85,8 +85,20 @@ export const AddDomain = ({
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domain), resolver: zodResolver(domain),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
},
mode: "onChange",
}); });
const certificateType = form.watch("certificateType");
const https = form.watch("https");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
@@ -94,13 +106,29 @@ export const AddDomain = ({
/* Convert null to undefined */ /* Convert null to undefined */
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
}); });
} }
if (!domainId) { if (!domainId) {
form.reset({}); form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
});
} }
}, [form, form.reset, data, isLoading]); }, [form, data, isLoading, domainId]);
// Separate effect for handling custom cert resolver validation
useEffect(() => {
if (certificateType === "custom") {
form.trigger("customCertResolver");
}
}, [certificateType, form]);
const dictionary = { const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created", success: domainId ? "Domain Updated" : "Domain Created",
@@ -256,34 +284,73 @@ export const AddDomain = ({
)} )}
/> />
{form.getValues().https && ( {https && (
<FormField <>
control={form.control} <FormField
name="certificateType" control={form.control}
render={({ field }) => ( name="certificateType"
<FormItem className="col-span-2"> render={({ field }) => {
<FormLabel>Certificate Provider</FormLabel> return (
<Select <FormItem>
onValueChange={field.onChange} <FormLabel>Certificate Provider</FormLabel>
defaultValue={field.value || ""} <Select
> onValueChange={(value) => {
<FormControl> field.onChange(value);
<SelectTrigger> if (value !== "custom") {
<SelectValue placeholder="Select a certificate provider" /> form.setValue(
</SelectTrigger> "customCertResolver",
</FormControl> undefined,
);
}
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<SelectContent> {certificateType === "custom" && (
<SelectItem value="none">None</SelectItem> <FormField
<SelectItem value={"letsencrypt"}> control={form.control}
Let's Encrypt name="customCertResolver"
</SelectItem> render={({ field }) => {
</SelectContent> return (
</Select> <FormItem>
<FormMessage /> <FormLabel>Custom Certificate Resolver</FormLabel>
</FormItem> <FormControl>
<Input
className="w-full"
placeholder="Enter your custom certificate resolver"
{...field}
value={field.value || ""}
onChange={(e) => {
field.onChange(e);
form.trigger("customCertResolver");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)} )}
/> </>
)} )}
</div> </div>
</div> </div>

View File

@@ -94,6 +94,7 @@ export const AddPreviewDomain = ({
/* Convert null to undefined */ /* Convert null to undefined */
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
customCertResolver: data?.customCertResolver || undefined,
}); });
} }

View File

@@ -35,16 +35,30 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const schema = z.object({ const schema = z
env: z.string(), .object({
buildArgs: z.string(), env: z.string(),
wildcardDomain: z.string(), buildArgs: z.string(),
port: z.number(), wildcardDomain: z.string(),
previewLimit: z.number(), port: z.number(),
previewHttps: z.boolean(), previewLimit: z.number(),
previewPath: z.string(), previewHttps: z.boolean(),
previewCertificateType: z.enum(["letsencrypt", "none"]), previewPath: z.string(),
}); previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
previewCustomCertResolver: z.string().optional(),
})
.superRefine((input, ctx) => {
if (
input.previewCertificateType === "custom" &&
!input.previewCustomCertResolver
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["previewCustomCertResolver"],
message: "Required",
});
}
});
type Schema = z.infer<typeof schema>; type Schema = z.infer<typeof schema>;
@@ -90,6 +104,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: data.previewHttps || false, previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/", previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none", previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
}); });
} }
}, [data]); }, [data]);
@@ -105,6 +120,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: formData.previewHttps, previewHttps: formData.previewHttps,
previewPath: formData.previewPath, previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType, previewCertificateType: formData.previewCertificateType,
previewCustomCertResolver: formData.previewCustomCertResolver,
}) })
.then(() => { .then(() => {
toast.success("Preview Deployments settings updated"); toast.success("Preview Deployments settings updated");
@@ -184,10 +200,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Preview Limit</FormLabel> <FormLabel>Preview Limit</FormLabel>
{/* <FormDescription>
Set the limit of preview deployments that can be
created for this app.
</FormDescription> */}
<FormControl> <FormControl>
<NumberInput placeholder="3000" {...field} /> <NumberInput placeholder="3000" {...field} />
</FormControl> </FormControl>
@@ -238,6 +250,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Let's Encrypt Let's Encrypt
</SelectItem> </SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -245,6 +258,25 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
)} )}
/> />
)} )}
{form.watch("previewCertificateType") === "custom" && (
<FormField
control={form.control}
name="previewCustomCertResolver"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Provider</FormLabel>
<FormControl>
<Input
placeholder="my-custom-resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2"> <div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2">

View File

@@ -104,6 +104,15 @@ export const AddDomainCompose = ({
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domainCompose), resolver: zodResolver(domainCompose),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
},
}); });
const https = form.watch("https"); const https = form.watch("https");
@@ -116,11 +125,21 @@ export const AddDomainCompose = ({
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
serviceName: data?.serviceName || undefined, serviceName: data?.serviceName || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
}); });
} }
if (!domainId) { if (!domainId) {
form.reset({}); form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
});
} }
}, [form, form.reset, data, isLoading]); }, [form, form.reset, data, isLoading]);
@@ -393,33 +412,55 @@ export const AddDomainCompose = ({
/> />
{https && ( {https && (
<FormField <>
control={form.control} <FormField
name="certificateType" control={form.control}
render={({ field }) => ( name="certificateType"
<FormItem className="col-span-2"> render={({ field }) => (
<FormLabel>Certificate Provider</FormLabel> <FormItem className="col-span-2">
<Select <FormLabel>Certificate Provider</FormLabel>
onValueChange={field.onChange} <Select
defaultValue={field.value || ""} onValueChange={field.onChange}
> defaultValue={field.value || ""}
<FormControl> >
<SelectTrigger> <FormControl>
<SelectValue placeholder="Select a certificate provider" /> <SelectTrigger>
</SelectTrigger> <SelectValue placeholder="Select a certificate provider" />
</FormControl> </SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Let's Encrypt Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> <SelectItem value={"custom"}>Custom</SelectItem>
</Select> </SelectContent>
<FormMessage /> </Select>
</FormItem> <FormMessage />
</FormItem>
)}
/>
{form.getValues().certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
placeholder="Enter your custom certificate resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)} )}
/> </>
)} )}
</div> </div>
</div> </div>

View File

@@ -35,7 +35,7 @@ const addServerDomain = z
.object({ .object({
domain: z.string().min(1, { message: "URL is required" }), domain: z.string().min(1, { message: "URL is required" }),
letsEncryptEmail: z.string(), letsEncryptEmail: z.string(),
certificateType: z.enum(["letsencrypt", "none"]), certificateType: z.enum(["letsencrypt", "none", "custom"]),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) { if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
@@ -193,6 +193,7 @@ export const WebDomain = () => {
); );
}} }}
/> />
<div className="flex w-full justify-end col-span-2"> <div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isLoading} type="submit">
{t("settings.common.save")} {t("settings.common.save")}

View File

@@ -55,7 +55,7 @@ export const CodeEditor = ({
)} )}
/> />
{props.disabled && ( {props.disabled && (
<div className="absolute top-0 rounded-md left-0 w-full h-full flex items-center justify-center z-[10] [background:var(--overlay)]" /> <div className="absolute top-0 rounded-md left-0 w-full h-full flex items-center justify-center z-[10] [background:var(--overlay)] h-full" />
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."certificateType" ADD VALUE 'custom';--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "customCertResolver" text;--> statement-breakpoint

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "previewCertificateProvider" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "application" RENAME COLUMN "previewCertificateProvider" TO "previewCustomCertResolver";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -505,6 +505,27 @@
"when": 1741460060541, "when": 1741460060541,
"tag": "0071_flaky_black_queen", "tag": "0071_flaky_black_queen",
"breakpoints": true "breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1741487009559,
"tag": "0072_green_susan_delgado",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1741489681190,
"tag": "0073_hot_domino",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1741490064139,
"tag": "0074_black_quasar",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,7 +10,8 @@ export const domain = z
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if (input.https && !input.certificateType) { if (input.https && !input.certificateType) {
@@ -20,6 +21,14 @@ export const domain = z
message: "Required", message: "Required",
}); });
} }
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required",
});
}
}); });
export const domainCompose = z export const domainCompose = z
@@ -32,7 +41,8 @@ export const domainCompose = z
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
serviceName: z.string().min(1, { message: "Service name is required" }), serviceName: z.string().min(1, { message: "Service name is required" }),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
@@ -43,4 +53,12 @@ export const domainCompose = z
message: "Required", message: "Required",
}); });
} }
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required",
});
}
}); });

View File

@@ -124,6 +124,7 @@ export const applications = pgTable("application", {
previewCertificateType: certificateType("certificateType") previewCertificateType: certificateType("certificateType")
.notNull() .notNull()
.default("none"), .default("none"),
previewCustomCertResolver: text("previewCustomCertResolver"),
previewLimit: integer("previewLimit").default(3), previewLimit: integer("previewLimit").default(3),
isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default( isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default(
false, false,
@@ -404,7 +405,7 @@ const createSchema = createInsertSchema(applications, {
previewLimit: z.number().optional(), previewLimit: z.number().optional(),
previewHttps: z.boolean().optional(), previewHttps: z.boolean().optional(),
previewPath: z.string().optional(), previewPath: z.string().optional(),
previewCertificateType: z.enum(["letsencrypt", "none"]).optional(), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
}); });
export const apiCreateApplication = createSchema.pick({ export const apiCreateApplication = createSchema.pick({

View File

@@ -41,6 +41,7 @@ export const domains = pgTable("domain", {
composeId: text("composeId").references(() => compose.composeId, { composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
customCertResolver: text("customCertResolver"),
applicationId: text("applicationId").references( applicationId: text("applicationId").references(
() => applications.applicationId, () => applications.applicationId,
{ onDelete: "cascade" }, { onDelete: "cascade" },
@@ -76,6 +77,7 @@ export const apiCreateDomain = createSchema.pick({
https: true, https: true,
applicationId: true, applicationId: true,
certificateType: true, certificateType: true,
customCertResolver: true,
composeId: true, composeId: true,
serviceName: true, serviceName: true,
domainType: true, domainType: true,
@@ -107,6 +109,7 @@ export const apiUpdateDomain = createSchema
port: true, port: true,
https: true, https: true,
certificateType: true, certificateType: true,
customCertResolver: true,
serviceName: true, serviceName: true,
domainType: true, domainType: true,
}) })

View File

@@ -10,4 +10,5 @@ export const applicationStatus = pgEnum("applicationStatus", [
export const certificateType = pgEnum("certificateType", [ export const certificateType = pgEnum("certificateType", [
"letsencrypt", "letsencrypt",
"none", "none",
"custom",
]); ]);

View File

@@ -10,7 +10,8 @@ export const domain = z
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if (input.https && !input.certificateType) { if (input.https && !input.certificateType) {
@@ -20,6 +21,14 @@ export const domain = z
message: "Required", message: "Required",
}); });
} }
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required when certificate type is custom",
});
}
}); });
export const domainCompose = z export const domainCompose = z
@@ -32,7 +41,8 @@ export const domainCompose = z
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string(),
serviceName: z.string().min(1, { message: "Service name is required" }), serviceName: z.string().min(1, { message: "Service name is required" }),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
@@ -43,4 +53,12 @@ export const domainCompose = z
message: "Required", message: "Required",
}); });
} }
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required when certificate type is custom",
});
}
}); });

View File

@@ -203,6 +203,7 @@ export const createPreviewDeployment = async (
port: application.previewPort, port: application.previewPort,
https: application.previewHttps, https: application.previewHttps,
certificateType: application.previewCertificateType, certificateType: application.previewCertificateType,
customCertResolver: application.previewCustomCertResolver,
domainType: "preview", domainType: "preview",
previewDeploymentId: previewDeployment.previewDeploymentId, previewDeploymentId: previewDeployment.previewDeploymentId,
}); });

View File

@@ -211,13 +211,9 @@ export const addDomainToCompose = async (
throw new Error(`The service ${serviceName} not found in the compose`); throw new Error(`The service ${serviceName} not found in the compose`);
} }
const httpLabels = await createDomainLabels(appName, domain, "web"); const httpLabels = createDomainLabels(appName, domain, "web");
if (https) { if (https) {
const httpsLabels = await createDomainLabels( const httpsLabels = createDomainLabels(appName, domain, "websecure");
appName,
domain,
"websecure",
);
httpLabels.push(...httpsLabels); httpLabels.push(...httpsLabels);
} }
@@ -279,12 +275,20 @@ export const writeComposeFile = async (
} }
}; };
export const createDomainLabels = async ( export const createDomainLabels = (
appName: string, appName: string,
domain: Domain, domain: Domain,
entrypoint: "web" | "websecure", entrypoint: "web" | "websecure",
) => { ) => {
const { host, port, https, uniqueConfigKey, certificateType, path } = domain; const {
host,
port,
https,
uniqueConfigKey,
certificateType,
path,
customCertResolver,
} = domain;
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`; const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
const labels = [ const labels = [
`traefik.http.routers.${routerName}.rule=Host(\`${host}\`)${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, `traefik.http.routers.${routerName}.rule=Host(\`${host}\`)${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
@@ -304,6 +308,10 @@ export const createDomainLabels = async (
labels.push( labels.push(
`traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`, `traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`,
); );
} else if (certificateType === "custom" && customCertResolver) {
labels.push(
`traefik.http.routers.${routerName}.tls.certresolver=${customCertResolver}`,
);
} }
} }

View File

@@ -148,6 +148,8 @@ export const createRouterConfig = async (
if (entryPoint === "websecure") { if (entryPoint === "websecure") {
if (certificateType === "letsencrypt") { if (certificateType === "letsencrypt") {
routerConfig.tls = { certResolver: "letsencrypt" }; routerConfig.tls = { certResolver: "letsencrypt" };
} else if (certificateType === "custom" && domain.customCertResolver) {
routerConfig.tls = { certResolver: domain.customCertResolver };
} else if (certificateType === "none") { } else if (certificateType === "none") {
routerConfig.tls = undefined; routerConfig.tls = undefined;
} }