Compare commits

...

11 Commits

Author SHA1 Message Date
Mauricio Siu
7ca96f6087 fix(editor): consistent YAML auto-indent on newline
The indentNodeProp from @codemirror/lang-yaml computes column-based
indents that produce inconsistent and odd-numbered indentation when
pressing Enter (e.g. 9 spaces after a nested 'web:' line, 6 after a
4-space 'image:' line). Override it with a line-based indentService:
keep the current line's indent, align list-entry keys after the dash
marker, and indent one unit deeper after lines opening a block
(':', '|', '>').

Fixes #4650
2026-07-14 11:57:12 -06:00
Mauricio Siu
7ba9818894 Merge pull request #4806 from tanaymishra/fix/validate-api-key-name-length
fix: validate API key name length to prevent opaque 500
2026-07-14 00:08:04 -06:00
Mauricio Siu
9142127fb3 Merge pull request #4814 from Dokploy/feat/backup-encryption-key
feat: export full keyring in backup encryption key file
2026-07-13 04:03:23 -06:00
Mauricio Siu
31380fd325 refactor(auth): simplify SSO configuration by removing unnecessary validation settings 2026-07-13 03:56:16 -06:00
tanaymishra
f577778667 fix: validate API key name length to prevent opaque 500
API key names longer than 32 characters were rejected by better-auth
with a 400 that surfaced as an opaque INTERNAL_SERVER_ERROR (the generic
"Failed to generate API key" toast). Add a shared name schema (min 1,
max 32, matching better-auth's default maximumNameLength) used by both
the tRPC input and the client form, and surface the limit on the Name
field so users see it before submitting.

Fixes #4798
2026-07-12 18:15:05 +05:30
Mauricio Siu
c04d56bf2c feat: export full keyring in backup encryption key file 2026-07-10 03:16:31 -06:00
Mauricio Siu
2e867c5be1 feat: optionally include encryption key in web server backups 2026-07-10 03:05:28 -06:00
Mauricio Siu
e87a245cdc Merge pull request #4789 from Dokploy/feat/encrypt-env-at-rest
feat: encrypt environment variables at rest with AES-256-GCM
2026-07-10 02:37:15 -06:00
Mauricio Siu
71bea42625 Merge pull request #4786 from juanjk24/patch-2
fix(ui): adjust button container to grid layout to prevent overflow in 2FA screen
2026-07-10 02:33:53 -06:00
Mauricio Siu
1cb9491013 feat: encrypt environment variables at rest with AES-256-GCM 2026-07-10 02:33:18 -06:00
Juan Cuellar
01ac30974f fix(ui): adjust button container to grid layout to prevent overflow in 2FA screen 2026-07-09 10:00:42 -05:00
28 changed files with 8989 additions and 33 deletions

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
describe("apiKeyNameSchema", () => {
it("rejects an empty name", () => {
const result = apiKeyNameSchema.safeParse("");
expect(result.success).toBe(false);
});
it("accepts a name at the maximum length", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(true);
});
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);
}
});
});

View File

