diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts
index cd344e201..6b3e0ab62 100644
--- a/apps/dokploy/__test__/dns/route53.test.ts
+++ b/apps/dokploy/__test__/dns/route53.test.ts
@@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => {
const records = await route53Client.listRecords(config, "Z123");
- expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
+ expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
- it("sends a single UPSERT change", async () => {
- send.mockResolvedValueOnce({});
+ it("sends a single UPSERT change when nothing exists yet", async () => {
+ send
+ .mockResolvedValueOnce({ ResourceRecordSets: [] })
+ .mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
@@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => {
});
expect(result).toEqual({ id: "A:app.example.com" });
- const command = send.mock.calls[0]?.[0] as HasInput;
+ const command = send.mock.calls[1]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
@@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => {
},
]);
});
+
+ it("keeps the values already in the record set", async () => {
+ send
+ .mockResolvedValueOnce({
+ ResourceRecordSets: [
+ {
+ Name: "app.example.com.",
+ Type: "A",
+ TTL: 300,
+ ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
+ },
+ ],
+ })
+ .mockResolvedValueOnce({});
+
+ await route53Client.upsertRecord(config, {
+ zoneId: "Z123",
+ type: "A",
+ name: "app.example.com",
+ content: "3.3.3.3",
+ });
+
+ const command = send.mock.calls[1]?.[0] as HasInput;
+ expect(
+ command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
+ ).toEqual([
+ { Value: "1.1.1.1" },
+ { Value: "2.2.2.2" },
+ { Value: "3.3.3.3" },
+ ]);
+ });
+
+ it("does not duplicate a value that is already in the record set", async () => {
+ send
+ .mockResolvedValueOnce({
+ ResourceRecordSets: [
+ {
+ Name: "app.example.com.",
+ Type: "A",
+ TTL: 300,
+ ResourceRecords: [{ Value: "1.1.1.1" }],
+ },
+ ],
+ })
+ .mockResolvedValueOnce({});
+
+ await route53Client.upsertRecord(config, {
+ zoneId: "Z123",
+ type: "A",
+ name: "app.example.com",
+ content: "1.1.1.1",
+ });
+
+ const command = send.mock.calls[1]?.[0] as HasInput;
+ expect(
+ command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
+ ).toEqual([{ Value: "1.1.1.1" }]);
+ });
+
+ it("wraps unquoted TXT values in double quotes", async () => {
+ send
+ .mockResolvedValueOnce({ ResourceRecordSets: [] })
+ .mockResolvedValueOnce({});
+
+ await route53Client.upsertRecord(config, {
+ zoneId: "Z123",
+ type: "TXT",
+ name: "example.com",
+ content: 'v=spf1 ~all\n"already quoted"',
+ });
+
+ const command = send.mock.calls[1]?.[0] as HasInput;
+ expect(
+ command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
+ ).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
+ });
});
describe("route53Client.updateRecord", () => {
@@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => {
]);
});
+ it("keeps every line of a multi-value record set", async () => {
+ send.mockResolvedValueOnce({});
+
+ await route53Client.updateRecord(config, "Z123", "NS:example.com", {
+ type: "NS",
+ name: "example.com",
+ content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
+ });
+
+ const command = send.mock.calls[0]?.[0] as HasInput;
+ expect(
+ command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
+ ).toEqual([
+ { Value: "ns1.example.com" },
+ { Value: "ns2.example.com" },
+ { Value: "ns3.example.com" },
+ ]);
+ });
+
it("skips the DELETE when the old record no longer exists", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx
index 237c2f32c..17dfc1166 100644
--- a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx
+++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx
@@ -22,6 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -82,8 +83,20 @@ const DnsRecordSchema = z
proxied: z.boolean(),
})
.superRefine((data, ctx) => {
+ const values = data.content
+ .split("\n")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ if (!values.length) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["content"],
+ message: "Content is required",
+ });
+ return;
+ }
const pattern = structuredValuePatterns[data.type];
- if (pattern && !pattern.test(data.content.trim())) {
+ if (pattern && !values.every((value) => pattern.test(value))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
@@ -161,6 +174,7 @@ export const DnsRecordPanel = ({
const proxied = form.watch("proxied");
const canProxy =
provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
+ const supportsMultipleValues = provider?.providerType === "route53";
const usesAutomaticTtl = canProxy && proxied;
const onSubmit = async (data: DnsRecordForm) => {
@@ -286,13 +300,29 @@ export const DnsRecordPanel = ({
{valueFields[type].label}
-
+ {supportsMultipleValues ? (
+
+ ) : (
+
+ )}
- {valueFields[type].hint && (
- {valueFields[type].hint}
+ {(supportsMultipleValues || valueFields[type].hint) && (
+
+ {[
+ valueFields[type].hint,
+ supportsMultipleValues &&
+ "One value per line: every line belongs to the same record set.",
+ ]
+ .filter(Boolean)
+ .join(" ")}
+
)}
diff --git a/packages/server/src/utils/dns/route53.ts b/packages/server/src/utils/dns/route53.ts
index c00bfbd68..8c7e70474 100644
--- a/packages/server/src/utils/dns/route53.ts
+++ b/packages/server/src/utils/dns/route53.ts
@@ -68,6 +68,9 @@ const findExactRecordSet = async (
return undefined;
};
+const formatValue = (type: DnsRecordType, value: string) =>
+ type === "TXT" && !value.startsWith('"') ? JSON.stringify(value) : value;
+
const buildRecordSet = (record: {
type: DnsRecordType;
name: string;
@@ -77,7 +80,11 @@ const buildRecordSet = (record: {
Name: ensureTrailingDot(record.name),
Type: record.type as ResourceRecordSet["Type"],
TTL: record.ttl ?? 300,
- ResourceRecords: [{ Value: record.content }],
+ ResourceRecords: record.content
+ .split("\n")
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .map((value) => ({ Value: formatValue(record.type, value) })),
});
export const route53Client: DnsClient = {
@@ -129,7 +136,7 @@ export const route53Client: DnsClient = {
id: buildRecordId(set.Type, set.Name),
type: set.Type,
name: stripTrailingDot(set.Name),
- content: set.ResourceRecords.map((r) => r.Value).join(", "),
+ content: set.ResourceRecords.map((r) => r.Value).join("\n"),
ttl: set.TTL ?? 300,
});
}
@@ -141,13 +148,25 @@ export const route53Client: DnsClient = {
async upsertRecord(config, record) {
const client = createClient(config);
+ const existing = await findExactRecordSet(
+ config,
+ record.zoneId,
+ record.type,
+ record.name,
+ );
+ const recordSet = buildRecordSet(record);
+ if (existing?.ResourceRecords?.length) {
+ const values = new Set([
+ ...existing.ResourceRecords.map((r) => r.Value),
+ ...(recordSet.ResourceRecords ?? []).map((r) => r.Value),
+ ]);
+ recordSet.ResourceRecords = [...values].map((Value) => ({ Value }));
+ }
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: record.zoneId,
ChangeBatch: {
- Changes: [
- { Action: "UPSERT", ResourceRecordSet: buildRecordSet(record) },
- ],
+ Changes: [{ Action: "UPSERT", ResourceRecordSet: recordSet }],
},
}),
);