feat: add DNS provider integration (Cloudflare, AWS Route53)

Lets you connect a DNS provider and manage its records (create,
update, delete) from Settings -> DNS Providers, instead of doing it
by hand in Cloudflare/AWS.

- dns_provider table, org-scoped, jsonb config as a discriminated
  union per provider type
- Cloudflare adapter (REST, bearer token)
- Route53 adapter (AWS SDK, SigV4); records are identified by
  type:name since Route53 has no native record id, so update
  handles renames as delete-old + upsert-new
- listZones/listRecords/createRecord/updateRecord/deleteRecord/
  testConnection wired through a shared DnsClient interface
- Settings UI: provider management, zone browser, record CRUD,
  IP-fill dropdown for A records (panel IP + remote servers)
- Access control: dnsProvider resource wired into custom roles
- Unit tests for both adapters and the config mask/merge logic
This commit is contained in:
Mauricio Siu
2026-08-12 03:25:25 -06:00
parent 3848fa9c0d
commit 4d58f3987f
29 changed files with 12233 additions and 18 deletions
+2 -1
View File
@@ -93,4 +93,5 @@ export type AuditResourceType =
| "application"
| "compose"
| "network"
| "vaultProvider";
| "vaultProvider"
| "dnsProvider";
@@ -0,0 +1,122 @@
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
export const dnsProviderType = pgEnum("DnsProviderType", [
"cloudflare",
"route53",
]);
export const cloudflareDnsConfigSchema = z.object({
providerType: z.literal("cloudflare"),
apiToken: z.string().trim().min(1),
});
export const route53DnsConfigSchema = z.object({
providerType: z.literal("route53"),
accessKeyId: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
endpoint: z.string().trim().url().optional(),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
cloudflareDnsConfigSchema,
route53DnsConfigSchema,
]);
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
export const dnsProvider = pgTable(
"dns_provider",
{
dnsProviderId: text("dnsProviderId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
providerType: dnsProviderType("providerType").notNull(),
config: jsonb("config").$type<DnsProviderConfig>().notNull(),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
},
(table) => [
uniqueIndex("dns_provider_org_name_idx").on(
table.organizationId,
table.name,
),
],
);
const dnsProviderNameSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-zA-Z0-9_-]+$/,
"Name can only contain letters, numbers, dashes and underscores",
);
const createSchema = createInsertSchema(dnsProvider);
export const apiCreateDnsProvider = createSchema.pick({}).extend({
name: dnsProviderNameSchema,
config: dnsProviderConfigSchema,
});
export const apiUpdateDnsProvider = createSchema.pick({}).extend({
dnsProviderId: z.string().min(1),
name: dnsProviderNameSchema,
config: dnsProviderConfigSchema,
});
export const apiFindOneDnsProvider = z.object({
dnsProviderId: z.string().min(1),
});
export const apiRemoveDnsProvider = z.object({
dnsProviderId: z.string().min(1),
});
export const apiTestDnsProvider = z.object({
dnsProviderId: z.string().min(1).optional(),
config: dnsProviderConfigSchema.optional(),
});
export const apiListDnsZones = z.object({
dnsProviderId: z.string().min(1),
});
export const apiListDnsRecords = z.object({
dnsProviderId: z.string().min(1),
zoneId: z.string().min(1),
});
const dnsRecordFieldsSchema = z.object({
type: z.enum(["A", "CNAME"]),
name: z.string().min(1),
content: z.string().min(1),
ttl: z.number().int().positive().optional(),
});
export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({
dnsProviderId: z.string().min(1),
zoneId: z.string().min(1),
});
export const apiUpdateDnsRecord = dnsRecordFieldsSchema.extend({
dnsProviderId: z.string().min(1),
zoneId: z.string().min(1),
recordId: z.string().min(1),
});
export const apiDeleteDnsRecord = z.object({
dnsProviderId: z.string().min(1),
zoneId: z.string().min(1),
recordId: z.string().min(1),
});
+1
View File
@@ -8,6 +8,7 @@ export * from "./certificate";
export * from "./compose";
export * from "./deployment";
export * from "./destination";
export * from "./dns-provider";
export * from "./domain";
export * from "./environment";
export * from "./forward-auth";
+1
View File
@@ -16,6 +16,7 @@ export * from "./services/cluster";
export * from "./services/compose";
export * from "./services/deployment";
export * from "./services/destination";
export * from "./services/dns-provider";
export * from "./services/docker";
export * from "./services/docker-volume";
export * from "./services/domain";
@@ -49,6 +49,7 @@ export const statements = {
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
} as const;
/**
@@ -76,6 +77,7 @@ export const enterpriseOnlyResources = new Set<string>([
"monitoring",
"auditLog",
"vaultProvider",
"dnsProvider",
]);
export const ac = createAccessControl(statements);
@@ -116,6 +118,7 @@ export const ownerRole = ac.newRole({
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
});
/**
@@ -154,6 +157,7 @@ export const adminRole = ac.newRole({
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
});
/**
@@ -198,4 +202,6 @@ export const memberRole = ac.newRole({
auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"],
// Members can see configured DNS providers to pick one when adding a domain
dnsProvider: ["read"],
});
@@ -0,0 +1,278 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateDnsProvider,
type DnsProviderConfig,
dnsProvider,
} from "@dokploy/server/db/schema";
import type { DnsRecordInput } from "@dokploy/server/utils/dns";
import { getDnsClient } from "@dokploy/server/utils/dns";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type DnsProvider = typeof dnsProvider.$inferSelect;
export const DNS_SECRET_MASK = "********";
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
};
export const maskDnsProviderConfig = (
config: DnsProviderConfig,
): DnsProviderConfig => {
const masked: Record<string, unknown> = { ...config };
for (const field of SENSITIVE_FIELDS[config.providerType]) {
if (masked[field]) {
masked[field] = DNS_SECRET_MASK;
}
}
return masked as DnsProviderConfig;
};
export const mergeDnsProviderConfig = (
incoming: DnsProviderConfig,
existing: DnsProviderConfig,
): DnsProviderConfig => {
const merged: Record<string, unknown> = { ...incoming };
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
if (merged[field] === DNS_SECRET_MASK) {
if (incoming.providerType !== existing.providerType) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Credentials must be re-entered when changing the provider type",
});
}
merged[field] = (existing as Record<string, unknown>)[field];
}
}
return merged as DnsProviderConfig;
};
const isUniqueNameViolation = (error: unknown) =>
error instanceof Error && error.message.includes("dns_provider_org_name_idx");
export const createDnsProvider = async (
input: z.infer<typeof apiCreateDnsProvider>,
organizationId: string,
) => {
try {
const newProvider = await db
.insert(dnsProvider)
.values({
name: input.name,
providerType: input.config.providerType,
config: input.config,
organizationId,
})
.returning()
.then((value) => value[0]);
if (!newProvider) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the DNS provider",
});
}
return newProvider;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A DNS provider named "${input.name}" already exists in this organization`,
});
}
throw error;
}
};
export const findDnsProviderById = async (dnsProviderId: string) => {
const provider = await db.query.dnsProvider.findFirst({
where: eq(dnsProvider.dnsProviderId, dnsProviderId),
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message: "DNS provider not found",
});
}
return provider;
};
export const findDnsProviderInOrganization = async (
dnsProviderId: string,
organizationId: string,
) => {
const provider = await findDnsProviderById(dnsProviderId);
if (provider.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this DNS provider",
});
}
return provider;
};
export const findDnsProvidersByOrganizationId = async (
organizationId: string,
) => {
return await db.query.dnsProvider.findMany({
where: eq(dnsProvider.organizationId, organizationId),
orderBy: (providers, { asc }) => [asc(providers.name)],
});
};
export const updateDnsProvider = async (
dnsProviderId: string,
name: string,
config: DnsProviderConfig,
) => {
const existing = await findDnsProviderById(dnsProviderId);
const mergedConfig = mergeDnsProviderConfig(config, existing.config);
try {
const updated = await db
.update(dnsProvider)
.set({
name,
providerType: mergedConfig.providerType,
config: mergedConfig,
})
.where(eq(dnsProvider.dnsProviderId, dnsProviderId))
.returning()
.then((res) => res[0]);
if (!updated) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating the DNS provider",
});
}
return updated;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A DNS provider named "${name}" already exists in this organization`,
});
}
throw error;
}
};
export const removeDnsProvider = async (dnsProviderId: string) => {
const removed = await db
.delete(dnsProvider)
.where(eq(dnsProvider.dnsProviderId, dnsProviderId))
.returning()
.then((res) => res[0]);
if (!removed) {
throw new TRPCError({
code: "NOT_FOUND",
message: "DNS provider not found",
});
}
return removed;
};
export const testDnsProviderConnection = async (config: DnsProviderConfig) => {
const client = getDnsClient(config.providerType);
try {
await client.testConnection(config);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error connecting to the DNS provider",
});
}
};
export const listDnsProviderZones = async (config: DnsProviderConfig) => {
const client = getDnsClient(config.providerType);
try {
return await client.listZones(config);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error listing zones for this DNS provider",
});
}
};
export const listDnsProviderRecords = async (
config: DnsProviderConfig,
zoneId: string,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.listRecords(config, zoneId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error listing records for this zone",
});
}
};
export const createDnsProviderRecord = async (
config: DnsProviderConfig,
record: DnsRecordInput,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.upsertRecord(config, record);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error creating the record",
});
}
};
export const updateDnsProviderRecord = async (
config: DnsProviderConfig,
zoneId: string,
recordId: string,
record: Omit<DnsRecordInput, "zoneId">,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.updateRecord(config, zoneId, recordId, record);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error updating the record",
});
}
};
export const deleteDnsProviderRecord = async (
config: DnsProviderConfig,
zoneId: string,
recordId: string,
) => {
const client = getDnsClient(config.providerType);
try {
await client.deleteRecord(config, zoneId, recordId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error deleting the record",
});
}
};
+143
View File
@@ -0,0 +1,143 @@
import type { cloudflareDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type CloudflareConfig = z.infer<typeof cloudflareDnsConfigSchema>;
type CloudflareResponse<T> = {
success: boolean;
errors: { code: number; message: string }[];
result: T;
result_info?: { page: number; per_page: number; total_pages: number };
};
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
const cfFetch = async <T>(
config: CloudflareConfig,
path: string,
init: RequestInit = {},
): Promise<T> => {
const response = await dnsFetch(`${CLOUDFLARE_API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${config.apiToken.trim()}`,
"Content-Type": "application/json",
...init.headers,
},
});
const body = (await response.json()) as CloudflareResponse<T>;
if (!response.ok || !body.success) {
const detail = body.errors?.map((e) => e.message).join(", ");
throw new Error(
`Cloudflare: request to ${path} failed${detail ? `: ${detail}` : ` (status ${response.status})`}`,
);
}
return body.result;
};
export const cloudflareClient: DnsClient<CloudflareConfig> = {
async listZones(config) {
const zones: { id: string; name: string }[] = [];
let page = 1;
while (true) {
const result = await cfFetch<{ id: string; name: string }[]>(
config,
`/zones?per_page=50&page=${page}`,
);
zones.push(...result.map((zone) => ({ id: zone.id, name: zone.name })));
if (result.length < 50) {
break;
}
page += 1;
}
return zones;
},
async listRecords(config, zoneId) {
const records: {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[] = [];
let page = 1;
while (true) {
const result = await cfFetch<
{
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[]
>(config, `/zones/${zoneId}/dns_records?per_page=50&page=${page}`);
records.push(...result);
if (result.length < 50) {
break;
}
page += 1;
}
return records;
},
async upsertRecord(config, record) {
const existing = await cfFetch<{ id: string }[]>(
config,
`/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`,
);
const payload = {
type: record.type,
name: record.name,
content: record.content,
ttl: record.ttl ?? 1,
};
const existingRecord = existing[0];
if (existingRecord) {
const updated = await cfFetch<{ id: string }>(
config,
`/zones/${record.zoneId}/dns_records/${existingRecord.id}`,
{ method: "PUT", body: JSON.stringify(payload) },
);
return { id: updated.id };
}
const created = await cfFetch<{ id: string }>(
config,
`/zones/${record.zoneId}/dns_records`,
{ method: "POST", body: JSON.stringify(payload) },
);
return { id: created.id };
},
async updateRecord(config, zoneId, recordId, record) {
const updated = await cfFetch<{ id: string }>(
config,
`/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
body: JSON.stringify({
type: record.type,
name: record.name,
content: record.content,
ttl: record.ttl ?? 1,
}),
},
);
return { id: updated.id };
},
async deleteRecord(config, zoneId, recordId) {
await cfFetch(config, `/zones/${zoneId}/dns_records/${recordId}`, {
method: "DELETE",
});
},
async testConnection(config) {
await cfFetch(config, "/zones?per_page=1");
},
};
+14
View File
@@ -0,0 +1,14 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import { cloudflareClient } from "./cloudflare";
import { route53Client } from "./route53";
import type { DnsClient } from "./types";
const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
cloudflare: cloudflareClient as DnsClient,
route53: route53Client as DnsClient,
};
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>
clients[providerType];
export * from "./types";
+216
View File
@@ -0,0 +1,216 @@
import {
ChangeResourceRecordSetsCommand,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
type ResourceRecordSet,
Route53Client,
} from "@aws-sdk/client-route-53";
import type { route53DnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import type { DnsClient } from "./types";
type Route53Config = z.infer<typeof route53DnsConfigSchema>;
const createClient = (config: Route53Config) =>
new Route53Client({
region: "us-east-1",
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
...(config.endpoint && { endpoint: config.endpoint }),
});
const stripTrailingDot = (name: string) => name.replace(/\.$/, "");
const ensureTrailingDot = (name: string) =>
name.endsWith(".") ? name : `${name}.`;
const stripZonePrefix = (id: string) => id.replace(/^\/hostedzone\//, "");
const buildRecordId = (type: string, name: string) =>
`${type}:${stripTrailingDot(name)}`;
const parseRecordId = (id: string) => {
const separatorIndex = id.indexOf(":");
if (separatorIndex === -1) {
throw new Error(`Invalid Route53 record id: "${id}"`);
}
return {
type: id.slice(0, separatorIndex),
name: id.slice(separatorIndex + 1),
};
};
const findExactRecordSet = async (
config: Route53Config,
zoneId: string,
type: string,
name: string,
): Promise<ResourceRecordSet | undefined> => {
const client = createClient(config);
const response = await client.send(
new ListResourceRecordSetsCommand({
HostedZoneId: zoneId,
StartRecordName: ensureTrailingDot(name),
StartRecordType: type as ResourceRecordSet["Type"],
MaxItems: 1,
}),
);
const candidate = response.ResourceRecordSets?.[0];
if (
candidate &&
candidate.Type === type &&
stripTrailingDot(candidate.Name ?? "") === stripTrailingDot(name)
) {
return candidate;
}
return undefined;
};
const buildRecordSet = (record: {
type: "A" | "CNAME";
name: string;
content: string;
ttl?: number;
}): ResourceRecordSet => ({
Name: ensureTrailingDot(record.name),
Type: record.type,
TTL: record.ttl ?? 300,
ResourceRecords: [{ Value: record.content }],
});
export const route53Client: DnsClient<Route53Config> = {
async listZones(config) {
const client = createClient(config);
const zones: { id: string; name: string }[] = [];
let marker: string | undefined;
do {
const response = await client.send(
new ListHostedZonesCommand({ Marker: marker, MaxItems: 100 }),
);
for (const zone of response.HostedZones ?? []) {
if (zone.Id && zone.Name) {
zones.push({
id: stripZonePrefix(zone.Id),
name: stripTrailingDot(zone.Name),
});
}
}
marker = response.IsTruncated ? response.NextMarker : undefined;
} while (marker);
return zones;
},
async listRecords(config, zoneId) {
const client = createClient(config);
const records: {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[] = [];
let nextName: string | undefined;
let nextType: string | undefined;
do {
const response = await client.send(
new ListResourceRecordSetsCommand({
HostedZoneId: zoneId,
StartRecordName: nextName,
StartRecordType: nextType as ResourceRecordSet["Type"] | undefined,
}),
);
for (const set of response.ResourceRecordSets ?? []) {
if (!set.Name || !set.Type || !set.ResourceRecords?.length) {
continue;
}
records.push({
id: buildRecordId(set.Type, set.Name),
type: set.Type,
name: stripTrailingDot(set.Name),
content: set.ResourceRecords.map((r) => r.Value).join(", "),
ttl: set.TTL ?? 300,
});
}
nextName = response.IsTruncated ? response.NextRecordName : undefined;
nextType = response.IsTruncated ? response.NextRecordType : undefined;
} while (nextName);
return records;
},
async upsertRecord(config, record) {
const client = createClient(config);
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: record.zoneId,
ChangeBatch: {
Changes: [
{ Action: "UPSERT", ResourceRecordSet: buildRecordSet(record) },
],
},
}),
);
return { id: buildRecordId(record.type, record.name) };
},
async updateRecord(config, zoneId, recordIdInput, record) {
const { type: oldType, name: oldName } = parseRecordId(recordIdInput);
const client = createClient(config);
const changes: {
Action: "DELETE" | "UPSERT";
ResourceRecordSet: ResourceRecordSet;
}[] = [];
const sameIdentity =
oldType === record.type &&
stripTrailingDot(oldName) === stripTrailingDot(record.name);
if (!sameIdentity) {
const existing = await findExactRecordSet(
config,
zoneId,
oldType,
oldName,
);
if (existing) {
changes.push({ Action: "DELETE", ResourceRecordSet: existing });
}
}
changes.push({
Action: "UPSERT",
ResourceRecordSet: buildRecordSet(record),
});
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: zoneId,
ChangeBatch: { Changes: changes },
}),
);
return { id: buildRecordId(record.type, record.name) };
},
async deleteRecord(config, zoneId, recordIdInput) {
const { type, name } = parseRecordId(recordIdInput);
const existing = await findExactRecordSet(config, zoneId, type, name);
if (!existing) {
throw new Error(`Route53: record "${name}" (${type}) not found`);
}
const client = createClient(config);
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: zoneId,
ChangeBatch: {
Changes: [{ Action: "DELETE", ResourceRecordSet: existing }],
},
}),
);
},
async testConnection(config) {
const client = createClient(config);
await client.send(new ListHostedZonesCommand({ MaxItems: 1 }));
},
};
+45
View File
@@ -0,0 +1,45 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
export interface DnsZone {
id: string;
name: string;
}
export interface DnsRecordInput {
zoneId: string;
type: "A" | "CNAME";
name: string;
content: string;
ttl?: number;
}
export interface DnsRecord {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
export interface DnsClient<C extends DnsProviderConfig = DnsProviderConfig> {
listZones(config: C): Promise<DnsZone[]>;
listRecords(config: C, zoneId: string): Promise<DnsRecord[]>;
upsertRecord(config: C, record: DnsRecordInput): Promise<{ id: string }>;
updateRecord(
config: C,
zoneId: string,
recordId: string,
record: Omit<DnsRecordInput, "zoneId">,
): Promise<{ id: string }>;
deleteRecord(config: C, zoneId: string, recordId: string): Promise<void>;
testConnection(config: C): Promise<void>;
}
export const DNS_REQUEST_TIMEOUT_MS = 15_000;
export const dnsFetch = async (url: string, init: RequestInit = {}) => {
return await fetch(url, {
...init,
signal: AbortSignal.timeout(DNS_REQUEST_TIMEOUT_MS),
});
};