diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts
index 356d429a4..f2abbc4bb 100644
--- a/apps/dokploy/__test__/dns/cloudflare.test.ts
+++ b/apps/dokploy/__test__/dns/cloudflare.test.ts
@@ -87,6 +87,154 @@ describe("cloudflareClient.listRecords", () => {
});
});
+describe("cloudflareClient MX priority", () => {
+ it("inlines the priority into the content when listing", async () => {
+ mockFetch.mockResolvedValue(
+ cfSuccess([
+ {
+ id: "mx-1",
+ type: "MX",
+ name: "example.com",
+ content: "mail.example.com",
+ ttl: 300,
+ priority: 20,
+ },
+ ]),
+ );
+
+ const records = await cloudflareClient.listRecords(config, "zone-1");
+
+ expect(records[0]?.content).toBe("20 mail.example.com");
+ });
+
+ it("splits the priority back out when writing", async () => {
+ mockFetch
+ .mockResolvedValueOnce(cfSuccess([]))
+ .mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
+
+ await cloudflareClient.upsertRecord(config, {
+ zoneId: "zone-1",
+ type: "MX",
+ name: "example.com",
+ content: "20 mail.example.com",
+ });
+
+ const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
+ expect(JSON.parse(init.body as string)).toMatchObject({
+ content: "mail.example.com",
+ priority: 20,
+ });
+ });
+
+ it("falls back to priority 10 when the content has no leading number", async () => {
+ mockFetch
+ .mockResolvedValueOnce(cfSuccess([]))
+ .mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
+
+ await cloudflareClient.upsertRecord(config, {
+ zoneId: "zone-1",
+ type: "MX",
+ name: "example.com",
+ content: "mail.example.com",
+ });
+
+ const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
+ expect(JSON.parse(init.body as string)).toMatchObject({
+ content: "mail.example.com",
+ priority: 10,
+ });
+ });
+
+ it("leaves non-MX content untouched", async () => {
+ mockFetch
+ .mockResolvedValueOnce(cfSuccess([]))
+ .mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
+
+ await cloudflareClient.upsertRecord(config, {
+ zoneId: "zone-1",
+ type: "TXT",
+ name: "example.com",
+ content: "10 not-a-priority",
+ });
+
+ const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
+ const body = JSON.parse(init.body as string);
+ expect(body.content).toBe("10 not-a-priority");
+ expect(body.priority).toBeUndefined();
+ });
+});
+
+describe("cloudflareClient proxy status", () => {
+ it("sends the proxy status for proxiable types", async () => {
+ mockFetch
+ .mockResolvedValueOnce(cfSuccess([]))
+ .mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
+
+ await cloudflareClient.upsertRecord(config, {
+ zoneId: "zone-1",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ proxied: true,
+ });
+
+ const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
+ expect(JSON.parse(init.body as string).proxied).toBe(true);
+ });
+
+ it("omits the proxy status for types Cloudflare cannot proxy", async () => {
+ mockFetch
+ .mockResolvedValueOnce(cfSuccess([]))
+ .mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
+
+ await cloudflareClient.upsertRecord(config, {
+ zoneId: "zone-1",
+ type: "TXT",
+ name: "example.com",
+ content: "hello",
+ proxied: true,
+ });
+
+ const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
+ expect(JSON.parse(init.body as string).proxied).toBeUndefined();
+ });
+
+ it("leaves the proxy status untouched when the caller does not set it", async () => {
+ mockFetch
+ .mockResolvedValueOnce(cfSuccess([]))
+ .mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
+
+ await cloudflareClient.upsertRecord(config, {
+ zoneId: "zone-1",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ });
+
+ const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
+ expect("proxied" in JSON.parse(init.body as string)).toBe(false);
+ });
+
+ it("returns the proxy status when listing records", async () => {
+ mockFetch.mockResolvedValue(
+ cfSuccess([
+ {
+ id: "a-1",
+ type: "A",
+ name: "app.example.com",
+ content: "1.2.3.4",
+ ttl: 1,
+ proxied: true,
+ },
+ ]),
+ );
+
+ const records = await cloudflareClient.listRecords(config, "zone-1");
+
+ expect(records[0]?.proxied).toBe(true);
+ });
+});
+
describe("cloudflareClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx
new file mode 100644
index 000000000..20accf405
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx
@@ -0,0 +1,32 @@
+import { useRouter } from "next/router";
+import { useEffect, useState } from "react";
+
+let lastDepth: number | null = null;
+
+const depthOf = (path: string) =>
+ path.split("?")[0]!.split("/").filter(Boolean).length;
+
+export const DnsPageTransition = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => {
+ const { asPath } = useRouter();
+ const depth = depthOf(asPath);
+ const [direction] = useState(() =>
+ lastDepth !== null && depth < lastDepth ? "back" : "forward",
+ );
+
+ useEffect(() => {
+ lastDepth = depth;
+ }, [depth]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx
new file mode 100644
index 000000000..3f06581a5
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx
@@ -0,0 +1,375 @@
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { Cloud, CloudOff, XIcon } from "lucide-react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+import { api } from "@/utils/api";
+
+export const DNS_RECORD_TYPES = [
+ "A",
+ "AAAA",
+ "CNAME",
+ "MX",
+ "TXT",
+ "NS",
+ "SRV",
+ "CAA",
+ "PTR",
+] as const;
+
+type RecordType = (typeof DNS_RECORD_TYPES)[number];
+
+export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"];
+
+const valueFields: Record<
+ RecordType,
+ { label: string; placeholder: string; hint?: string }
+> = {
+ A: { label: "IPv4 address", placeholder: "203.0.113.10" },
+ AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" },
+ CNAME: { label: "Target", placeholder: "app.example.com" },
+ MX: {
+ label: "Mail server",
+ placeholder: "10 mail.example.com",
+ hint: "Start with the priority, then the mail server.",
+ },
+ TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" },
+ NS: { label: "Nameserver", placeholder: "ns1.example.com" },
+ SRV: {
+ label: "Target",
+ placeholder: "1 10 5269 talk.example.com",
+ hint: "Priority, weight, port, then target.",
+ },
+ CAA: {
+ label: "Value",
+ placeholder: '0 issue "letsencrypt.org"',
+ hint: "Flags, tag, then the quoted value.",
+ },
+ PTR: { label: "Target", placeholder: "host.example.com" },
+};
+
+const DnsRecordSchema = z.object({
+ type: z.enum(DNS_RECORD_TYPES),
+ name: z.string().min(1, { message: "Name is required" }),
+ content: z.string().min(1, { message: "Content is required" }),
+ ttl: z.string(),
+ proxied: z.boolean(),
+});
+
+type DnsRecordForm = z.infer;
+
+export interface DnsRecordValue {
+ id: string;
+ type: string;
+ name: string;
+ content: string;
+ ttl: number;
+ proxied?: boolean;
+}
+
+interface Props {
+ dnsProviderId: string;
+ zoneId: string;
+ zoneName: string;
+ record: DnsRecordValue | null;
+ onClose: () => void;
+}
+
+export const DnsRecordPanel = ({
+ dnsProviderId,
+ zoneId,
+ zoneName,
+ record,
+ onClose,
+}: Props) => {
+ const utils = api.useUtils();
+ const createRecord = api.dnsProvider.createRecord.useMutation();
+ const updateRecord = api.dnsProvider.updateRecord.useMutation();
+ const { mutateAsync, isPending, error, isError } = record
+ ? updateRecord
+ : createRecord;
+
+ const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
+ const { data: servers } = api.server.all.useQuery();
+ const { data: panelPublicIp } = api.server.publicIp.useQuery();
+ const { data: panelStoredIp } = api.settings.getIp.useQuery();
+
+ const panelIp = panelPublicIp || panelStoredIp;
+ const ipSuggestions = [
+ ...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
+ ...(servers ?? []).map((server) => ({
+ ip: server.ipAddress,
+ label: server.name,
+ })),
+ ].filter(
+ (suggestion, index, all) =>
+ !!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
+ );
+
+ const form = useForm({
+ defaultValues: record
+ ? {
+ type: (DNS_RECORD_TYPES.includes(record.type as RecordType)
+ ? record.type
+ : "A") as RecordType,
+ name: record.name,
+ content: record.content,
+ ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
+ proxied: record.proxied ?? false,
+ }
+ : { type: "A", name: "", content: "", ttl: "", proxied: false },
+ resolver: zodResolver(DnsRecordSchema),
+ });
+
+ const type = form.watch("type");
+ const proxied = form.watch("proxied");
+ const canProxy =
+ provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
+ const usesAutomaticTtl = canProxy && proxied;
+
+ const onSubmit = async (data: DnsRecordForm) => {
+ const name = data.name.trim() === "@" ? zoneName : data.name;
+ const isProxied = canProxy && data.proxied;
+ const payload = {
+ dnsProviderId,
+ zoneId,
+ type: data.type,
+ name,
+ content: data.content,
+ ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined,
+ ...(canProxy && { proxied: data.proxied }),
+ ...(record && { recordId: record.id }),
+ };
+ await mutateAsync(payload as any)
+ .then(() => {
+ toast.success(record ? "Record updated" : "Record created");
+ utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
+ onClose();
+ })
+ .catch(() => {});
+ };
+
+ return (
+
+
+
+
+ {record ? "Edit record" : "New record"}
+
+ {zoneName}
+
+
+
+
+ {isError &&
{error?.message}}
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx
new file mode 100644
index 000000000..2673bd0c7
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx
@@ -0,0 +1,33 @@
+import { cn } from "@/lib/utils";
+
+const recordTypeStyles: Record = {
+ A: "bg-blue-500/10 text-blue-700 ring-blue-500/25 dark:text-blue-300",
+ AAAA: "bg-indigo-500/10 text-indigo-700 ring-indigo-500/25 dark:text-indigo-300",
+ CNAME:
+ "bg-violet-500/10 text-violet-700 ring-violet-500/25 dark:text-violet-300",
+ MX: "bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-300",
+ TXT: "bg-emerald-500/10 text-emerald-700 ring-emerald-500/25 dark:text-emerald-300",
+ NS: "bg-cyan-500/10 text-cyan-700 ring-cyan-500/25 dark:text-cyan-300",
+ SRV: "bg-rose-500/10 text-rose-700 ring-rose-500/25 dark:text-rose-300",
+ CAA: "bg-teal-500/10 text-teal-700 ring-teal-500/25 dark:text-teal-300",
+ PTR: "bg-orange-500/10 text-orange-700 ring-orange-500/25 dark:text-orange-300",
+ SOA: "bg-slate-500/10 text-slate-700 ring-slate-500/25 dark:text-slate-300",
+};
+
+interface Props {
+ type: string;
+ className?: string;
+}
+
+export const DnsRecordTypeBadge = ({ type, className }: Props) => (
+
+ {type.toUpperCase()}
+
+);
diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx
index 67d6a7547..166b00c3e 100644
--- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx
+++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx
@@ -33,6 +33,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
const providerLabels = {
@@ -180,22 +185,30 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
return (