Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7659420
feat: add S3 backup encryption at rest
claude Dec 7, 2025
f252f96
refactor: use rclone crypt for S3 backup encryption
claude Dec 7, 2025
8796617
feat: add full rclone crypt options for S3 backup encryption
claude Dec 7, 2025
04a32ca
test: add unit tests for S3 backup encryption utilities
claude Dec 7, 2025
5871c09
chore: merge canary branch and resolve migration conflict
claude Dec 8, 2025
bc177c8
feat: add encryption support to volume backups
claude Dec 8, 2025
9820c64
fix: merge fixed
amirhmoradi Dec 17, 2025
59a8c7e
Support encrypted backup maintenance
amirhmoradi Dec 18, 2025
98a2e1b
Merge pull request #2 from amirhmoradi/amirhmoradi-cx/add-rclone-cryp…
amirhmoradi Dec 18, 2025
872e894
Merge branch 'canary' of github.com:amirhmoradi/dokploy into claude/a…
amirhmoradi Dec 18, 2025
b419133
fix: correct S3 encryption migration numbering and journal registration
claude Dec 20, 2025
035ff0c
Merge upstream/canary and fix S3 encryption migration numbering
claude Jan 6, 2026
46fa176
refactor: simplify S3 backup encryption to transparent rclone crypt l…
claude Jan 6, 2026
26072d3
refactor: simplify S3 backup encryption to transparent rclone crypt l…
claude Jan 6, 2026
c456f32
fix: correct indentation in backup.ts router
claude Jan 7, 2026
faf1699
Merge upstream and resolve migration conflict
amirhmoradi Jan 12, 2026
f0af03f
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 12, 2026
8bd68ae
Merge branch 'canary' of github.com:amirhmoradi/dokploy into claude/a…
amirhmoradi Jan 15, 2026
fa27ead
Merge branch 'claude/add-s3-backup-encryption-018RrZGgmyuupd7qWBRNYgC…
amirhmoradi Jan 15, 2026
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