Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Dokploy includes multiple features to make your life easier.
- **Multi Server**: Deploy and manage your applications remotely to external servers.
- **Self-Hosted**: Self-host Dokploy on your VPS.

### 🔐 Encrypted Backups

Backups can be encrypted at rest using [rclone crypt](https://rclone.org/crypt/). Configure encryption when creating an S3 Destination in **Dashboard → Settings → S3 Destinations** by enabling **Backup Encryption** and providing the primary password (and optional salt/password2). When enabled, Dokploy will automatically encrypt backup uploads and decrypt during restores.

## 🚀 Getting Started

To get started, run the following command on a VPS:
Expand Down
75 changes: 75 additions & 0 deletions apps/dokploy/__test__/backup/encryption-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
buildRcloneCommand,
getBackupRemotePath,
getRcloneS3Remote,
} from "@dokploy/server/utils/backups/utils";
import type { Destination } from "@dokploy/server/services/destination";

const createDestination = (
overrides: Partial<Destination> = {},
): Destination => ({
destinationId: "dest-1",
name: "Encrypted bucket",
provider: "",
accessKey: "ACCESS_KEY",
secretAccessKey: "SECRET_KEY",
bucket: "my-bucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-1",
createdAt: new Date("2024-01-01T00:00:00Z"),
encryptionEnabled: false,
encryptionKey: null,
encryptionPassword2: null,
filenameEncryption: "off",
directoryNameEncryption: false,
...overrides,
});

describe("rclone encryption helpers", () => {
it("builds a plain S3 remote without encryption", () => {
const destination = createDestination();

const { remote, envVars } = getRcloneS3Remote(destination);

expect(envVars).toBe("");
expect(remote).toContain(":s3,");
expect(remote).toContain("my-bucket");
});

it("builds a crypt remote and env vars when encryption is enabled", () => {
const destination = createDestination({
encryptionEnabled: true,
encryptionKey: "primary-pass",
encryptionPassword2: "salt-pass",
filenameEncryption: "standard",
directoryNameEncryption: true,
});

const { remote, envVars } = getRcloneS3Remote(destination);

expect(remote.startsWith(":crypt")).toBe(true);
expect(remote).toContain('remote=":s3,');
expect(remote.endsWith(":")).toBe(true);
expect(envVars).toContain("RCLONE_CRYPT_PASSWORD='primary-pass'");
expect(envVars).toContain("RCLONE_CRYPT_PASSWORD2='salt-pass'");
});

it("returns the correct remote path for nested prefixes", () => {
const destination = createDestination();
const { remote } = getRcloneS3Remote(destination);

const remotePath = getBackupRemotePath(remote, "daily/db");

expect(remotePath).toBe(`${remote}/daily/db/`);
});

it("adds encryption env vars to commands only when provided", () => {
expect(buildRcloneCommand("rclone lsf remote")).toBe("rclone lsf remote");

expect(
buildRcloneCommand("rclone lsf remote", "RCLONE_CRYPT_PASSWORD='secret'"),
).toBe("RCLONE_CRYPT_PASSWORD='secret' rclone lsf remote");
});
});
289 changes: 289 additions & 0 deletions apps/dokploy/__test__/utils/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import type { Destination } from "@dokploy/server/services/destination";
import { getRcloneS3Remote } from "@dokploy/server/utils/backups/utils";
import { describe, expect, test } from "vitest";

// Mock destination factory for testing
const createMockDestination = (
overrides: Partial<Destination> = {},
): Destination => ({
destinationId: "test-dest-id",
name: "Test Destination",
provider: "aws",
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
bucket: "my-backup-bucket",
region: "us-east-1",
endpoint: "https://s3.amazonaws.com",
organizationId: "org-123",
createdAt: new Date(),
encryptionEnabled: false,
encryptionKey: null,
encryptionPassword2: null,
filenameEncryption: "off",
directoryNameEncryption: false,
...overrides,
});

describe("getRcloneS3Remote", () => {
describe("without encryption", () => {
test("should return basic S3 remote without provider", () => {
const destination = createMockDestination({
provider: null,
});

const result = getRcloneS3Remote(destination);

expect(result.envVars).toBe("");
expect(result.remote).toContain(":s3,");
expect(result.remote).toContain(
`access_key_id="${destination.accessKey}"`,
);
expect(result.remote).toContain(
`secret_access_key="${destination.secretAccessKey}"`,
);
expect(result.remote).toContain(`region="${destination.region}"`);
expect(result.remote).toContain(`endpoint="${destination.endpoint}"`);
expect(result.remote).toContain("no_check_bucket=true");
expect(result.remote).toContain("force_path_style=true");
expect(result.remote).toContain(`:${destination.bucket}`);
expect(result.remote).not.toContain("provider=");
});

test("should return S3 remote with provider when specified", () => {
const destination = createMockDestination({
provider: "aws",
});

const result = getRcloneS3Remote(destination);

expect(result.envVars).toBe("");
expect(result.remote).toContain(`provider="${destination.provider}"`);
});

test("should return S3 remote when encryption is disabled", () => {
const destination = createMockDestination({
encryptionEnabled: false,
encryptionKey: "some-key",
});

const result = getRcloneS3Remote(destination);

expect(result.envVars).toBe("");
expect(result.remote).not.toContain(":crypt,");
});

test("should return S3 remote when encryption enabled but no key", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: null,
});

const result = getRcloneS3Remote(destination);

expect(result.envVars).toBe("");
expect(result.remote).not.toContain(":crypt,");
});
});