@@ -0,0 +1,93 @@
import {
decryptValue,
encryptValue,
exportEncryptionKeys,
isEncrypted,
} from "@dokploy/server/lib/encryption";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("encryptValue / decryptValue", () => {
it("round-trips a value", () => {
const value =
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
const encrypted = encryptValue(value);
expect(encrypted).not.toBe(value);
expect(isEncrypted(encrypted)).toBe(true);
expect(encrypted).not.toContain("secret");
expect(decryptValue(encrypted)).toBe(value);
});
it("uses a random IV so equal inputs produce different ciphertexts", () => {
const value = "KEY=value";
expect(encryptValue(value)).not.toBe(encryptValue(value));
});
it("passes legacy plaintext through on decrypt", () => {
const plaintext = "KEY=legacy-plaintext-value";
expect(decryptValue(plaintext)).toBe(plaintext);
});
it("passes empty values through unchanged", () => {
expect(encryptValue("")).toBe("");
expect(decryptValue("")).toBe("");
});
it("does not double-encrypt an already encrypted value", () => {
const encrypted = encryptValue("KEY=value");
expect(encryptValue(encrypted)).toBe(encrypted);
});
it("throws a descriptive error on tampered ciphertext", () => {
const encrypted = encryptValue("KEY=value");
const tampered = `${encrypted.slice(0, -4)}AAAA`;
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
});
it("exports the derived keys as 32-byte hex lines for backups", () => {
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
});
});
describe("dedicated ENCRYPTION_KEY", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
const loadWithEncryptionKey = async (key: string) => {
vi.stubEnv("ENCRYPTION_KEY", key);
vi.resetModules();
return await import("@dokploy/server/lib/encryption");
};
it("encrypts with the dedicated key when set", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const encrypted = withKey.encryptValue("KEY=value");
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
// The default (auth-secret derived) module cannot read it
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
});
it("still decrypts legacy values via the auth-secret fallback", async () => {
// Encrypted before the install adopted a dedicated key
const legacyEncrypted = encryptValue("KEY=legacy-value");
const withKey = await loadWithEncryptionKey("my-dedicated-key");
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
});
it("re-encrypts with the dedicated key on write", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const reEncrypted = withKey.encryptValue(
withKey.decryptValue(encryptValue("KEY=migrated")),
);
const other = await loadWithEncryptionKey("another-key");
// Readable only by the dedicated key (or its own fallback), proving
// the write used the primary key, not the legacy one
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
expect(() => other.decryptValue(reEncrypted)).toThrow();
});
});

View File

