Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
62 changes: 56 additions & 6 deletions packages/server/src/utils/backups/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@ import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getContainerSearchCommand,
getCreateDatabaseBackupTempService,
getRemoveServiceCommand,
getS3Credentials,
getServiceExistsCommand,
getServiceNodeCommand,
normalizeS3Path,
} from "./utils";

export const runPostgresBackup = async (
postgres: Postgres,
backup: BackupSchedule,
) => {
const { name, environmentId } = postgres;
const { name, environmentId, databaseUser, appName } = postgres;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);

Expand All @@ -30,18 +39,59 @@ export const runPostgresBackup = async (
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const searchCommand = getContainerSearchCommand(backup);

if (!searchCommand) {
throw new Error("searchCommand is empty");
}

const { stdout: serviceId } = await execAsync(searchCommand, {
shell: "/bin/bash",
});

const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (postgres.serverId) {
await execAsyncRemote(postgres.serverId, backupCommand);

if (serviceId) {
if (postgres.serverId) {
await execAsyncRemote(postgres.serverId, backupCommand);
} else {
await execAsync(backupCommand, {
shell: "/bin/bash",
});
}
} else {
await execAsync(backupCommand, {
const serviceNodeCommend = getServiceNodeCommand(postgres.appName);
const { stdout: node } = await execAsync(serviceNodeCommend, {
shell: "/bin/bash",
});

const serviceExistCommand = getServiceExistsCommand(postgres.appName);
const { stdout: exist } = await execAsync(serviceExistCommand, {
shell: "/bin/bash",
});

if (exist.trim() === "true") {
const removeServiceCommand = getRemoveServiceCommand(postgres.appName);
await execAsync(removeServiceCommand, {
shell: "/bin/bash",
});
}

const createDatabaseBackupTempService =
getCreateDatabaseBackupTempService(
backup,
appName,
databaseUser,
node,
rcloneCommand,
);

await execAsync(createDatabaseBackupTempService, {
shell: "/bin/bash",
});
}
Expand Down
56 changes: 55 additions & 1 deletion packages/server/src/utils/backups/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const getComposeContainerCommand = (
return `docker ps -q --filter "status=running" --filter "label=com.docker.compose.project=${appName}" --filter "label=com.docker.compose.service=${serviceName}" | head -n 1`;
};

const getContainerSearchCommand = (backup: BackupSchedule) => {
export const getContainerSearchCommand = (backup: BackupSchedule) => {
const { backupType, postgres, mysql, mariadb, mongo, compose, serviceName } =
backup;

Expand Down Expand Up @@ -268,3 +268,57 @@ export const getBackupCommand = (
echo "Backup done ✅" >> ${logPath};
`;
};

export const getServiceNodeCommand = (appName: string) => {
return `docker service ps ${appName} --format "{{.Node}}" --filter "desired-state=running"`;
};

export const getRemoveServiceCommand = (appName: string) => {
return `docker service rm backup-service-${appName}`;
};

export const getServiceExistsCommand = (appName: string) => {
return `
if docker service ls \
--filter name=backup-service-${appName} \
--format '{{.Name}}' | \
grep -q "^backup-service-${appName}$"; then
echo true
else
echo false
fi
`.trim();
};

export const getCreateDatabaseBackupTempService = (
backup: BackupSchedule,
appName: string,
databaseUser: string,
node: string,
rcloneCommand: string,
) => {
const containerSearch = getContainerSearchCommand(backup);
const backupCommand = generateBackupCommand(backup);

return [
"docker service create",
`--name backup-service-${appName}`,
"--restart-condition none",
`--constraint 'node.hostname == ${node.trim()}'`,
"--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock",
"alpine sh -c '",
"apk add --no-cache rclone docker-cli &&",
"set -eo pipefail;",
`CONTAINER_ID=$(${containerSearch});`,
'if [ -z "$CONTAINER_ID" ]; then echo "❌ Error: Container not found"; exit 1; fi;',
'echo "Container Up: $CONTAINER_ID";',
'echo "Executing backup test...";',
`${backupCommand} > /dev/null || { echo "❌ Error: Backup process failed"; exit 1; };`,
'echo "Starting upload to S3...";',
`docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password ${backup.database} | gzip" |`,
`${rcloneCommand} || { echo "❌ Error: Upload to S3 failed"; exit 1; };`,
"sleep 6;",
'echo "Backup done ✅"',
"'",
].join(" ");
};