Compare commits

...

6 Commits

18 changed files with 9442 additions and 30 deletions
+153 -2
View File
@@ -1,5 +1,22 @@
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
import { describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { apiCreateBackup } from "@dokploy/server/db/schema";
import { keepLatestNBackups } from "@dokploy/server/utils/backups";
import {
getBackupFileName,
normalizeS3Path,
} from "@dokploy/server/utils/backups/utils";
import { getRestoreCommand } from "@dokploy/server/utils/restore/utils";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const { findDestinationByIdMock } = vi.hoisted(() => ({
findDestinationByIdMock: vi.fn(),
}));
vi.mock("@dokploy/server/services/destination", () => ({
findDestinationById: findDestinationByIdMock,
}));
describe("normalizeS3Path", () => {
test("should handle empty and whitespace-only prefix", () => {
@@ -59,3 +76,137 @@ describe("normalizeS3Path", () => {
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
});
});
describe("customName", () => {
const base = {
destinationId: "dest-1",
prefix: "/",
database: "db",
schedule: "0 0 * * *",
backupType: "database" as const,
databaseType: "postgres" as const,
};
test("rejects a name that starts with a hyphen", () => {
expect(
apiCreateBackup.safeParse({ ...base, customName: "-rf" }).success,
).toBe(false);
});
test("accepts dots in the name", () => {
expect(
apiCreateBackup.safeParse({ ...base, customName: "daily.db" }).success,
).toBe(true);
});
});
describe("getBackupFileName", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(Date.UTC(2026, 7, 4, 3, 27, 47, 369)));
});
afterEach(() => {
vi.useRealTimers();
});
test("timestamp only", () => {
expect(getBackupFileName(undefined, "sql.gz")).toBe(
"2026-08-04T03-27-47-369Z.sql.gz",
);
});
test("custom name before the timestamp", () => {
expect(getBackupFileName("my-backup", "sql.gz")).toBe(
"my-backup-2026-08-04T03-27-47-369Z.sql.gz",
);
});
test("fixed prefix, custom name and timestamp", () => {
expect(getBackupFileName("dokploy-local", "zip", "webserver-backup")).toBe(
"webserver-backup-dokploy-local-2026-08-04T03-27-47-369Z.zip",
);
});
});
describe("mongo restore", () => {
test("strips only the trailing .gz", () => {
const cmd = getRestoreCommand({
appName: "app",
type: "mongo",
restoreType: "database",
credentials: { database: "db" },
rcloneCommand: "rclone copy src",
backupFile: "app/db.gz-2026-08-04T03-27-47-369Z.bson.gz",
});
expect(cmd).toContain("< db.gz-2026-08-04T03-27-47-369Z.bson &&");
});
});
describe("keepLatestNBackups", () => {
let stubDir: string;
let deletedFile: string;
let originalPath: string | undefined;
beforeEach(() => {
stubDir = mkdtempSync(join(tmpdir(), "rclone-stub-"));
deletedFile = join(stubDir, "deleted.txt");
writeFileSync(
join(stubDir, "rclone"),
[
"#!/bin/bash",
'if [ "$1" = "lsf" ]; then',
' printf "%s\\n" "$@" | grep -qx -- "--use-server-modtime" || exit 1',
' printf "%s\\n" "$@" | grep -qx "tp" || exit 1',
' echo "2026-08-04 03:27:00;2026-08-04T03-27-00-000Z.sql.gz"',
' echo "2026-08-04 03:28:00;dokploy-local-2026-08-04T03-28-00-000Z.sql.gz"',
' echo "2026-08-04 03:29:00;2026-08-04T03-29-00-000Z.sql.gz"',
' echo "2026-08-04 03:30:00;dokploy-local-2026-08-04T03-30-00-000Z.sql.gz"',
'elif [ "$1" = "delete" ]; then',
` echo "\${@: -1}" >> "${deletedFile}"`,
"fi",
].join("\n"),
{ mode: 0o755 },
);
originalPath = process.env.PATH;
process.env.PATH = `${stubDir}:${originalPath ?? ""}`;
findDestinationByIdMock.mockResolvedValue({
bucket: "test-bucket",
accessKey: "AK",
secretAccessKey: "SK",
region: "us-east-1",
endpoint: "https://s3.example.com",
});
});
afterEach(() => {
process.env.PATH = originalPath;
rmSync(stubDir, { recursive: true, force: true });
});
test("deletes the oldest by modtime, not by name", async () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
await keepLatestNBackups({
backupId: "backup-1",
destinationId: "dest-1",
prefix: "/",
appName: "test-app",
databaseType: "postgres",
keepLatestCount: 2,
} as never);
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
const deleted = readFileSync(deletedFile, "utf-8")
.trim()
.split("\n")
.map((path) => path.split("/").at(-1))
.sort();
expect(deleted).toEqual([
"2026-08-04T03-27-00-000Z.sql.gz",
"dokploy-local-2026-08-04T03-28-00-000Z.sql.gz",
]);
});
});
@@ -78,6 +78,15 @@ const Schema = z
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
customName: z
.string()
.trim()
.max(100, "Custom name must be 100 characters or less")
.regex(
/^([a-zA-Z0-9][a-zA-Z0-9._-]*)?$/,
"Must start with a letter or number; only letters, numbers, dots, hyphens and underscores are allowed",
)
.optional(),
enabled: z.boolean(),
includeEncryptionKey: z.boolean(),
database: z.string().min(1, "Database required"),
@@ -226,6 +235,7 @@ export const HandleBackup = ({
enabled: true,
includeEncryptionKey: true,
prefix: "/",
customName: "",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
@@ -266,6 +276,7 @@ export const HandleBackup = ({
enabled: backup?.enabled ?? true,
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
prefix: backup?.prefix ?? "/",
customName: backup?.customName ?? "",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
serviceName: backup?.serviceName ?? null,
@@ -310,6 +321,7 @@ export const HandleBackup = ({
await createBackup({
destinationId: data.destinationId,
prefix: data.prefix,
customName: data.customName ?? "",
schedule: data.schedule,
enabled: data.enabled,
includeEncryptionKey: data.includeEncryptionKey,
@@ -625,6 +637,29 @@ export const HandleBackup = ({
);
}}
/>
<FormField
control={form.control}
name="customName"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Custom File Name</FormLabel>
<FormControl>
<Input placeholder={"my-backup"} {...field} />
</FormControl>
<FormDescription>
Optional. Prepended to the automatically generated
timestamp, e.g.
"my-backup-2026-08-03T20-58-22-123Z.sql.gz". Only
letters, numbers, dots, hyphens and underscores are
allowed. "Keep the latest" counts every backup in the
same destination folder, regardless of its custom name.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
@@ -408,11 +408,11 @@ export const RestoreBackup = ({
name="backupFile"
render={({ field }) => (
<FormItem className="">
<FormLabel className="flex items-center justify-between">
Search Backup Files
<FormLabel className="flex items-center justify-between gap-2">
<span className="shrink-0">Search Backup Files</span>
{field.value && (
<Badge variant="outline" className="truncate">
{field.value}
<Badge variant="outline" className="min-w-0 max-w-[70%]">
<span className="min-w-0 truncate">{field.value}</span>
<Copy
className="ml-2 size-4 cursor-pointer"
onClick={(e) => {
@@ -435,14 +435,17 @@ export const RestoreBackup = ({
!field.value && "text-muted-foreground",
)}
>
<span className="truncate text-left flex-1 w-52">
<span className="min-w-0 flex-1 truncate text-left">
{field.value || "Search and select a backup file"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search backup files..."
@@ -464,7 +467,7 @@ export const RestoreBackup = ({
</div>
) : (
<ScrollArea className="h-64">
<CommandGroup className="w-96">
<CommandGroup className="w-full">
{files?.map((file) => (
<CommandItem
value={file.Path}
@@ -481,14 +484,14 @@ export const RestoreBackup = ({
}}
>
<div className="flex w-full flex-col gap-1">
<div className="flex w-full justify-between">
<span className="font-medium">
<div className="flex w-full items-start justify-between gap-2">
<span className="min-w-0 flex-1 break-all font-medium">
{file.Path}
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
"mt-0.5 h-4 w-4 shrink-0",
file.Path === field.value
? "opacity-100"
: "opacity-0",
@@ -261,6 +261,17 @@ export const ShowBackups = ({
</p>
</div>
{backup.customName && (
<div className="min-w-[150px]">
<span className="text-sm font-medium text-muted-foreground">
Custom Name
</span>
<p className="font-medium text-sm mt-0.5">
{backup.customName}
</p>
</div>
)}
<div className="min-w-[100px]">
<span className="text-sm font-medium text-muted-foreground">
Keep Latest
@@ -0,0 +1 @@
ALTER TABLE "backup" ADD COLUMN "customName" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1791377393983,
"tag": "0197_complex_sugar_man",
"breakpoints": true
}
]
}
+14
View File
@@ -45,6 +45,7 @@ export const backups = pgTable("backup", {
enabled: boolean("enabled"),
database: text("database").notNull(),
prefix: text("prefix").notNull(),
customName: text("customName"),
serviceName: text("serviceName"),
destinationId: text("destinationId")
.notNull()
@@ -139,11 +140,22 @@ export const backupsRelations = relations(backups, ({ one, many }) => ({
deployments: many(deployments),
}));
const customNameSchema = z
.string()
.trim()
.max(100, "Custom name must be 100 characters or less")
.regex(
/^([a-zA-Z0-9][a-zA-Z0-9._-]*)?$/,
"Must start with a letter or number; only letters, numbers, dots, hyphens and underscores are allowed",
)
.optional();
const createSchema = createInsertSchema(backups, {
backupId: z.string(),
destinationId: z.string(),
enabled: z.boolean().optional(),
prefix: z.string().min(1),
customName: customNameSchema,
database: z.string().min(1),
schedule: z.string(),
keepLatestCount: z.number().optional(),
@@ -169,6 +181,7 @@ export const apiCreateBackup = createSchema.pick({
schedule: true,
enabled: true,
prefix: true,
customName: true,
destinationId: true,
keepLatestCount: true,
database: true,
@@ -212,6 +225,7 @@ export const apiUpdateBackup = createSchema
.required()
.extend({
includeEncryptionKey: z.boolean().optional(),
customName: customNameSchema,
});
export const apiRestoreBackup = z.object({
+5 -2
View File
@@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getBackupFileName,
getS3Credentials,
normalizeS3Path,
} from "./utils";
@@ -25,7 +25,10 @@ export const runComposeBackup = async (
const project = await findProjectById(environment.projectId);
const { prefix, databaseType, serviceName } = backup;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
const backupFileName = getBackupFileName(
backup.customName,
databaseType === "mongo" ? "bson.gz" : "sql.gz",
);
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
+3 -2
View File
@@ -139,9 +139,10 @@ export const keepLatestNBackups = async (
const backupFilesPath = `:s3:${destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
// --include "*.bson.gz" or "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" ${backupFilesPath}`;
// sort by server mtime: custom names break lexical order
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" --format "tp" --use-server-modtime ${backupFilesPath}`;
// when we pipe the above command with this one, we only get the list of files we want to delete
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | cut -d';' -f2- | xargs -I{}`;
// this command deletes the files
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{}
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
+2 -2
View File
@@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getBackupFileName,
getS3Credentials,
normalizeS3Path,
} from "./utils";
@@ -31,7 +31,7 @@ export const runLibsqlBackup = async (
});
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const backupFileName = getBackupFileName(backup.customName, "sql.gz");
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
+2 -2
View File
@@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getBackupFileName,
getS3Credentials,
normalizeS3Path,
} from "./utils";
@@ -25,7 +25,7 @@ export const runMariadbBackup = async (
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const backupFileName = getBackupFileName(backup.customName, "sql.gz");
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
+2 -2
View File
@@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getBackupFileName,
getS3Credentials,
normalizeS3Path,
} from "./utils";
@@ -22,7 +22,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
const backupFileName = getBackupFileName(backup.customName, "bson.gz");
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
+2 -2
View File
@@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getBackupFileName,
getS3Credentials,
normalizeS3Path,
} from "./utils";
@@ -22,7 +22,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const backupFileName = getBackupFileName(backup.customName, "sql.gz");
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
@@ -11,7 +11,7 @@ import { sendDatabaseBackupNotifications } from "../notifications/database-backu
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getBackupFileName,
getS3Credentials,
normalizeS3Path,
} from "./utils";
@@ -31,7 +31,7 @@ export const runPostgresBackup = async (
});
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const backupFileName = getBackupFileName(backup.customName, "sql.gz");
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
@@ -61,6 +61,13 @@ export const removeScheduleBackup = (backupId: string) => {
export const getBackupTimestamp = () =>
new Date().toISOString().replace(/[:.]/g, "-");
export const getBackupFileName = (
customName: string | null | undefined,
extension: string,
fixedPrefix?: string,
) =>
`${[fixedPrefix, customName, getBackupTimestamp()].filter(Boolean).join("-")}.${extension}`;
export const normalizeS3Path = (prefix: string) => {
// Trim whitespace and remove leading/trailing slashes
const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, "");
@@ -16,7 +16,7 @@ import { findDestinationById } from "@dokploy/server/services/destination";
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
import { execAsync } from "../process/execAsync";
import { redactRcloneCredentials } from "./redact";
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
import { getBackupFileName, getS3Credentials, normalizeS3Path } from "./utils";
function formatBytes(bytes?: number) {
if (bytes === undefined) return "Unknown size";
@@ -42,10 +42,13 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
try {
const destination = await findDestinationById(backup.destinationId);
const rcloneFlags = getS3Credentials(destination);
const timestamp = getBackupTimestamp();
const { BASE_PATH } = paths();
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
const backupFileName = `webserver-backup-${timestamp}.zip`;
const backupFileName = getBackupFileName(
backup.customName,
"zip",
"webserver-backup",
);
const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
try {
+1 -1
View File
@@ -90,7 +90,7 @@ const getMongoSpecificCommand = (
): string => {
const tempDir = "/tmp/dokploy-restore";
const fileName = backupFile.split("/").pop() || "backup.sql.gz";
const decompressedName = fileName.replace(".gz", "");
const decompressedName = fileName.replace(/\.gz$/, "");
return `
rm -rf ${tempDir} && \
mkdir -p ${tempDir} && \