@@ -79,6 +79,7 @@ const Schema = z
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
includeEncryptionKey: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
@@ -223,6 +224,7 @@ export const HandleBackup = ({
: "",
destinationId: "",
enabled: true,
includeEncryptionKey: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
@@ -262,6 +264,7 @@ export const HandleBackup = ({
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
@@ -309,6 +312,7 @@ export const HandleBackup = ({
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
includeEncryptionKey: data.includeEncryptionKey,
database: data.database,
keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType,
@@ -665,6 +669,31 @@ export const HandleBackup = ({
</FormItem>
)}
/>
{databaseType === "web-server" && (
<FormField
control={form.control}
name="includeEncryptionKey"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Include encryption key</FormLabel>
<FormDescription>
Stores the encryption key inside the backup so
environment variables can be restored on a new server.
Anyone with access to the backup file can decrypt
them.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{backupType === "compose" && (
<>
{form.watch("databaseType") === "postgres" && (

View File

@@ -32,10 +32,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
import { api } from "@/utils/api";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
name: apiKeyNameSchema,
prefix: z.string().optional(),
expiresIn: z.number().nullable(),
organizationId: z.string().min(1, "Organization is required"),
@@ -159,8 +160,15 @@ export const AddApiKey = () => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My API Key" {...field} />
<Input
placeholder="My API Key"
maxLength={API_KEY_NAME_MAX_LENGTH}
{...field}
/>
</FormControl>
<FormDescription>
Maximum {API_KEY_NAME_MAX_LENGTH} characters
</FormDescription>
<FormMessage />
</FormItem>
)}

View File

@@ -7,7 +7,11 @@ import {
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import {
getIndentUnit,
indentService,
StreamLanguage,
} from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { search, searchKeymap } from "@codemirror/search";
@@ -98,6 +102,27 @@ const dockerComposeServiceOptions = [
},
}));
// The indentNodeProp shipped with @codemirror/lang-yaml computes wrong
// column-based indents on Enter (odd/inconsistent amounts, see #4650), so
// indentation is resolved line-based here: keep the current indent, align
// list-entry keys after the dash marker, and go one unit deeper after a
// line that opens a block (ending in ":", "|" or ">").
const yamlIndent = indentService.of((context, pos) => {
const line = context.state.doc.lineAt(pos);
const before = context.state.doc.sliceString(line.from, pos);
const indent = /^ */.exec(before)?.[0].length ?? 0;
const trimmed = before.trim();
if (!trimmed || trimmed.startsWith("#")) {
return indent;
}
const base =
trimmed.startsWith("- ") && /:\s/.test(trimmed) ? indent + 2 : indent;
if (/[:|>]$/.test(trimmed)) {
return base + getIndentUnit(context.state);
}
return base;
});
function dockerComposeComplete(
context: CompletionContext,
): CompletionResult | null {
@@ -160,7 +185,7 @@ export const CodeEditor = ({
search(),
keymap.of(searchKeymap),
language === "yaml"
? yaml()
? [yamlIndent, yaml()]
: language === "json"
? json()
: language === "css"

View File

@@ -0,0 +1 @@
ALTER TABLE "backup" ADD COLUMN "includeEncryptionKey" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1219,6 +1219,13 @@
"when": 1783494977500,
"tag": "0173_aspiring_annihilus",
"breakpoints": true
},
{
"idx": 174,
"version": "7",
"when": 1783674181297,
"tag": "0174_great_naoko",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,23 @@
import { z } from "zod";
/**
* Maximum length allowed for an API key name.
*
* This mirrors the default `maximumNameLength` enforced by the
* `@better-auth/api-key` plugin. Names longer than this are rejected by
* better-auth with a 400, so we validate against it up front to surface a
* clear field-level error instead of an opaque 500.
*/
export const API_KEY_NAME_MAX_LENGTH = 32;
/**
* Shared validation for an API key name, used by both the tRPC input schema
* and the client form so the two can't drift.
*/
export const apiKeyNameSchema = z
.string()
.min(1, "Name is required")
.max(
API_KEY_NAME_MAX_LENGTH,
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.11",
"version": "v0.29.12",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -310,7 +310,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
</button>
</div>
<div className="flex gap-4">
<div className="grid grid-cols-2 gap-4">
<Button
variant="outline"
className="w-full"

View File

@@ -35,6 +35,7 @@ import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, asc, eq, gt, ne } from "drizzle-orm";
import { z } from "zod";
import { apiKeyNameSchema } from "@/lib/api-keys";
import { audit } from "@/server/api/utils/audit";
import {
adminProcedure,
@@ -45,7 +46,7 @@ import {
} from "../trpc";
const apiCreateApiKey = z.object({
name: z.string().min(1),
name: apiKeyNameSchema,
prefix: z.string().optional(),
expiresIn: z.number().optional(),
metadata: z.object({

View File

@@ -51,7 +51,12 @@ import {
UpdateConfigSwarmSchema,
} from "./shared";
import { sshKeys } from "./ssh-key";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
encryptedText,
generateAppName,
} from "./utils";
export const sourceType = pgEnum("sourceType", [
"docker",
"git",
@@ -82,11 +87,11 @@ export const applications = pgTable("application", {
.$defaultFn(() => generateAppName("app"))
.unique(),
description: text("description"),
env: text("env"),
previewEnv: text("previewEnv"),
env: encryptedText("env"),
previewEnv: encryptedText("previewEnv"),
watchPaths: text("watchPaths").array(),
previewBuildArgs: text("previewBuildArgs"),
previewBuildSecrets: text("previewBuildSecrets"),
previewBuildArgs: encryptedText("previewBuildArgs"),
previewBuildSecrets: encryptedText("previewBuildSecrets"),
previewLabels: text("previewLabels").array(),
previewWildcard: text("previewWildcard"),
previewPort: integer("previewPort").default(3000),
@@ -105,8 +110,8 @@ export const applications = pgTable("application", {
"previewRequireCollaboratorPermissions",
).default(true),
rollbackActive: boolean("rollbackActive").default(false),
buildArgs: text("buildArgs"),
buildSecrets: text("buildSecrets"),
buildArgs: encryptedText("buildArgs"),
buildSecrets: encryptedText("buildSecrets"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"),

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

@@ -17,7 +17,12 @@ import { schedules } from "./schedule";
import { server } from "./server";
import { applicationStatus, triggerType } from "./shared";
import { sshKeys } from "./ssh-key";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
encryptedText,
generateAppName,
} from "./utils";
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
"git",
"github",
@@ -39,7 +44,7 @@ export const compose = pgTable("compose", {
.notNull()
.$defaultFn(() => generateAppName("compose")),
description: text("description"),
env: text("env"),
env: encryptedText("env"),
composeFile: text("composeFile").notNull().default(""),
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
sourceType: sourceTypeCompose("sourceType").notNull().default("github"),

View File

@@ -11,6 +11,7 @@ import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { projects } from "./project";
import { redis } from "./redis";
import { encryptedText } from "./utils";
export const environments = pgTable("environment", {
environmentId: text("environmentId")
@@ -22,7 +23,7 @@ export const environments = pgTable("environment", {
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
env: text("env").notNull().default(""),
env: encryptedText("env").notNull().default(""),
projectId: text("projectId")
.notNull()
.references(() => projects.projectId, { onDelete: "cascade" }),

View File

@@ -37,6 +37,7 @@ import {
import {
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@@ -58,7 +59,7 @@ export const libsql = pgTable("libsql", {
enableNamespaces: boolean("enableNamespaces").notNull().default(false),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
env: text("env"),
env: encryptedText("env"),
// RESOURCES
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),

View File

@@ -33,6 +33,7 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@@ -54,7 +55,7 @@ export const mariadb = pgTable("mariadb", {
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
env: encryptedText("env"),
// RESOURCES
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),

View File

@@ -40,6 +40,7 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@@ -59,7 +60,7 @@ export const mongo = pgTable("mongo", {
dockerImage: text("dockerImage").notNull().default("mongo:8"),
command: text("command"),
args: text("args").array(),
env: text("env"),
env: encryptedText("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"),

View File

@@ -33,6 +33,7 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@@ -54,7 +55,7 @@ export const mysql = pgTable("mysql", {
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
env: encryptedText("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"),

View File

@@ -33,6 +33,7 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@@ -53,7 +54,7 @@ export const postgres = pgTable("postgres", {
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
env: encryptedText("env"),
memoryReservation: text("memoryReservation"),
externalPort: integer("externalPort"),
memoryLimit: text("memoryLimit"),

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import { organization } from "./account";
import { environments } from "./environment";
import { projectTags } from "./tag";
import { encryptedText } from "./utils";
export const projects = pgTable("project", {
projectId: text("projectId")
@@ -21,7 +22,7 @@ export const projects = pgTable("project", {
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
env: text("env").notNull().default(""),
env: encryptedText("env").notNull().default(""),
});
export const projectRelations = relations(projects, ({ many, one }) => ({
@@ -37,6 +38,7 @@ const createSchema = createInsertSchema(projects, {
projectId: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
env: z.string().optional(),
});
export const apiCreateProject = createSchema.pick({

View File

@@ -27,7 +27,12 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
encryptedText,
generateAppName,
} from "./utils";
export const redis = pgTable("redis", {
redisId: text("redisId")
@@ -44,7 +49,7 @@ export const redis = pgTable("redis", {
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
env: encryptedText("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"),

View File

@@ -1,7 +1,36 @@
import { decryptValue, encryptValue } from "@dokploy/server/lib/encryption";
import { generatePassword } from "@dokploy/server/templates";
import { faker } from "@faker-js/faker";
import { customType } from "drizzle-orm/pg-core";
import { customAlphabet } from "nanoid";
/**
* Text column encrypted at rest (AES-256-GCM, key derived from
* BETTER_AUTH_SECRET). Legacy plaintext values are passed through on read
* and get encrypted the next time they are written.
*/
export const encryptedText = customType<{ data: string; driverData: string }>({
dataType() {
return "text";
},
toDriver(value) {
return encryptValue(value);
},
fromDriver(value) {
try {
return decryptValue(value);
} catch {
// Fail open so a key mismatch (e.g. restoring a backup under a
// different BETTER_AUTH_SECRET) degrades to showing ciphertext
// instead of breaking every query that touches the row.
console.error(
"Failed to decrypt an encrypted column; returning the raw value. This usually means BETTER_AUTH_SECRET changed after the value was encrypted.",
);
return value;
}
},
});
const alphabet = "abcdefghijklmnopqrstuvwxyz123456789";
const customNanoid = customAlphabet(alphabet, 6);

View File

@@ -419,11 +419,7 @@ const createBetterAuth = () =>
enableMetadata: true,
references: "user",
}),
sso({
saml: {
enableInResponseToValidation: false,
},
}),
sso(),
scim({
beforeSCIMTokenGenerated: async ({ user }) => {
const dbUser = await db.query.user.findFirst({

View File

@@ -0,0 +1,15 @@
import { readSecret } from "../db/constants";
const { ENCRYPTION_KEY, ENCRYPTION_KEY_FILE } = process.env;
function resolveEncryptionSecret(): string | undefined {
if (ENCRYPTION_KEY) {
return ENCRYPTION_KEY;
}
if (ENCRYPTION_KEY_FILE) {
return readSecret(ENCRYPTION_KEY_FILE);
}
return undefined;
}
export const encryptionSecret = resolveEncryptionSecret();

View File

@@ -0,0 +1,108 @@
import {
createCipheriv,
createDecipheriv,
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;
const deriveKey = (secret: string) =>
createHmac("sha256", secret).update("dokploy:db-encryption:v1").digest();
const primaryKey = deriveKey(encryptionSecret ?? betterAuthSecret);
// Installs that adopt a dedicated ENCRYPTION_KEY still hold values encrypted
// with the auth-secret-derived key; keep it as a decrypt fallback so they
// lazily re-encrypt on the next write instead of becoming unreadable.
const decryptionKeys = encryptionSecret
? [primaryKey, deriveKey(betterAuthSecret)]
: [primaryKey];
// Derived keys only — never the raw secrets. A leaked key can decrypt
// stored values, but the raw BETTER_AUTH_SECRET could also forge sessions.
export const exportEncryptionKeys = () =>
decryptionKeys.map((key) => key.toString("hex")).join("\n");
let restoredKeys: Buffer[] | undefined;
// A backup created with "include encryption key" places the original
// server's keys at BASE_PATH when restored; use them as a last-resort
// decryption fallback so restored values keep working on the new server.
const loadRestoredKeys = (): Buffer[] => {
if (restoredKeys?.length) {
return restoredKeys;
}
try {
const { BASE_PATH } = paths();
const keys = readFileSync(
join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE),
"utf8",
)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((hex) => Buffer.from(hex, "hex"))
.filter(
(key) =>
key.length === 32 && !decryptionKeys.some((own) => own.equals(key)),
);
if (keys.length) {
restoredKeys = keys;
}
} catch {
// No restored key file present.
}
return restoredKeys ?? [];
};
export const isEncrypted = (value: string) =>
value.startsWith(ENCRYPTION_PREFIX);
export const encryptValue = (value: string): string => {
if (!value || isEncrypted(value)) {
return value;
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv("aes-256-gcm", primaryKey, iv);
const encrypted = Buffer.concat([
cipher.update(value, "utf8"),
cipher.final(),
]);
return `${ENCRYPTION_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString("base64")}`;
};
export const decryptValue = (value: string): string => {
if (!value || !isEncrypted(value)) {
return value;
}
const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
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);
const keys = [...decryptionKeys, ...loadRestoredKeys()];
for (const key of keys) {
try {
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]).toString("utf8");
} catch {
// GCM auth failed for this key; try the next one.
}
}
throw new Error(
"Failed to decrypt a stored secret. This usually means ENCRYPTION_KEY or BETTER_AUTH_SECRET changed after the value was encrypted.",
);
};

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,
exportEncryptionKeys,
} 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),
exportEncryptionKeys(),
{ 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`,