describe("with encryption", () => {
test("should return crypt-wrapped remote with basic encryption", () => {
const destination = createMockDestination({
provider: "aws",
encryptionEnabled: true,
encryptionKey: "my-encryption-key",
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain(":crypt,");
expect(result.remote).toContain("filename_encryption=off");
expect(result.remote).toContain("directory_name_encryption=false");
expect(result.envVars).toBe("RCLONE_CRYPT_PASSWORD='my-encryption-key'");
});

test("should include password2 when provided", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "my-encryption-key",
encryptionPassword2: "my-salt-password",
});

const result = getRcloneS3Remote(destination);

expect(result.envVars).toContain(
"RCLONE_CRYPT_PASSWORD='my-encryption-key'",
);
expect(result.envVars).toContain(
"RCLONE_CRYPT_PASSWORD2='my-salt-password'",
);
});

test("should handle standard filename encryption", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "my-key",
filenameEncryption: "standard",
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain("filename_encryption=standard");
});

test("should handle obfuscate filename encryption", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "my-key",
filenameEncryption: "obfuscate",
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain("filename_encryption=obfuscate");
});

test("should handle directory name encryption", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "my-key",
directoryNameEncryption: true,
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain("directory_name_encryption=true");
});

test("should handle all encryption options together", () => {
const destination = createMockDestination({
provider: "aws",
encryptionEnabled: true,
encryptionKey: "encryption-key",
encryptionPassword2: "salt-password",
filenameEncryption: "standard",
directoryNameEncryption: true,
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain(":crypt,");
expect(result.remote).toContain("filename_encryption=standard");
expect(result.remote).toContain("directory_name_encryption=true");
expect(result.envVars).toContain(
"RCLONE_CRYPT_PASSWORD='encryption-key'",
);
expect(result.envVars).toContain(
"RCLONE_CRYPT_PASSWORD2='salt-password'",
);
});

test("should escape single quotes in encryption key", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "key'with'quotes",
});

const result = getRcloneS3Remote(destination);

expect(result.envVars).toContain("key'\\''with'\\''quotes");
});

test("should escape single quotes in password2", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "my-key",
encryptionPassword2: "salt'with'quotes",
});

const result = getRcloneS3Remote(destination);

expect(result.envVars).toContain(
"RCLONE_CRYPT_PASSWORD2='salt'\\''with'\\''quotes'",
);
});

test("should wrap S3 remote correctly in crypt remote", () => {
const destination = createMockDestination({
bucket: "test-bucket",
provider: "aws",
encryptionEnabled: true,
encryptionKey: "my-key",
});

const result = getRcloneS3Remote(destination);

// The crypt remote should contain the S3 remote and bucket
expect(result.remote).toMatch(/:crypt,remote=":s3,.*:test-bucket",/);
// Should end with a colon for the path
expect(result.remote).toMatch(/:$/);
});
});

describe("edge cases", () => {
test("should handle special characters in access keys", () => {
const destination = createMockDestination({
accessKey: "AKIA+/=EXAMPLE",
secretAccessKey: "secret+/=key",
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain(
`access_key_id="${destination.accessKey}"`,
);
expect(result.remote).toContain(
`secret_access_key="${destination.secretAccessKey}"`,
);
});

test("should handle custom endpoints", () => {
const destination = createMockDestination({
endpoint: "https://s3.custom-region.example.com:9000",
provider: "minio",
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain(`endpoint="${destination.endpoint}"`);
expect(result.remote).toContain(`provider="${destination.provider}"`);
});

test("should handle empty password2", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "my-key",
encryptionPassword2: "",
});

const result = getRcloneS3Remote(destination);

// Empty string is falsy, so password2 should not be included
expect(result.envVars).toBe("RCLONE_CRYPT_PASSWORD='my-key'");
expect(result.envVars).not.toContain("RCLONE_CRYPT_PASSWORD2");
});

test("should handle null filenameEncryption with default", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "my-key",
filenameEncryption: null as unknown as string,
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain("filename_encryption=off");
});

test("should handle null directoryNameEncryption with default", () => {
const destination = createMockDestination({
encryptionEnabled: true,
encryptionKey: "my-key",
directoryNameEncryption: null as unknown as boolean,
});

const result = getRcloneS3Remote(destination);

expect(result.remote).toContain("directory_name_encryption=false");
});
});
});
Loading