Merge branch 'Dokploy:canary' into feat/quick-service-switcher

This commit is contained in:
Mohammed Imran
2026-03-09 21:03:36 +05:30
committed by GitHub
37 changed files with 380 additions and 209 deletions

View File

@@ -23,7 +23,6 @@ import {
CommandList, CommandList,
CommandSeparator, CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { StatusTooltip } from "../shared/status-tooltip"; import { StatusTooltip } from "../shared/status-tooltip";
@@ -56,7 +55,7 @@ export const SearchCommand = () => {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const { data: session } = authClient.useSession(); const { data: session } = api.user.session.useQuery();
const { data } = api.project.all.useQuery(undefined, { const { data } = api.project.all.useQuery(undefined, {
enabled: !!session, enabled: !!session,
}); });

View File

@@ -12,14 +12,13 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
export const AddGithubProvider = () => { export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data: activeOrganization } = api.organization.active.useQuery(); const { data: activeOrganization } = api.organization.active.useQuery();
const { data: session } = authClient.useSession(); const { data: session } = api.user.session.useQuery();
const { data } = api.user.get.useQuery(); const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState(""); const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false); const [isOrganization, setIsOrganization] = useState(false);
@@ -99,8 +98,8 @@ export const AddGithubProvider = () => {
<form <form
action={ action={
isOrganization isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}` ? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}` : `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
} }
method="post" method="post"
> >

View File

@@ -37,7 +37,7 @@ export const ShowUsers = () => {
const { mutateAsync } = api.user.remove.useMutation(); const { mutateAsync } = api.user.remove.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
const { data: session } = authClient.useSession(); const { data: session } = api.user.session.useQuery();
return ( return (
<div className="w-full"> <div className="w-full">

View File

@@ -546,7 +546,7 @@ function SidebarLogo() {
const { state } = useSidebar(); const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery(); const { data: user } = api.user.get.useQuery();
const { data: session } = authClient.useSession(); const { data: session } = api.user.session.useQuery();
const { const {
data: organizations, data: organizations,
refetch, refetch,

View File

@@ -9,7 +9,8 @@ import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language"; import { StreamLanguage } from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties"; import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell"; import { shell } from "@codemirror/legacy-modes/mode/shell";
import { EditorView } from "@codemirror/view"; import { search, searchKeymap } from "@codemirror/search";
import { EditorView, keymap } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github"; import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@@ -155,6 +156,8 @@ export const CodeEditor = ({
}} }}
theme={resolvedTheme === "dark" ? githubDark : githubLight} theme={resolvedTheme === "dark" ? githubDark : githubLight}
extensions={[ extensions={[
search(),
keymap.of(searchKeymap),
language === "yaml" language === "yaml"
? yaml() ? yaml()
: language === "json" : language === "json"

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.28.3", "version": "v0.28.5",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -53,7 +53,8 @@
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0", "@codemirror/language": "^6.11.0",
"@codemirror/legacy-modes": "6.4.0", "@codemirror/legacy-modes": "6.4.0",
"@codemirror/view": "6.29.0", "@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*", "@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.17", "@dokploy/trpc-openapi": "0.0.17",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",

View File

@@ -10,22 +10,29 @@ type Query = {
state: string; state: string;
installation_id: string; installation_id: string;
setup_action: string; setup_action: string;
userId: string;
}; };
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse,
) { ) {
const { code, state, installation_id, userId }: Query = req.query as Query; const { code, state, installation_id }: Query = req.query as Query;
if (!code) { if (!code) {
return res.status(400).json({ error: "Missing code parameter" }); return res.status(400).json({ error: "Missing code parameter" });
} }
const [action, value] = state?.split(":"); const [action, ...rest] = state?.split(":");
// Value could be the organizationId or the githubProviderId // For gh_init: rest[0] = organizationId, rest[1] = userId
// For gh_setup: rest[0] = githubProviderId
if (action === "gh_init") { if (action === "gh_init") {
const organizationId = rest[0];
const userId = rest[1] || (req.query.userId as string);
if (!userId) {
return res.status(400).json({ error: "Missing userId parameter" });
}
const octokit = new Octokit({}); const octokit = new Octokit({});
const { data } = await octokit.request( const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions", "POST /app-manifests/{code}/conversions",
@@ -44,7 +51,7 @@ export default async function handler(
githubWebhookSecret: data.webhook_secret, githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem, githubPrivateKey: data.pem,
}, },
value as string, organizationId as string,
userId, userId,
); );
} else if (action === "gh_setup") { } else if (action === "gh_setup") {
@@ -53,7 +60,7 @@ export default async function handler(
.set({ .set({
githubInstallationId: installation_id, githubInstallationId: installation_id,
}) })
.where(eq(github.githubId, value as string)) .where(eq(github.githubId, rest[0] as string))
.returning(); .returning();
} }

View File

@@ -777,7 +777,7 @@ const EnvironmentPage = (
} }
if (success > 0) { if (success > 0) {
toast.success( toast.success(
`${success} service${success !== 1 ? "s" : ""} deployed successfully`, `${success} service${success !== 1 ? "s" : ""} queued for deployment`,
); );
} }
if (failed > 0) { if (failed > 0) {

View File

@@ -149,12 +149,12 @@ export const settingsRouter = createTRPCRouter({
// Check if port 8080 is already in use before enabling dashboard // Check if port 8080 is already in use before enabling dashboard
const portCheck = await checkPortInUse(8080, input.serverId); const portCheck = await checkPortInUse(8080, input.serverId);
if (portCheck.isInUse) { if (portCheck.isInUse) {
const conflictingContainer = portCheck.conflictingContainer const conflictInfo = portCheck.conflictingContainer
? ` by container "${portCheck.conflictingContainer}"` ? ` by ${portCheck.conflictingContainer}`
: ""; : "";
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`, message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
}); });
} }
newPorts.push({ newPorts.push({

View File

@@ -101,6 +101,16 @@ export const userRouter = createTRPCRouter({
return memberResult; return memberResult;
}), }),
session: protectedProcedure.query(async ({ ctx }) => {
return {
user: {
id: ctx.user.id,
},
session: {
activeOrganizationId: ctx.session.activeOrganizationId,
},
};
}),
get: protectedProcedure.query(async ({ ctx }) => { get: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({ const memberResult = await db.query.member.findFirst({
where: and( where: and(

View File

@@ -54,13 +54,13 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
WITH recent_metrics AS ( WITH recent_metrics AS (
SELECT metrics_json SELECT metrics_json
FROM container_metrics FROM container_metrics
WHERE container_name = ? WHERE container_name = ? OR container_name LIKE ?
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT ? LIMIT ?
) )
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
` `
rows, err := db.Query(query, containerName, limit) rows, err := db.Query(query, containerName, containerName+".%", limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -90,12 +90,12 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
WITH recent_metrics AS ( WITH recent_metrics AS (
SELECT metrics_json SELECT metrics_json
FROM container_metrics FROM container_metrics
WHERE container_name = ? WHERE container_name = ? OR container_name LIKE ?
ORDER BY timestamp DESC ORDER BY timestamp DESC
) )
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
` `
rows, err := db.Query(query, containerName) rows, err := db.Query(query, containerName, containerName+".%")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -2,8 +2,24 @@ import path from "node:path";
import Docker from "dockerode"; import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true"; export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
export const DOCKER_HOST = process.env.DOCKER_HOST;
export const DOCKER_PORT = process.env.DOCKER_PORT
? Number(process.env.DOCKER_PORT)
: undefined;
export const CLEANUP_CRON_JOB = "50 23 * * *"; export const CLEANUP_CRON_JOB = "50 23 * * *";
export const docker = new Docker(); export const docker = new Docker({
...(DOCKER_API_VERSION && {
version: DOCKER_API_VERSION,
}),
...(DOCKER_HOST && {
host: DOCKER_HOST,
}),
...(DOCKER_PORT && {
port: DOCKER_PORT,
}),
});
// When not set, use the legacy default so 2FA remains working for users who // When not set, use the legacy default so 2FA remains working for users who
// enabled it before BETTER_AUTH_SECRET was introduced . // enabled it before BETTER_AUTH_SECRET was introduced .

View File

@@ -365,12 +365,13 @@ const createSchema = createInsertSchema(applications, {
previewPath: z.string().optional(), previewPath: z.string().optional(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
previewRequireCollaboratorPermissions: z.boolean().optional(), previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional().optional(),
previewLabels: z.array(z.string()).optional(), previewLabels: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(), cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.bigint().nullable(), stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(), ulimitsSwarm: UlimitsSwarmSchema.nullable(),
enableSubmodules: z.boolean().optional(),
}); });
export const apiCreateApplication = createSchema.pick({ export const apiCreateApplication = createSchema.pick({
@@ -433,13 +434,13 @@ export const apiSaveGithubProvider = createSchema
owner: true, owner: true,
buildPath: true, buildPath: true,
githubId: true, githubId: true,
watchPaths: true,
enableSubmodules: true,
}) })
.required() .required()
.extend({ .extend({
triggerType: z.enum(["push", "tag"]).default("push"), triggerType: z.enum(["push", "tag"]).default("push"),
}); })
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveGitlabProvider = createSchema export const apiSaveGitlabProvider = createSchema
.pick({ .pick({
@@ -451,10 +452,9 @@ export const apiSaveGitlabProvider = createSchema
gitlabId: true, gitlabId: true,
gitlabProjectId: true, gitlabProjectId: true,
gitlabPathNamespace: true, gitlabPathNamespace: true,
watchPaths: true,
enableSubmodules: true,
}) })
.required(); .required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveBitbucketProvider = createSchema export const apiSaveBitbucketProvider = createSchema
.pick({ .pick({
@@ -465,10 +465,9 @@ export const apiSaveBitbucketProvider = createSchema
bitbucketRepositorySlug: true, bitbucketRepositorySlug: true,
bitbucketId: true, bitbucketId: true,
applicationId: true, applicationId: true,
watchPaths: true,
enableSubmodules: true,
}) })
.required(); .required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveGiteaProvider = createSchema export const apiSaveGiteaProvider = createSchema
.pick({ .pick({
@@ -478,10 +477,9 @@ export const apiSaveGiteaProvider = createSchema
giteaOwner: true, giteaOwner: true,
giteaRepository: true, giteaRepository: true,
giteaId: true, giteaId: true,
watchPaths: true,
enableSubmodules: true,
}) })
.required(); .required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveDockerProvider = createSchema export const apiSaveDockerProvider = createSchema
.pick({ .pick({
@@ -506,6 +504,7 @@ export const apiSaveGitProvider = createSchema
.merge( .merge(
createSchema.pick({ createSchema.pick({
customGitSSHKeyId: true, customGitSSHKeyId: true,
enableSubmodules: true,
}), }),
); );

View File

@@ -135,15 +135,25 @@ export const getTrustedOrigins = async () => {
if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) { if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
return trustedOriginsCache.data; return trustedOriginsCache.data;
} }
try {
const trustedOrigins = await runQuery(); const trustedOrigins = await runQuery();
trustedOriginsCache = { trustedOriginsCache = {
data: trustedOrigins, data: trustedOrigins,
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS, expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
}; };
return trustedOrigins; return trustedOrigins;
} catch (error) {
console.error("Failed to fetch trusted origins:", error);
return trustedOriginsCache?.data ?? [];
}
} }
return runQuery(); try {
return await runQuery();
} catch (error) {
console.error("Failed to fetch trusted origins:", error);
return [];
}
}; };
export const getTrustedProviders = async () => { export const getTrustedProviders = async () => {

View File

@@ -117,12 +117,12 @@ export const createDeployment = async (
>, >,
) => { ) => {
const application = await findApplicationById(deployment.applicationId); const application = await findApplicationById(deployment.applicationId);
try {
await removeLastTenDeployments( await removeLastTenDeployments(
deployment.applicationId, deployment.applicationId,
"application", "application",
application.serverId, application.serverId,
); );
try {
const serverId = application.buildServerId || application.serverId; const serverId = application.buildServerId || application.serverId;
const { LOGS_PATH } = paths(!!serverId); const { LOGS_PATH } = paths(!!serverId);
@@ -200,13 +200,12 @@ export const createDeploymentPreview = async (
const previewDeployment = await findPreviewDeploymentById( const previewDeployment = await findPreviewDeploymentById(
deployment.previewDeploymentId, deployment.previewDeploymentId,
); );
try {
await removeLastTenDeployments( await removeLastTenDeployments(
deployment.previewDeploymentId, deployment.previewDeploymentId,
"previewDeployment", "previewDeployment",
previewDeployment?.application?.serverId, previewDeployment?.application?.serverId,
); );
try {
const appName = `${previewDeployment.appName}`; const appName = `${previewDeployment.appName}`;
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId); const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
@@ -281,12 +280,12 @@ export const createDeploymentCompose = async (
>, >,
) => { ) => {
const compose = await findComposeById(deployment.composeId); const compose = await findComposeById(deployment.composeId);
try {
await removeLastTenDeployments( await removeLastTenDeployments(
deployment.composeId, deployment.composeId,
"compose", "compose",
compose.serverId, compose.serverId,
); );
try {
const { LOGS_PATH } = paths(!!compose.serverId); const { LOGS_PATH } = paths(!!compose.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${compose.appName}-${formattedDateTime}.log`; const fileName = `${compose.appName}-${formattedDateTime}.log`;
@@ -369,8 +368,8 @@ export const createDeploymentBackup = async (
} else if (backup.backupType === "compose") { } else if (backup.backupType === "compose") {
serverId = backup.compose?.serverId; serverId = backup.compose?.serverId;
} }
try {
await removeLastTenDeployments(deployment.backupId, "backup", serverId); await removeLastTenDeployments(deployment.backupId, "backup", serverId);
try {
const { LOGS_PATH } = paths(!!serverId); const { LOGS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${backup.appName}-${formattedDateTime}.log`; const fileName = `${backup.appName}-${formattedDateTime}.log`;
@@ -439,12 +438,12 @@ export const createDeploymentSchedule = async (
) => { ) => {
const schedule = await findScheduleById(deployment.scheduleId); const schedule = await findScheduleById(deployment.scheduleId);
try {
const serverId = const serverId =
schedule.application?.serverId || schedule.application?.serverId ||
schedule.compose?.serverId || schedule.compose?.serverId ||
schedule.server?.serverId; schedule.server?.serverId;
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId); await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
try {
const { SCHEDULES_PATH } = paths(!!serverId); const { SCHEDULES_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${schedule.appName}-${formattedDateTime}.log`; const fileName = `${schedule.appName}-${formattedDateTime}.log`;
@@ -515,7 +514,6 @@ export const createDeploymentVolumeBackup = async (
) => { ) => {
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId); const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
try {
const serverId = const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId; volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
await removeLastTenDeployments( await removeLastTenDeployments(
@@ -523,6 +521,7 @@ export const createDeploymentVolumeBackup = async (
"volumeBackup", "volumeBackup",
serverId, serverId,
); );
try {
const { VOLUME_BACKUPS_PATH } = paths(!!serverId); const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`; const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
@@ -601,24 +600,23 @@ export const removeDeployment = async (deploymentId: string) => {
.then((result) => result[0]); .then((result) => result[0]);
if (!deployment) { if (!deployment) {
throw new TRPCError({ return null;
code: "BAD_REQUEST",
message: "Deployment not found",
});
} }
const command = `
rm -f ${deployment.logPath}; const logPath = path.join(deployment.logPath);
`; if (logPath && logPath !== ".") {
const command = `rm -f ${logPath};`;
if (deployment.serverId) { if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command); await execAsyncRemote(deployment.serverId, command);
} else { } else {
await execAsync(command); await execAsync(command);
} }
}
return deployment; return deployment;
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Error creating the deployment"; error instanceof Error ? error.message : "Error removing the deployment";
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message, message,
@@ -686,34 +684,49 @@ const removeLastTenDeployments = async (
if (serverId) { if (serverId) {
let command = ""; let command = "";
for (const oldDeployment of deploymentsToDelete) { for (const oldDeployment of deploymentsToDelete) {
try {
const logPath = path.join(oldDeployment.logPath); const logPath = path.join(oldDeployment.logPath);
if (oldDeployment.rollbackId) { if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId); await removeRollbackById(oldDeployment.rollbackId);
} }
if (logPath !== ".") { if (logPath && logPath !== ".") {
command += ` command += `rm -rf ${logPath};`;
rm -rf ${logPath};
`;
} }
await removeDeployment(oldDeployment.deploymentId); await removeDeployment(oldDeployment.deploymentId);
} catch (err) {
console.error(
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
err,
);
}
} }
if (command) {
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, command);
}
} else { } else {
for (const oldDeployment of deploymentsToDelete) { for (const oldDeployment of deploymentsToDelete) {
try {
if (oldDeployment.rollbackId) { if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId); await removeRollbackById(oldDeployment.rollbackId);
} }
const logPath = path.join(oldDeployment.logPath); const logPath = path.join(oldDeployment.logPath);
if ( if (
logPath &&
logPath !== "." &&
existsSync(logPath) && existsSync(logPath) &&
!oldDeployment.errorMessage && !oldDeployment.errorMessage
logPath !== "."
) { ) {
await fsPromises.unlink(logPath); await fsPromises.unlink(logPath);
} }
await removeDeployment(oldDeployment.deploymentId); await removeDeployment(oldDeployment.deploymentId);
} catch (err) {
console.error(
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
err,
);
}
} }
} }
} }

View File

@@ -16,7 +16,7 @@ function shEscape(s: string | undefined): string {
return `'${s.replace(/'/g, `'\\''`)}'`; return `'${s.replace(/'/g, `'\\''`)}'`;
} }
function safeDockerLoginCommand( export function safeDockerLoginCommand(
registry: string | undefined, registry: string | undefined,
user: string | undefined, user: string | undefined,
pass: string | undefined, pass: string | undefined,

View File

@@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment";
import type { Mount } from "./mount"; import type { Mount } from "./mount";
import type { Port } from "./port"; import type { Port } from "./port";
import type { Project } from "./project"; import type { Project } from "./project";
import type { Registry } from "./registry"; import { type Registry, safeDockerLoginCommand } from "./registry";
export const createRollback = async ( export const createRollback = async (
input: z.infer<typeof createRollbackSchema>, input: z.infer<typeof createRollbackSchema>,
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
const command = `docker image rm ${image} --force`; const command = `docker image rm ${image} --force`;
if (serverId) { if (serverId) {
await execAsyncRemote(command, serverId); await execAsyncRemote(serverId, command);
} else { } else {
await execAsync(command); await execAsync(command);
} }
@@ -171,6 +171,23 @@ export const rollback = async (rollbackId: string) => {
); );
}; };
const dockerLoginForRegistry = async (
registry: Registry,
serverId?: string | null,
) => {
const loginCommand = safeDockerLoginCommand(
registry.registryUrl,
registry.username,
registry.password,
);
if (serverId) {
await execAsyncRemote(serverId, loginCommand);
} else {
await execAsync(loginCommand);
}
};
const rollbackApplication = async ( const rollbackApplication = async (
appName: string, appName: string,
image: string, image: string,
@@ -188,6 +205,14 @@ const rollbackApplication = async (
throw new Error("Full context is required for rollback"); throw new Error("Full context is required for rollback");
} }
// Ensure Docker daemon is authenticated with the rollback registry
// before updating the swarm service. The authconfig in CreateServiceOptions
// alone is not sufficient — Docker Swarm also relies on the daemon's
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
if (fullContext.rollbackRegistry) {
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
}
const docker = await getRemoteDocker(serverId); const docker = await getRemoteDocker(serverId);
// Use the same configuration as mechanizeDockerContainer // Use the same configuration as mechanizeDockerContainer

View File

@@ -413,17 +413,38 @@ export const checkPortInUse = async (
serverId?: string, serverId?: string,
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => { ): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
try { try {
const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; // Check if port is in use by a Docker container
const { stdout } = serverId const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
? await execAsyncRemote(serverId, command) const { stdout: dockerOut } = serverId
: await execAsync(command); ? await execAsyncRemote(serverId, dockerCommand)
: await execAsync(dockerCommand);
const container = stdout.trim(); const container = dockerOut.trim();
if (container) {
return { return {
isInUse: !!container, isInUse: true,
conflictingContainer: container || undefined, conflictingContainer: `container "${container}"`,
}; };
}
// Check if port is in use by a host-level service (non-Docker)
// Dokploy runs inside a container, so we spawn an ephemeral container
// with --net=host to share the host's network stack and use nc -z to
// check if something is listening on the port
const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`;
const { stdout: hostOut } = serverId
? await execAsyncRemote(serverId, hostCommand)
: await execAsync(hostCommand);
if (hostOut.includes("in_use")) {
return {
isInUse: true,
conflictingContainer: "a host-level service",
};
}
return { isInUse: false };
} catch (error) { } catch (error) {
console.error("Error checking port availability:", error); console.error("Error checking port availability:", error);
return { isInUse: false }; return { isInUse: false };

View File

@@ -30,6 +30,18 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
baseURL: config.apiUrl, baseURL: config.apiUrl,
}); });
case "azure": case "azure":
// Azure OpenAI-compatible endpoints already include /v1 in the path.
// Using createAzure with such URLs causes a doubled /v1//v1/ suffix.
if (config.apiUrl.includes("/v1")) {
return createOpenAICompatible({
name: "azure",
baseURL: config.apiUrl,
headers: {
"api-key": config.apiKey,
Authorization: `Bearer ${config.apiKey}`,
},
});
}
return createAzure({ return createAzure({
apiKey: config.apiKey, apiKey: config.apiKey,
baseURL: config.apiUrl, baseURL: config.apiUrl,

View File

@@ -14,13 +14,14 @@ export const runComposeBackup = async (
compose: Compose, compose: Compose,
backup: BackupSchedule, backup: BackupSchedule,
) => { ) => {
const { environmentId, name } = compose; const { environmentId, name, appName } = compose;
const environment = await findEnvironmentById(environmentId); const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
const { prefix, databaseType } = backup; const { prefix, databaseType, serviceName } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`; const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({ const deployment = await createDeploymentBackup({
backupId: backup.backupId, backupId: backup.backupId,
title: "Compose Backup", title: "Compose Backup",

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants"; import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
import { member } from "@dokploy/server/db/schema"; import { member } from "@dokploy/server/db/schema";
import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { BackupSchedule } from "@dokploy/server/services/backup";
@@ -11,7 +10,7 @@ import { startLogCleanup } from "../access-log/handler";
import { cleanupAll } from "../docker/utils"; import { cleanupAll } from "../docker/utils";
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
import { execAsync, execAsyncRemote } from "../process/execAsync"; import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, scheduleBackup } from "./utils"; import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
export const initCronJobs = async () => { export const initCronJobs = async () => {
console.log("Setting up cron jobs...."); console.log("Setting up cron jobs....");
@@ -107,6 +106,20 @@ export const initCronJobs = async () => {
} }
}; };
const getServiceAppName = (backup: BackupSchedule): string => {
if (backup.compose?.appName) {
return backup.serviceName
? `${backup.compose.appName}_${backup.serviceName}`
: backup.compose.appName;
}
const serviceAppName =
backup.postgres?.appName ||
backup.mysql?.appName ||
backup.mariadb?.appName ||
backup.mongo?.appName;
return serviceAppName || backup.appName;
};
export const keepLatestNBackups = async ( export const keepLatestNBackups = async (
backup: BackupSchedule, backup: BackupSchedule,
serverId?: string | null, serverId?: string | null,
@@ -117,18 +130,16 @@ export const keepLatestNBackups = async (
try { try {
const rcloneFlags = getS3Credentials(backup.destination); const rcloneFlags = getS3Credentials(backup.destination);
const backupFilesPath = path.join( const appName = getServiceAppName(backup);
`:s3:${backup.destination.bucket}`, const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
backup.prefix,
);
// --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone // --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`; const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
// when we pipe the above command with this one, we only get the list of files we want to delete // when we pipe the above command with this one, we only get the list of files we want to delete
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`; const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
// this command deletes the files // this command deletes the files
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{} // to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{}
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}/{}`; const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`; const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`;

View File

@@ -14,13 +14,13 @@ export const runMariadbBackup = async (
mariadb: Mariadb, mariadb: Mariadb,
backup: BackupSchedule, backup: BackupSchedule,
) => { ) => {
const { environmentId, name } = mariadb; const { environmentId, name, appName } = mariadb;
const environment = await findEnvironmentById(environmentId); const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
const { prefix } = backup; const { prefix } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`; const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({ const deployment = await createDeploymentBackup({
backupId: backup.backupId, backupId: backup.backupId,
title: "MariaDB Backup", title: "MariaDB Backup",

View File

@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const { environmentId, name } = mongo; const { environmentId, name, appName } = mongo;
const environment = await findEnvironmentById(environmentId); const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
const { prefix } = backup; const { prefix } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`; const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({ const deployment = await createDeploymentBackup({
backupId: backup.backupId, backupId: backup.backupId,
title: "MongoDB Backup", title: "MongoDB Backup",

View File

@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { environmentId, name } = mysql; const { environmentId, name, appName } = mysql;
const environment = await findEnvironmentById(environmentId); const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
const { prefix } = backup; const { prefix } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`; const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({ const deployment = await createDeploymentBackup({
backupId: backup.backupId, backupId: backup.backupId,
title: "MySQL Backup", title: "MySQL Backup",

View File

@@ -14,7 +14,7 @@ export const runPostgresBackup = async (
postgres: Postgres, postgres: Postgres,
backup: BackupSchedule, backup: BackupSchedule,
) => { ) => {
const { name, environmentId } = postgres; const { name, environmentId, appName } = postgres;
const environment = await findEnvironmentById(environmentId); const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
@@ -26,7 +26,7 @@ export const runPostgresBackup = async (
const { prefix } = backup; const { prefix } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`; const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try { try {
const rcloneFlags = getS3Credentials(destination); const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

View File

@@ -31,7 +31,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
const { BASE_PATH } = paths(); const { BASE_PATH } = paths();
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-")); const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
const backupFileName = `webserver-backup-${timestamp}.zip`; const backupFileName = `webserver-backup-${timestamp}.zip`;
const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`; const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
try { try {
await execAsync(`mkdir -p ${tempDir}/filesystem`); await execAsync(`mkdir -p ${tempDir}/filesystem`);
@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
await execAsync(cleanupCommand); await execAsync(cleanupCommand);
await execAsync( await execAsync(
`rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`, `rsync -a --ignore-errors --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`,
); );
writeStream.write("Copied filesystem to temp directory\n"); writeStream.write("Copied filesystem to temp directory\n");

View File

@@ -53,7 +53,7 @@ Compose Type: ${composeType} ✅`;
cd "${projectPath}"; cd "${projectPath}";
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""} ${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; } env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""} ${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}

View File

@@ -18,7 +18,9 @@ export const randomizeComposeFile = async (
) => { ) => {
const compose = await findComposeById(composeId); const compose = await findComposeById(composeId);
const composeFile = compose.composeFile; const composeFile = compose.composeFile;
const composeData = parse(composeFile) as ComposeSpecification; const composeData = parse(composeFile, {
maxAliasCount: 10000,
}) as ComposeSpecification;
const randomSuffix = suffix || generateRandomHash(); const randomSuffix = suffix || generateRandomHash();

View File

@@ -63,7 +63,9 @@ export const loadDockerCompose = async (
if (existsSync(path)) { if (existsSync(path)) {
const yamlStr = readFileSync(path, "utf8"); const yamlStr = readFileSync(path, "utf8");
const parsedConfig = parse(yamlStr) as ComposeSpecification; const parsedConfig = parse(yamlStr, {
maxAliasCount: 10000,
}) as ComposeSpecification;
return parsedConfig; return parsedConfig;
} }
return null; return null;
@@ -86,7 +88,9 @@ export const loadDockerComposeRemote = async (
return null; return null;
} }
if (!stdout) return null; if (!stdout) return null;
const parsedConfig = parse(stdout) as ComposeSpecification; const parsedConfig = parse(stdout, {
maxAliasCount: 10000,
}) as ComposeSpecification;
return parsedConfig; return parsedConfig;
} catch { } catch {
return null; return null;

View File

@@ -211,7 +211,10 @@ export const testGiteaConnection = async (input: { giteaId: string }) => {
}); });
} }
const baseUrl = provider.giteaUrl.replace(/\/+$/, ""); const baseUrl = (provider.giteaInternalUrl || provider.giteaUrl).replace(
/\/+$/,
"",
);
// Use /user/repos to get authenticated user's repositories with pagination // Use /user/repos to get authenticated user's repositories with pagination
let allRepos = 0; let allRepos = 0;
@@ -268,7 +271,9 @@ export const getGiteaRepositories = async (giteaId?: string) => {
await refreshGiteaToken(giteaId); await refreshGiteaToken(giteaId);
const giteaProvider = await findGiteaById(giteaId); const giteaProvider = await findGiteaById(giteaId);
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, ""); const baseUrl = (
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
).replace(/\/+$/, "");
// Use /user/repos to get authenticated user's repositories with pagination // Use /user/repos to get authenticated user's repositories with pagination
let allRepositories: any[] = []; let allRepositories: any[] = [];
@@ -333,7 +338,9 @@ export const getGiteaBranches = async (input: {
const giteaProvider = await findGiteaById(input.giteaId); const giteaProvider = await findGiteaById(input.giteaId);
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, ""); const baseUrl = (
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
).replace(/\/+$/, "");
// Handle pagination for branches // Handle pagination for branches
let allBranches: any[] = []; let allBranches: any[] = [];

View File

@@ -214,10 +214,13 @@ export const getGitlabBranches = async (input: {
const allBranches = []; const allBranches = [];
let page = 1; let page = 1;
const perPage = 100; // GitLab's max per page is 100 const perPage = 100; // GitLab's max per page is 100
const baseUrl = (
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
).replace(/\/+$/, "");
while (true) { while (true) {
const branchesResponse = await fetch( const branchesResponse = await fetch(
`${gitlabProvider.gitlabUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`, `${baseUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
{ {
headers: { headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`, Authorization: `Bearer ${gitlabProvider.accessToken}`,
@@ -292,10 +295,13 @@ export const validateGitlabProvider = async (gitlabProvider: Gitlab) => {
const allProjects = []; const allProjects = [];
let page = 1; let page = 1;
const perPage = 100; // GitLab's max per page is 100 const perPage = 100; // GitLab's max per page is 100
const baseUrl = (
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
).replace(/\/+$/, "");
while (true) { while (true) {
const response = await fetch( const response = await fetch(
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`, `${baseUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
{ {
headers: { headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`, Authorization: `Bearer ${gitlabProvider.accessToken}`,

View File

@@ -69,6 +69,7 @@ export const restoreComposeBackup = async (
}, },
restoreType: composeType, restoreType: composeType,
rcloneCommand, rcloneCommand,
backupFile: backupInput.backupFile,
}); });
emit("Starting restore..."); emit("Starting restore...");

View File

@@ -30,7 +30,7 @@ export const getMongoRestoreCommand = (
databaseUser: string, databaseUser: string,
databasePassword: string, databasePassword: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`; return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`;
}; };
export const getComposeSearchCommand = ( export const getComposeSearchCommand = (

View File

@@ -152,17 +152,14 @@ export const createRouterConfig = async (
} }
if ((entryPoint === "websecure" && https) || !https) { if ((entryPoint === "websecure" && https) || !https) {
// redirects // redirects - skip for preview deployments as wildcard subdomains
// should not inherit parent redirect rules (e.g., www-redirect)
if (domain.domainType !== "preview") {
for (const redirect of redirects) { for (const redirect of redirects) {
let middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`; const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
if (domain.domainType === "preview") {
middlewareName = `redirect-${appName.replace(
/^preview-(.+)-[^-]+$/,
"$1",
)}-${redirect.uniqueConfigKey}`;
}
routerConfig.middlewares?.push(middlewareName); routerConfig.middlewares?.push(middlewareName);
} }
}
// security // security
if (security.length > 0) { if (security.length > 0) {

View File

@@ -4,6 +4,24 @@ import { findComposeById } from "@dokploy/server/services/compose";
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups"; import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import { getS3Credentials, normalizeS3Path } from "../backups/utils"; import { getS3Credentials, normalizeS3Path } from "../backups/utils";
export const getVolumeServiceAppName = (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
): string => {
if (volumeBackup.compose?.appName) {
return volumeBackup.serviceName
? `${volumeBackup.compose.appName}_${volumeBackup.serviceName}`
: volumeBackup.compose.appName;
}
const serviceAppName =
volumeBackup.application?.appName ||
volumeBackup.postgres?.appName ||
volumeBackup.mysql?.appName ||
volumeBackup.mariadb?.appName ||
volumeBackup.mongo?.appName ||
volumeBackup.redis?.appName;
return serviceAppName || volumeBackup.appName;
};
export const backupVolume = async ( export const backupVolume = async (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>, volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
) => { ) => {
@@ -12,8 +30,9 @@ export const backupVolume = async (
volumeBackup.application?.serverId || volumeBackup.compose?.serverId; volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId); const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
const destination = volumeBackup.destination; const destination = volumeBackup.destination;
const s3AppName = getVolumeServiceAppName(volumeBackup);
const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`; const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
const rcloneFlags = getS3Credentials(volumeBackup.destination); const rcloneFlags = getS3Credentials(volumeBackup.destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName); const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);

View File

@@ -12,7 +12,7 @@ import {
import { scheduledJobs, scheduleJob } from "node-schedule"; import { scheduledJobs, scheduleJob } from "node-schedule";
import { getS3Credentials, normalizeS3Path } from "../backups/utils"; import { getS3Credentials, normalizeS3Path } from "../backups/utils";
import { sendVolumeBackupNotifications } from "../notifications/volume-backup"; import { sendVolumeBackupNotifications } from "../notifications/volume-backup";
import { backupVolume } from "./backup"; import { backupVolume, getVolumeServiceAppName } from "./backup";
// Helper functions to extract project info from volume backup // Helper functions to extract project info from volume backup
const getProjectName = ( const getProjectName = (
@@ -81,9 +81,9 @@ const cleanupOldVolumeBackups = async (
try { try {
const rcloneFlags = getS3Credentials(destination); const rcloneFlags = getS3Credentials(destination);
const normalizedPrefix = normalizeS3Path(prefix); const s3AppName = getVolumeServiceAppName(volumeBackup);
const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`; const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`;
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`; const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`;
const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`; const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`;
const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`; const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`; const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`;
@@ -131,6 +131,7 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
? "mongodb" ? "mongodb"
: volumeBackup.serviceType; : volumeBackup.serviceType;
try {
await sendVolumeBackupNotifications({ await sendVolumeBackupNotifications({
projectName, projectName,
applicationName: volumeBackup.name, applicationName: volumeBackup.name,
@@ -139,6 +140,12 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
type: "success", type: "success",
organizationId, organizationId,
}); });
} catch (notificationError) {
console.error(
"Failed to send volume backup success notification",
notificationError,
);
}
} catch (error) { } catch (error) {
const { VOLUME_BACKUPS_PATH } = paths(!!serverId); const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
const volumeBackupPath = path.join( const volumeBackupPath = path.join(
@@ -160,6 +167,7 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
? "mongodb" ? "mongodb"
: volumeBackup.serviceType; : volumeBackup.serviceType;
try {
await sendVolumeBackupNotifications({ await sendVolumeBackupNotifications({
projectName, projectName,
applicationName: volumeBackup.name, applicationName: volumeBackup.name,
@@ -169,5 +177,11 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
organizationId, organizationId,
errorMessage: error instanceof Error ? error.message : String(error), errorMessage: error instanceof Error ? error.message : String(error),
}); });
} catch (notificationError) {
console.error(
"Failed to send volume backup error notification",
notificationError,
);
}
} }
}; };

48
pnpm-lock.yaml generated
View File

@@ -131,9 +131,12 @@ importers:
'@codemirror/legacy-modes': '@codemirror/legacy-modes':
specifier: 6.4.0 specifier: 6.4.0
version: 6.4.0 version: 6.4.0
'@codemirror/search':
specifier: ^6.6.0
version: 6.6.0
'@codemirror/view': '@codemirror/view':
specifier: 6.29.0 specifier: ^6.39.15
version: 6.29.0 version: 6.39.15
'@dokploy/server': '@dokploy/server':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/server version: link:../../packages/server
@@ -241,10 +244,10 @@ importers:
version: 11.10.0(typescript@5.9.3) version: 11.10.0(typescript@5.9.3)
'@uiw/codemirror-theme-github': '@uiw/codemirror-theme-github':
specifier: ^4.23.12 specifier: ^4.23.12
version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0) version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
'@uiw/react-codemirror': '@uiw/react-codemirror':
specifier: ^4.23.12 specifier: ^4.23.12
version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.29.0)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@xterm/addon-attach': '@xterm/addon-attach':
specifier: 0.10.0 specifier: 0.10.0
version: 0.10.0(@xterm/xterm@5.5.0) version: 0.10.0(@xterm/xterm@5.5.0)
@@ -1285,9 +1288,6 @@ packages:
'@codemirror/theme-one-dark@6.1.3': '@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.29.0':
resolution: {integrity: sha512-ED4ims4fkf7eOA+HYLVP8VVg3NMllt1FPm9PEJBfYFnidKlRITBaua38u68L1F60eNtw2YNcDN5jsIzhKZwWQA==}
'@codemirror/view@6.39.15': '@codemirror/view@6.39.15':
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==} resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
@@ -8793,14 +8793,14 @@ snapshots:
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.29.0 '@codemirror/view': 6.39.15
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@codemirror/commands@6.10.2': '@codemirror/commands@6.10.2':
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.29.0 '@codemirror/view': 6.39.15
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@codemirror/lang-json@6.0.2': '@codemirror/lang-json@6.0.2':
@@ -8821,7 +8821,7 @@ snapshots:
'@codemirror/language@6.12.1': '@codemirror/language@6.12.1':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.29.0 '@codemirror/view': 6.39.15
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8 '@lezer/lr': 1.4.8
@@ -8851,15 +8851,9 @@ snapshots:
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.29.0 '@codemirror/view': 6.39.15
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@codemirror/view@6.29.0':
dependencies:
'@codemirror/state': 6.5.4
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@codemirror/view@6.39.15': '@codemirror/view@6.39.15':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
@@ -12094,7 +12088,7 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.10.13 '@types/node': 24.10.13
'@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)': '@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.2 '@codemirror/commands': 6.10.2
@@ -12102,30 +12096,30 @@ snapshots:
'@codemirror/lint': 6.9.4 '@codemirror/lint': 6.9.4
'@codemirror/search': 6.6.0 '@codemirror/search': 6.6.0
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.29.0 '@codemirror/view': 6.39.15
'@uiw/codemirror-theme-github@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)': '@uiw/codemirror-theme-github@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
dependencies: dependencies:
'@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0) '@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
transitivePeerDependencies: transitivePeerDependencies:
- '@codemirror/language' - '@codemirror/language'
- '@codemirror/state' - '@codemirror/state'
- '@codemirror/view' - '@codemirror/view'
'@uiw/codemirror-themes@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0)': '@uiw/codemirror-themes@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.29.0 '@codemirror/view': 6.39.15
'@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.29.0)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6
'@codemirror/commands': 6.10.2 '@codemirror/commands': 6.10.2
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/theme-one-dark': 6.1.3 '@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.29.0 '@codemirror/view': 6.39.15
'@uiw/codemirror-extensions-basic-setup': 4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.29.0) '@uiw/codemirror-extensions-basic-setup': 4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
codemirror: 6.0.2 codemirror: 6.0.2
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
@@ -12772,7 +12766,7 @@ snapshots:
'@codemirror/lint': 6.9.4 '@codemirror/lint': 6.9.4
'@codemirror/search': 6.6.0 '@codemirror/search': 6.6.0
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.29.0 '@codemirror/view': 6.39.15
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies: