feat: optionally include encryption key in web server backups

This commit is contained in:
Mauricio Siu
2026-07-10 03:05:28 -06:00
parent e87a245cdc
commit 2e867c5be1
8 changed files with 8652 additions and 4 deletions

View File

@@ -50,6 +50,7 @@ export const backups = pgTable("backup", {
.notNull()
.references(() => destinations.destinationId, { onDelete: "cascade" }),
keepLatestCount: integer("keepLatestCount"),
includeEncryptionKey: boolean("includeEncryptionKey").notNull().default(true),
backupType: backupType("backupType").notNull().default("database"),
databaseType: databaseType("databaseType").notNull(),
composeId: text("composeId").references(
@@ -160,6 +161,7 @@ const createSchema = createInsertSchema(backups, {
mongoId: z.string().optional(),
libsqlId: z.string().optional(),
userId: z.string().optional(),
includeEncryptionKey: z.boolean().optional(),
metadata: z.any().optional(),
});
@@ -180,6 +182,7 @@ export const apiCreateBackup = createSchema.pick({
backupType: true,
composeId: true,
serviceName: true,
includeEncryptionKey: true,
metadata: true,
});
@@ -206,7 +209,10 @@ export const apiUpdateBackup = createSchema
metadata: true,
databaseType: true,
})
.required();
.required()
.extend({
includeEncryptionKey: z.boolean().optional(),
});
export const apiRestoreBackup = z.object({
databaseId: z.string(),

View File

@@ -4,9 +4,14 @@ import {
createHmac,
randomBytes,
} from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { betterAuthSecret } from "./auth-secret";
import { encryptionSecret } from "./encryption-secret";
export const ENCRYPTION_KEY_BACKUP_FILE = "encryption.key";
const ENCRYPTION_PREFIX = "enc:v1:";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
@@ -23,6 +28,33 @@ const decryptionKeys = encryptionSecret
? [primaryKey, deriveKey(betterAuthSecret)]
: [primaryKey];
export const exportEncryptionKey = () => primaryKey.toString("hex");
let restoredKey: Buffer | undefined;
// A backup created with "include encryption key" places the original
// server's key at BASE_PATH when restored; use it as a last-resort
// decryption fallback so restored values keep working on the new server.
const loadRestoredKey = (): Buffer | undefined => {
if (restoredKey) {
return restoredKey;
}
try {
const { BASE_PATH } = paths();
const hex = readFileSync(
join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE),
"utf8",
).trim();
const key = Buffer.from(hex, "hex");
if (key.length === 32 && !key.equals(primaryKey)) {
restoredKey = key;
}
} catch {
// No restored key file present.
}
return restoredKey;
};
export const isEncrypted = (value: string) =>
value.startsWith(ENCRYPTION_PREFIX);
@@ -47,7 +79,9 @@ export const decryptValue = (value: string): string => {
const iv = payload.subarray(0, IV_LENGTH);
const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
for (const key of decryptionKeys) {
const restored = loadRestoredKey();
const keys = restored ? [...decryptionKeys, restored] : decryptionKeys;
for (const key of keys) {
try {
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);

View File

@@ -1,8 +1,12 @@
import { createWriteStream } from "node:fs";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import {
ENCRYPTION_KEY_BACKUP_FILE,
exportEncryptionKey,
} from "@dokploy/server/lib/encryption";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import {
createDeploymentBackup,
@@ -78,11 +82,22 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
await execAsync(cleanupCommand);
await execAsync(
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' ${BASE_PATH}/ ${tempDir}/filesystem/`,
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' --exclude='${ENCRYPTION_KEY_BACKUP_FILE}' ${BASE_PATH}/ ${tempDir}/filesystem/`,
);
writeStream.write("Copied filesystem to temp directory\n");
if (backup.includeEncryptionKey) {
// Restoring the filesystem places this file at BASE_PATH, where
// the encryption keyring picks it up as a decryption fallback.
await writeFile(
join(tempDir, "filesystem", ENCRYPTION_KEY_BACKUP_FILE),
exportEncryptionKey(),
{ mode: 0o600 },
);
writeStream.write("Included encryption key in backup\n");
}
await execAsync(
// Zip all .sql files since we created more than one
`cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,