From 67906b9672832a611faf86fd3c32a0d74af19257 Mon Sep 17 00:00:00 2001 From: tarai-dl Date: Mon, 20 Apr 2026 07:47:33 -0400 Subject: [PATCH] feat: add SFTP, FTP, and Google Drive backup destinations (#168) Add support for multiple backup destination types beyond S3: - SFTP: Secure File Transfer Protocol via SSH - FTP: Standard File Transfer Protocol - Google Drive: via service account authentication Changes: - Add destinationTypeEnum to DB schema (s3, sftp, ftp, gdrive) - Add getRcloneConfig() with type-specific credential handling - Add getSftpCredentials(), getFtpCredentials(), getGdriveCredentials() - Update backup utilities to use new destination abstraction - Add destination type selector UI with conditional form fields - Add DESTINATION_TYPES constant with descriptions Uses rclone for all destination types, keeping the existing S3 workflow unchanged while adding new backends via rclone's built-in support. Resolves #168 --- .../settings/destination/constants.ts | 27 + .../destination/handle-destinations.tsx | 606 +++++++++++++----- packages/server/src/db/schema/destination.ts | 99 ++- packages/server/src/utils/backups/compose.ts | 9 +- packages/server/src/utils/backups/mariadb.ts | 9 +- packages/server/src/utils/backups/mongo.ts | 9 +- packages/server/src/utils/backups/mysql.ts | 9 +- packages/server/src/utils/backups/postgres.ts | 9 +- packages/server/src/utils/backups/utils.ts | 166 ++++- .../server/src/utils/backups/web-server.ts | 18 +- 10 files changed, 723 insertions(+), 238 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/destination/constants.ts b/apps/dokploy/components/dashboard/settings/destination/constants.ts index f43e47d1a1..82c667361d 100644 --- a/apps/dokploy/components/dashboard/settings/destination/constants.ts +++ b/apps/dokploy/components/dashboard/settings/destination/constants.ts @@ -131,3 +131,30 @@ export const S3_PROVIDERS: Array<{ name: "Any other S3 compatible provider", }, ]; + +export const DESTINATION_TYPES: Array<{ + key: string; + name: string; + description: string; +}> = [ + { + key: "s3", + name: "S3 Compatible Storage", + description: "AWS S3, MinIO, DigitalOcean Spaces, and other S3-compatible providers", + }, + { + key: "sftp", + name: "SFTP", + description: "Secure File Transfer Protocol - remote server via SSH", + }, + { + key: "ftp", + name: "FTP", + description: "File Transfer Protocol - standard remote file server", + }, + { + key: "gdrive", + name: "Google Drive", + description: "Google Drive using a service account", + }, +]; diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index 966c8e5f5b..cfe4b801e2 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -33,20 +33,50 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; -import { S3_PROVIDERS } from "./constants"; +import { DESTINATION_TYPES, S3_PROVIDERS } from "./constants"; -const addDestination = z.object({ - name: z.string().min(1, "Name is required"), - provider: z.string().min(1, "Provider is required"), - accessKeyId: z.string().min(1, "Access Key Id is required"), - secretAccessKey: z.string().min(1, "Secret Access Key is required"), - bucket: z.string().min(1, "Bucket is required"), - region: z.string(), - endpoint: z.string().min(1, "Endpoint is required"), - serverId: z.string().optional(), -}); +const addDestination = z + .object({ + name: z.string().min(1, "Name is required"), + destinationType: z.enum(["s3", "sftp", "ftp", "gdrive"]), + // S3 fields + provider: z.string().optional(), + accessKeyId: z.string().optional(), + secretAccessKey: z.string().optional(), + bucket: z.string().optional(), + region: z.string().optional(), + endpoint: z.string().optional(), + // SFTP/FTP fields + host: z.string().optional(), + port: z.number().optional(), + username: z.string().optional(), + password: z.string().optional(), + remotePath: z.string().optional(), + // Google Drive fields + serviceAccountJson: z.string().optional(), + gdriveRootFolderId: z.string().optional(), + serverId: z.string().optional(), + }) + .refine( + (data) => { + if (data.destinationType === "s3") { + return !!data.accessKeyId && !!data.secretAccessKey && !!data.bucket; + } + if (data.destinationType === "sftp" || data.destinationType === "ftp") { + return !!data.host && !!data.username; + } + if (data.destinationType === "gdrive") { + return !!data.serviceAccountJson; + } + return true; + }, + { + message: "Required fields for the selected destination type are missing", + }, + ); type AddDestination = z.infer; @@ -82,6 +112,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { const form = useForm({ defaultValues: { + destinationType: "s3", provider: "", accessKeyId: "", bucket: "", @@ -89,19 +120,37 @@ export const HandleDestinations = ({ destinationId }: Props) => { region: "", secretAccessKey: "", endpoint: "", + host: "", + port: undefined, + username: "", + password: "", + remotePath: "", + serviceAccountJson: "", + gdriveRootFolderId: "", }, resolver: zodResolver(addDestination), }); + + const destinationType = form.watch("destinationType"); + useEffect(() => { if (destination) { form.reset({ name: destination.name, + destinationType: (destination.destinationType as "s3" | "sftp" | "ftp" | "gdrive") || "s3", provider: destination.provider || "", - accessKeyId: destination.accessKey, - secretAccessKey: destination.secretAccessKey, - bucket: destination.bucket, - region: destination.region, - endpoint: destination.endpoint, + accessKeyId: destination.accessKey || "", + secretAccessKey: destination.secretAccessKey || "", + bucket: destination.bucket || "", + region: destination.region || "", + endpoint: destination.endpoint || "", + host: destination.host || "", + port: destination.port || undefined, + username: destination.username || "", + password: destination.password || "", + remotePath: destination.remotePath || "", + serviceAccountJson: destination.serviceAccountJson || "", + gdriveRootFolderId: destination.gdriveRootFolderId || "", }); } else { form.reset(); @@ -110,13 +159,24 @@ export const HandleDestinations = ({ destinationId }: Props) => { const onSubmit = async (data: AddDestination) => { await mutateAsync({ - provider: data.provider || "", - accessKey: data.accessKeyId, - bucket: data.bucket, - endpoint: data.endpoint, name: data.name, - region: data.region, - secretAccessKey: data.secretAccessKey, + destinationType: data.destinationType, + // S3 + provider: data.provider || "", + accessKey: data.accessKeyId || "", + bucket: data.bucket || "", + endpoint: data.endpoint || "", + region: data.region || "", + secretAccessKey: data.secretAccessKey || "", + // SFTP/FTP + host: data.host || "", + port: data.port || undefined, + username: data.username || "", + password: data.password || "", + remotePath: data.remotePath || "", + // Google Drive + serviceAccountJson: data.serviceAccountJson || "", + gdriveRootFolderId: data.gdriveRootFolderId || "", destinationId: destinationId || "", }) .then(async () => { @@ -135,25 +195,27 @@ export const HandleDestinations = ({ destinationId }: Props) => { }; const handleTestConnection = async (serverId?: string) => { - const result = await form.trigger([ - "provider", - "accessKeyId", - "secretAccessKey", - "bucket", - "endpoint", - ]); + if (destinationType === "s3") { + const result = await form.trigger([ + "provider", + "accessKeyId", + "secretAccessKey", + "bucket", + "endpoint", + ]); - if (!result) { - const errors = form.formState.errors; - const errorFields = Object.entries(errors) - .map(([field, error]) => `${field}: ${error?.message}`) - .filter(Boolean) - .join("\n"); + if (!result) { + const errors = form.formState.errors; + const errorFields = Object.entries(errors) + .map(([field, error]) => `${field}: ${error?.message}`) + .filter(Boolean) + .join("\n"); - toast.error("Please fill all required fields", { - description: errorFields, - }); - return; + toast.error("Please fill all required fields", { + description: errorFields, + }); + return; + } } if (isCloud && !serverId) { @@ -161,23 +223,16 @@ export const HandleDestinations = ({ destinationId }: Props) => { return; } - const provider = form.getValues("provider"); - const accessKey = form.getValues("accessKeyId"); - const secretKey = form.getValues("secretAccessKey"); - const bucket = form.getValues("bucket"); - const endpoint = form.getValues("endpoint"); - const region = form.getValues("region"); - - const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`; + const data = form.getValues(); await testConnection({ - provider, - accessKey, - bucket, - endpoint, + provider: data.provider || "", + accessKey: data.accessKeyId || "", + bucket: data.bucket || "", + endpoint: data.endpoint || "", name: "Test", - region, - secretAccessKey: secretKey, + region: data.region || "", + secretAccessKey: data.secretAccessKey || "", serverId, }) .then(() => { @@ -185,7 +240,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { }) .catch((e) => { toast.error("Error connecting to provider", { - description: `${e.message}\n\nTry manually: rclone ls ${connectionString}`, + description: e.message, }); }); }; @@ -208,7 +263,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { )} - + {destinationId ? "Update" : "Add"} Destination @@ -239,7 +294,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { Name - + @@ -248,11 +303,11 @@ export const HandleDestinations = ({ destinationId }: Props) => { /> { return ( - Provider + Destination Type - - - - ); - }} - /> - ( - -
- Secret Access Key -
- - - - -
- )} - /> - ( - -
- Bucket -
- - - - -
- )} - /> - ( - -
- Region -
- - - - -
- )} - /> - ( - - Endpoint - - - - - - )} - /> + {/* S3 Fields */} + {destinationType === "s3" && ( + <> + { + return ( + + Provider + + + + + + ); + }} + /> + { + return ( + + Access Key Id + + + + + + ); + }} + /> + ( + +
+ Secret Access Key +
+ + + + +
+ )} + /> + ( + +
+ Bucket +
+ + + + +
+ )} + /> + ( + +
+ Region +
+ + + + +
+ )} + /> + ( + + Endpoint + + + + + + )} + /> + + )} + + {/* SFTP Fields */} + {destinationType === "sftp" && ( + <> + ( + + Host + + + + + + )} + /> + ( + + Port + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + + )} + /> + ( + + Username + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Remote Path + + + + + + )} + /> + + )} + + {/* FTP Fields */} + {destinationType === "ftp" && ( + <> + ( + + Host + + + + + + )} + /> + ( + + Port + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + + )} + /> + ( + + Username + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Remote Path + + + + + + )} + /> + + )} + + {/* Google Drive Fields */} + {destinationType === "gdrive" && ( + <> + ( + + Service Account JSON + +