Merge branch 'canary' into feat/add-rhel-flavors

This commit is contained in:
Mauricio Siu
2026-04-05 01:10:34 -06:00
293 changed files with 111989 additions and 2127 deletions

View File

@@ -1,7 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const DOKPLOY_DOCKER_API_VERSION =
process.env.DOKPLOY_DOCKER_API_VERSION;
export const DOKPLOY_DOCKER_HOST = process.env.DOKPLOY_DOCKER_HOST;
@@ -10,20 +12,79 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
: undefined;
export const CLEANUP_CRON_JOB = "50 23 * * *";
export const docker = new Docker({
...(DOKPLOY_DOCKER_API_VERSION && {
version: DOKPLOY_DOCKER_API_VERSION,
}),
...(DOKPLOY_DOCKER_HOST && {
host: DOKPLOY_DOCKER_HOST,
}),
...(DOKPLOY_DOCKER_PORT && {
port: DOKPLOY_DOCKER_PORT,
}),
});
type DockerSocketCandidate = {
label: string;
path: string;
};
const getDockerConfig = (): Docker => {
const versionOption = DOKPLOY_DOCKER_API_VERSION
? { version: DOKPLOY_DOCKER_API_VERSION }
: {};
// Explicit remote Docker host configuration
if (DOKPLOY_DOCKER_HOST) {
console.info(
`Using remote Docker host: ${DOKPLOY_DOCKER_HOST}${DOKPLOY_DOCKER_PORT ? `:${DOKPLOY_DOCKER_PORT}` : ""}`,
);
return new Docker({
host: DOKPLOY_DOCKER_HOST,
...(DOKPLOY_DOCKER_PORT && { port: DOKPLOY_DOCKER_PORT }),
...versionOption,
});
}
// Local socket auto-detection (Rancher Desktop, Colima, standard Docker)
const dockerSocketCandidates: Array<DockerSocketCandidate> = [];
if (process.env.DOCKER_HOST) {
dockerSocketCandidates.push({
label: "DOCKER_HOST environment variable",
path: process.env.DOCKER_HOST.replace("unix://", ""),
});
}
if (process.env.HOME) {
dockerSocketCandidates.push({
label: "Rancher Desktop socket",
path: `${process.env.HOME}/.rd/docker.sock`,
});
}
dockerSocketCandidates.push({
label: "Standard Docker socket",
path: "/var/run/docker.sock",
});
for (const candidate of dockerSocketCandidates) {
try {
if (candidate.path && fs.existsSync(candidate.path)) {
console.info(
`Using Docker socket (${candidate.label}): ${candidate.path}`,
);
return new Docker({
socketPath: candidate.path,
...versionOption,
});
}
} catch (e) {
console.info(
`Docker socket initialization failed for ${candidate.label} (${candidate.path}): ${e instanceof Error ? e.message : "Unknown error"}`,
);
}
}
console.info(
"Using default Docker configuration. You can set the DOCKER_HOST environment variable to specify a custom Docker socket path.",
);
return new Docker({ ...versionOption });
};
export const docker = getDockerConfig();
// 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.
export const BETTER_AUTH_SECRET =
process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789";

View File

@@ -163,6 +163,14 @@ export const member = pgTable("member", {
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
accessedGitProviders: text("accessedGitProviders")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
accessedServers: text("accessedServers")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
});
export const memberRelations = relations(member, ({ one }) => ({

View File

@@ -115,6 +115,7 @@ export const applications = pgTable("application", {
subtitle: text("subtitle"),
command: text("command"),
args: text("args").array(),
icon: text("icon"),
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
sourceType: sourceType("sourceType").notNull().default("github"),
cleanCache: boolean("cleanCache").default(false),
@@ -173,7 +174,7 @@ export const applications = pgTable("application", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
//
@@ -368,10 +369,15 @@ const createSchema = createInsertSchema(applications, {
watchPaths: z.array(z.string()).optional().optional(),
previewLabels: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.bigint().nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
enableSubmodules: z.boolean().optional(),
icon: z
.string()
.max(2 * 1024 * 1024, "Icon must be less than 2MB")
.nullable()
.optional(),
});
export const apiCreateApplication = createSchema.pick({

View File

@@ -15,6 +15,7 @@ import { generateAppName } from ".";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { destinations } from "./destination";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
@@ -26,6 +27,7 @@ export const databaseType = pgEnum("databaseType", [
"mysql",
"mongo",
"web-server",
"libsql",
]);
export const backupType = pgEnum("backupType", ["database", "compose"]);
@@ -74,6 +76,9 @@ export const backups = pgTable("backup", {
mongoId: text("mongoId").references((): AnyPgColumn => mongo.mongoId, {
onDelete: "cascade",
}),
libsqlId: text("libsqlId").references((): AnyPgColumn => libsql.libsqlId, {
onDelete: "cascade",
}),
userId: text("userId").references(() => user.id),
// Only for compose backups
metadata: jsonb("metadata").$type<
@@ -118,6 +123,10 @@ export const backupsRelations = relations(backups, ({ one, many }) => ({
fields: [backups.mongoId],
references: [mongo.mongoId],
}),
libsql: one(libsql, {
fields: [backups.libsqlId],
references: [libsql.libsqlId],
}),
user: one(user, {
fields: [backups.userId],
references: [user.id],
@@ -137,11 +146,19 @@ const createSchema = createInsertSchema(backups, {
database: z.string().min(1),
schedule: z.string(),
keepLatestCount: z.number().optional(),
databaseType: z.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]),
databaseType: z.enum([
"postgres",
"mariadb",
"mysql",
"mongo",
"web-server",
"libsql",
]),
postgresId: z.string().optional(),
mariadbId: z.string().optional(),
mysqlId: z.string().optional(),
mongoId: z.string().optional(),
libsqlId: z.string().optional(),
userId: z.string().optional(),
metadata: z.any().optional(),
});
@@ -157,6 +174,7 @@ export const apiCreateBackup = createSchema.pick({
mysqlId: true,
postgresId: true,
mongoId: true,
libsqlId: true,
databaseType: true,
userId: true,
backupType: true,
@@ -192,7 +210,14 @@ export const apiUpdateBackup = createSchema
export const apiRestoreBackup = z.object({
databaseId: z.string(),
databaseType: z.enum(["postgres", "mysql", "mariadb", "mongo", "web-server"]),
databaseType: z.enum([
"postgres",
"mysql",
"mariadb",
"mongo",
"web-server",
"libsql",
]),
backupType: z.enum(["database", "compose"]),
databaseName: z.string().min(1),
backupFile: z.string().min(1),

View File

@@ -56,7 +56,6 @@ export const apiUpdateCertificate = z.object({
name: z.string().min(1).optional(),
certificateData: z.string().min(1).optional(),
privateKey: z.string().min(1).optional(),
autoRenew: z.boolean().optional(),
});
export const apiDeleteCertificate = z.object({

View File

@@ -3,6 +3,10 @@ import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import {
ADDITIONAL_FLAG_ERROR,
ADDITIONAL_FLAG_REGEX,
} from "../validations/destination";
import { organization } from "./account";
import { backups } from "./backups";
@@ -18,6 +22,7 @@ export const destinations = pgTable("destination", {
bucket: text("bucket").notNull(),
region: text("region").notNull(),
endpoint: text("endpoint").notNull(),
additionalFlags: text("additionalFlags").array(),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -44,6 +49,9 @@ const createSchema = createInsertSchema(destinations, {
endpoint: z.string(),
secretAccessKey: z.string(),
region: z.string(),
additionalFlags: z
.array(z.string().regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR))
.default([]),
});
export const apiCreateDestination = createSchema
@@ -55,6 +63,7 @@ export const apiCreateDestination = createSchema
region: true,
endpoint: true,
secretAccessKey: true,
additionalFlags: true,
})
.required()
.extend({
@@ -81,6 +90,7 @@ export const apiUpdateDestination = createSchema
secretAccessKey: true,
destinationId: true,
provider: true,
additionalFlags: true,
})
.required()
.extend({

View File

@@ -1,4 +1,4 @@
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
type AnyPgColumn,
boolean,
@@ -31,6 +31,7 @@ export const domains = pgTable("domain", {
host: text("host").notNull(),
https: boolean("https").notNull().default(false),
port: integer("port").default(3000),
customEntrypoint: text("customEntrypoint"),
path: text("path").default("/"),
serviceName: text("serviceName"),
domainType: domainType("domainType").default("application"),
@@ -53,6 +54,7 @@ export const domains = pgTable("domain", {
certificateType: certificateType("certificateType").notNull().default("none"),
internalPath: text("internalPath").default("/"),
stripPath: boolean("stripPath").notNull().default(false),
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
});
export const domainsRelations = relations(domains, ({ one }) => ({
@@ -80,6 +82,7 @@ export const apiCreateDomain = createSchema.pick({
host: true,
path: true,
port: true,
customEntrypoint: true,
https: true,
applicationId: true,
certificateType: true,
@@ -90,6 +93,7 @@ export const apiCreateDomain = createSchema.pick({
previewDeploymentId: true,
internalPath: true,
stripPath: true,
middlewares: true,
});
export const apiFindDomain = z.object({
@@ -113,6 +117,7 @@ export const apiUpdateDomain = createSchema
host: true,
path: true,
port: true,
customEntrypoint: true,
https: true,
certificateType: true,
customCertResolver: true,
@@ -120,5 +125,6 @@ export const apiUpdateDomain = createSchema
domainType: true,
internalPath: true,
stripPath: true,
middlewares: true,
})
.merge(createSchema.pick({ domainId: true }).required());

View File

@@ -4,6 +4,7 @@ import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { compose } from "./compose";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
@@ -36,12 +37,13 @@ export const environmentRelations = relations(
references: [projects.projectId],
}),
applications: many(applications),
mariadb: many(mariadb),
postgres: many(postgres),
mysql: many(mysql),
redis: many(redis),
mongo: many(mongo),
compose: many(compose),
libsql: many(libsql),
mariadb: many(mariadb),
mongo: many(mongo),
mysql: many(mysql),
postgres: many(postgres),
redis: many(redis),
}),
);

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
@@ -32,6 +32,9 @@ export const gitProvider = pgTable("git_provider", {
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
sharedWithOrganization: boolean("sharedWithOrganization")
.notNull()
.default(false),
});
export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
@@ -64,3 +67,8 @@ export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
export const apiRemoveGitProvider = z.object({
gitProviderId: z.string().min(1),
});
export const apiToggleShareGitProvider = z.object({
gitProviderId: z.string().min(1),
sharedWithOrganization: z.boolean(),
});

View File

@@ -1,7 +1,7 @@
export * from "./account";
export * from "./ai";
export * from "./audit-log";
export * from "./application";
export * from "./audit-log";
export * from "./backups";
export * from "./bitbucket";
export * from "./certificate";
@@ -14,6 +14,7 @@ export * from "./git-provider";
export * from "./gitea";
export * from "./github";
export * from "./gitlab";
export * from "./libsql";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";

View File

@@ -0,0 +1,248 @@
import { relations } from "drizzle-orm";
import {
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { backups } from "./backups";
import { environments } from "./environment";
import { mounts } from "./mount";
import { server } from "./server";
import {
applicationStatus,
type EndpointSpecSwarm,
EndpointSpecSwarmSchema,
type HealthCheckSwarm,
HealthCheckSwarmSchema,
type LabelsSwarm,
LabelsSwarmSchema,
type NetworkSwarm,
NetworkSwarmSchema,
type PlacementSwarm,
PlacementSwarmSchema,
type RestartPolicySwarm,
RestartPolicySwarmSchema,
type ServiceModeSwarm,
ServiceModeSwarmSchema,
sqldNode,
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import {
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const libsql = pgTable("libsql", {
libsqlId: text("libsqlId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
appName: text("appName")
.notNull()
.$defaultFn(() => generateAppName("libsql"))
.unique(),
description: text("description"),
databaseUser: text("databaseUser").notNull(),
databasePassword: text("databasePassword").notNull(),
sqldNode: sqldNode("sqldNode").notNull().default("primary"),
sqldPrimaryUrl: text("sqldPrimaryUrl"),
enableNamespaces: boolean("enableNamespaces").notNull().default(false),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
env: text("env"),
// RESOURCES
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"),
cpuLimit: text("cpuLimit"),
//
externalPort: integer("externalPort"),
externalGRPCPort: integer("externalGRPCPort"),
externalAdminPort: integer("externalAdminPort"),
applicationStatus: applicationStatus("applicationStatus")
.notNull()
.default("idle"),
healthCheckSwarm: json("healthCheckSwarm").$type<HealthCheckSwarm>(),
restartPolicySwarm: json("restartPolicySwarm").$type<RestartPolicySwarm>(),
placementSwarm: json("placementSwarm").$type<PlacementSwarm>(),
updateConfigSwarm: json("updateConfigSwarm").$type<UpdateConfigSwarm>(),
rollbackConfigSwarm: json("rollbackConfigSwarm").$type<UpdateConfigSwarm>(),
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
environmentId: text("environmentId")
.notNull()
.references(() => environments.environmentId, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
});
export const libsqlRelations = relations(libsql, ({ one, many }) => ({
environment: one(environments, {
fields: [libsql.environmentId],
references: [environments.environmentId],
}),
backups: many(backups),
mounts: many(mounts),
server: one(server, {
fields: [libsql.serverId],
references: [server.serverId],
}),
}));
const createSchema = createInsertSchema(libsql, {
libsqlId: z.string(),
name: z.string().min(1),
appName: z.string().min(1),
createdAt: z.string(),
databaseUser: z.string().min(1),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
sqldNode: z.enum(sqldNode.enumValues),
sqldPrimaryUrl: z.string().nullable(),
enableNamespaces: z.boolean().default(false),
dockerImage: z
.string()
.default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
command: z.string().optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),
cpuReservation: z.string().optional(),
cpuLimit: z.string().optional(),
environmentId: z.string(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
externalPort: z.number(),
externalGRPCPort: z.number(),
externalAdminPort: z.number(),
description: z.string().optional(),
serverId: z.string().optional(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
restartPolicySwarm: RestartPolicySwarmSchema.nullable(),
placementSwarm: PlacementSwarmSchema.nullable(),
updateConfigSwarm: UpdateConfigSwarmSchema.nullable(),
rollbackConfigSwarm: UpdateConfigSwarmSchema.nullable(),
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
});
export const apiCreateLibsql = createSchema
.pick({
name: true,
appName: true,
dockerImage: true,
environmentId: true,
description: true,
databaseUser: true,
databasePassword: true,
sqldNode: true,
sqldPrimaryUrl: true,
enableNamespaces: true,
serverId: true,
})
.required()
.superRefine((data, ctx) => {
if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
});
}
if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message:
"sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.",
});
}
});
export const apiFindOneLibsql = z.object({
libsqlId: z.string().min(1),
});
export const apiChangeLibsqlStatus = createSchema
.pick({
libsqlId: true,
applicationStatus: true,
})
.required();
export const apiSaveEnvironmentVariablesLibsql = createSchema
.pick({
libsqlId: true,
env: true,
})
.required();
export const apiSaveExternalPortsLibsql = createSchema
.pick({
libsqlId: true,
externalPort: true,
externalGRPCPort: true,
externalAdminPort: true,
})
.required({ libsqlId: true })
.superRefine((data, ctx) => {
if (
data.externalPort === null &&
data.externalGRPCPort === null &&
data.externalAdminPort === null
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Either externalPort, externalGRPCPort or externalAdminPort must be provided.",
path: ["externalPort", "externalGRPCPort", "externalAdminPort"],
});
}
});
export const apiDeployLibsql = createSchema
.pick({
libsqlId: true,
})
.required();
export const apiResetLibsql = createSchema
.pick({
libsqlId: true,
appName: true,
})
.required();
export const apiUpdateLibsql = createSchema
.partial()
.extend({
libsqlId: z.string().min(1),
})
.omit({ serverId: true });
export const apiRebuildLibsql = createSchema
.pick({
libsqlId: true,
})
.required();

View File

@@ -28,7 +28,13 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const mariadb = pgTable("mariadb", {
mariadbId: text("mariadbId")
@@ -67,7 +73,7 @@ export const mariadb = pgTable("mariadb", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
@@ -108,17 +114,13 @@ const createSchema = createInsertSchema(mariadb, {
createdAt: z.string(),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
.regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
})
.optional(),
dockerImage: z.string().default("mariadb:6"),
@@ -142,7 +144,7 @@ const createSchema = createInsertSchema(mariadb, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});

View File

@@ -35,7 +35,13 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const mongo = pgTable("mongo", {
mongoId: text("mongoId")
@@ -50,7 +56,7 @@ export const mongo = pgTable("mongo", {
description: text("description"),
databaseUser: text("databaseUser").notNull(),
databasePassword: text("databasePassword").notNull(),
dockerImage: text("dockerImage").notNull(),
dockerImage: text("dockerImage").notNull().default("mongo:8"),
command: text("command"),
args: text("args").array(),
env: text("env"),
@@ -70,7 +76,7 @@ export const mongo = pgTable("mongo", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
@@ -110,12 +116,9 @@ const createSchema = createInsertSchema(mongo, {
createdAt: z.string(),
mongoId: z.string(),
name: z.string().min(1),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseUser: z.string().min(1),
dockerImage: z.string().default("mongo:15"),
command: z.string().optional(),
@@ -139,7 +142,7 @@ const createSchema = createInsertSchema(mongo, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});

View File

@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { compose } from "./compose";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
@@ -19,8 +20,11 @@ export const serviceType = pgEnum("serviceType", [
"mongo",
"redis",
"compose",
"libsql",
]);
export type ServiceType = (typeof serviceType.enumValues)[number];
export const mountType = pgEnum("mountType", ["bind", "volume", "file"]);
export const mounts = pgTable("mount", {
@@ -39,7 +43,10 @@ export const mounts = pgTable("mount", {
() => applications.applicationId,
{ onDelete: "cascade" },
),
postgresId: text("postgresId").references(() => postgres.postgresId, {
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
libsqlId: text("libsqlId").references(() => libsql.libsqlId, {
onDelete: "cascade",
}),
mariadbId: text("mariadbId").references(() => mariadb.mariadbId, {
@@ -51,10 +58,10 @@ export const mounts = pgTable("mount", {
mysqlId: text("mysqlId").references(() => mysql.mysqlId, {
onDelete: "cascade",
}),
redisId: text("redisId").references(() => redis.redisId, {
postgresId: text("postgresId").references(() => postgres.postgresId, {
onDelete: "cascade",
}),
composeId: text("composeId").references(() => compose.composeId, {
redisId: text("redisId").references(() => redis.redisId, {
onDelete: "cascade",
}),
});
@@ -64,9 +71,13 @@ export const MountssRelations = relations(mounts, ({ one }) => ({
fields: [mounts.applicationId],
references: [applications.applicationId],
}),
postgres: one(postgres, {
fields: [mounts.postgresId],
references: [postgres.postgresId],
compose: one(compose, {
fields: [mounts.composeId],
references: [compose.composeId],
}),
libsql: one(libsql, {
fields: [mounts.libsqlId],
references: [libsql.libsqlId],
}),
mariadb: one(mariadb, {
fields: [mounts.mariadbId],
@@ -80,14 +91,14 @@ export const MountssRelations = relations(mounts, ({ one }) => ({
fields: [mounts.mysqlId],
references: [mysql.mysqlId],
}),
postgres: one(postgres, {
fields: [mounts.postgresId],
references: [postgres.postgresId],
}),
redis: one(redis, {
fields: [mounts.redisId],
references: [redis.redisId],
}),
compose: one(compose, {
fields: [mounts.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(mounts, {
@@ -107,13 +118,10 @@ const createSchema = createInsertSchema(mounts, {
"mongo",
"redis",
"compose",
"libsql",
]),
});
export type ServiceType = NonNullable<
z.infer<typeof createSchema>["serviceType"]
>;
export const apiCreateMount = createSchema
.pick({
type: true,
@@ -121,8 +129,8 @@ export const apiCreateMount = createSchema
volumeName: true,
content: true,
mountPath: true,
serviceType: true,
filePath: true,
serviceType: true,
})
.extend({
serviceId: z.string().min(1),
@@ -141,23 +149,13 @@ export const apiRemoveMount = createSchema
// })
.required();
export const apiFindMountByApplicationId = createSchema
.extend({
serviceId: z.string().min(1),
serviceType: z.enum([
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"compose",
]),
})
.pick({
serviceId: true,
serviceType: true,
});
export const apiFindMountByApplicationId = z.object({
serviceType: z
.string()
.min(1)
.transform((val) => val as ServiceType),
serviceId: z.string().min(1),
});
export const apiUpdateMount = createSchema.partial().extend({
mountId: z.string().min(1),

View File

@@ -28,7 +28,13 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const mysql = pgTable("mysql", {
mysqlId: text("mysqlId")
@@ -65,7 +71,7 @@ export const mysql = pgTable("mysql", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
@@ -106,17 +112,13 @@ const createSchema = createInsertSchema(mysql, {
name: z.string().min(1),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
.regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
})
.optional(),
dockerImage: z.string().default("mysql:8"),
@@ -139,7 +141,7 @@ const createSchema = createInsertSchema(mysql, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});

View File

@@ -20,6 +20,7 @@ export const notificationType = pgEnum("notificationType", [
"resend",
"gotify",
"ntfy",
"mattermost",
"pushover",
"custom",
"lark",
@@ -37,6 +38,7 @@ export const notifications = pgTable("notification", {
databaseBackup: boolean("databaseBackup").notNull().default(false),
volumeBackup: boolean("volumeBackup").notNull().default(false),
dokployRestart: boolean("dokployRestart").notNull().default(false),
dokployBackup: boolean("dokployBackup").notNull().default(false),
dockerCleanup: boolean("dockerCleanup").notNull().default(false),
serverThreshold: boolean("serverThreshold").notNull().default(false),
notificationType: notificationType("notificationType").notNull(),
@@ -64,6 +66,9 @@ export const notifications = pgTable("notification", {
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
onDelete: "cascade",
}),
mattermostId: text("mattermostId").references(() => mattermost.mattermostId, {
onDelete: "cascade",
}),
customId: text("customId").references(() => custom.customId, {
onDelete: "cascade",
}),
@@ -154,6 +159,16 @@ export const ntfy = pgTable("ntfy", {
priority: integer("priority").notNull().default(3),
});
export const mattermost = pgTable("mattermost", {
mattermostId: text("mattermostId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(),
channel: text("channel"),
username: text("username"),
});
export const custom = pgTable("custom", {
customId: text("customId")
.notNull()
@@ -220,6 +235,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.ntfyId],
references: [ntfy.ntfyId],
}),
mattermost: one(mattermost, {
fields: [notifications.mattermostId],
references: [mattermost.mattermostId],
}),
custom: one(custom, {
fields: [notifications.customId],
references: [custom.customId],
@@ -248,6 +267,7 @@ export const apiCreateSlack = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -276,6 +296,7 @@ export const apiCreateTelegram = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -306,6 +327,7 @@ export const apiCreateDiscord = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -337,6 +359,7 @@ export const apiCreateEmail = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -373,6 +396,7 @@ export const apiCreateResend = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -403,6 +427,7 @@ export const apiCreateGotify = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -437,6 +462,7 @@ export const apiCreateNtfy = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -464,6 +490,53 @@ export const apiTestNtfyConnection = apiCreateNtfy.pick({
priority: true,
});
export const apiCreateMattermost = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
webhookUrl: z.string().url(),
channel: z.string().optional(),
username: z.string().optional(),
})
.required({
name: true,
webhookUrl: true,
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
});
export const apiUpdateMattermost = apiCreateMattermost.partial().extend({
notificationId: z.string().min(1),
mattermostId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestMattermostConnection = apiCreateMattermost
.pick({
webhookUrl: true,
channel: true,
username: true,
})
.extend({
channel: z.string().optional(),
username: z.string().optional(),
});
export const apiFindOneNotification = z.object({
notificationId: z.string().min(1),
});
@@ -472,6 +545,7 @@ export const apiCreateCustom = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -499,6 +573,7 @@ export const apiCreateLark = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -525,6 +600,7 @@ export const apiCreateTeams = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -551,6 +627,7 @@ export const apiCreatePushover = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -585,6 +662,7 @@ export const apiUpdatePushover = z.object({
expire: z.number().min(1).max(10800).nullish(),
appBuildError: z.boolean().optional(),
databaseBackup: z.boolean().optional(),
dokployBackup: z.boolean().optional(),
volumeBackup: z.boolean().optional(),
dokployRestart: z.boolean().optional(),
name: z.string().optional(),

View File

@@ -28,7 +28,13 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const postgres = pgTable("postgres", {
postgresId: text("postgresId")
@@ -65,7 +71,7 @@ export const postgres = pgTable("postgres", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
@@ -103,12 +109,9 @@ const createSchema = createInsertSchema(postgres, {
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
dockerImage: z.string().default("postgres:18"),
@@ -133,7 +136,7 @@ const createSchema = createInsertSchema(postgres, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});

View File

@@ -64,7 +64,7 @@ export const redis = pgTable("redis", {
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
ulimitsSwarm: json("ulimitsSwarm").$type<UlimitsSwarm>(),
replicas: integer("replicas").default(1).notNull(),
@@ -121,7 +121,7 @@ const createSchema = createInsertSchema(redis, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});

View File

@@ -15,6 +15,7 @@ import { applications } from "./application";
import { certificates } from "./certificate";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
@@ -116,6 +117,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
relationName: "applicationBuildServer",
}),
compose: many(compose),
libsql: many(libsql),
redis: many(redis),
mariadb: many(mariadb),
mongo: many(mongo),

View File

@@ -16,6 +16,8 @@ export const certificateType = pgEnum("certificateType", [
export const triggerType = pgEnum("triggerType", ["push", "tag"]);
export const sqldNode = pgEnum("sqldNode", ["primary", "replica"]);
export interface HealthCheckSwarm {
Test?: string[] | undefined;
Interval?: number | undefined;

View File

@@ -1,5 +1,5 @@
import { paths } from "@dokploy/server/constants";
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
boolean,
integer,
@@ -65,7 +65,11 @@ export const user = pgTable("user", {
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
isEnterpriseCloud: boolean("isEnterpriseCloud").notNull().default(false),
trustedOrigins: text("trustedOrigins").array(),
bookmarkedTemplates: text("bookmarkedTemplates")
.array()
.default(sql`ARRAY[]::text[]`),
});
export const usersRelations = relations(user, ({ one, many }) => ({
@@ -87,7 +91,9 @@ const createSchema = createInsertSchema(user, {
}).omit({
role: true,
trustedOrigins: true,
bookmarkedTemplates: true,
isValidEnterpriseLicense: true,
isEnterpriseCloud: true,
});
export const apiCreateUserInvitation = createSchema.pick({}).extend({
@@ -126,6 +132,8 @@ export const apiAssignPermissions = createSchema
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
accessedGitProviders: z.array(z.string()).optional(),
accessedServers: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional(),
canCreateServices: z.boolean().optional(),
canDeleteProjects: z.boolean().optional(),

View File

@@ -12,6 +12,13 @@ export const APP_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
export const APP_NAME_MESSAGE =
"App name can only contain letters, numbers, dots, underscores and hyphens";
/** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */
export const DATABASE_PASSWORD_REGEX =
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;
export const DATABASE_PASSWORD_MESSAGE =
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility";
export const generateAppName = (type: string) => {
const verb = faker.hacker.verb().replace(/ /g, "-");
const adjective = faker.hacker.adjective().replace(/ /g, "-");

View File

@@ -7,6 +7,7 @@ import { applications } from "./application";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { destinations } from "./destination";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { serviceType } from "./mount";
@@ -53,6 +54,9 @@ export const volumeBackups = pgTable("volume_backup", {
redisId: text("redisId").references(() => redis.redisId, {
onDelete: "cascade",
}),
libsqlId: text("libsqlId").references(() => libsql.libsqlId, {
onDelete: "cascade",
}),
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
@@ -93,6 +97,10 @@ export const volumeBackupsRelations = relations(
fields: [volumeBackups.redisId],
references: [redis.redisId],
}),
libsql: one(libsql, {
fields: [volumeBackups.libsqlId],
references: [libsql.libsqlId],
}),
compose: one(compose, {
fields: [volumeBackups.composeId],
references: [compose.composeId],

View File

@@ -0,0 +1,3 @@
export const ADDITIONAL_FLAG_REGEX = /^--[a-zA-Z0-9-]+(=[a-zA-Z0-9._:/@-]+)?$/;
export const ADDITIONAL_FLAG_ERROR =
"Invalid flag format. Must start with -- (e.g. --s3-sign-accept-encoding=false)";

View File

@@ -20,6 +20,7 @@ export const domain = z
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string(),
middlewares: z.array(z.string()).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
@@ -83,6 +84,7 @@ export const domainCompose = z
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string(),
serviceName: z.string().min(1, { message: "Service name is required" }),
middlewares: z.array(z.string()).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {

View File

@@ -14,7 +14,7 @@ import {
export type TemplateProps = {
projectName: string;
applicationName: string;
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb";
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb" | "libsql";
type: "error" | "success";
errorMessage?: string;
date: string;

View File

@@ -0,0 +1,106 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
type: "error" | "success";
errorMessage?: string;
date: string;
backupSize?: string;
};
export const DokployBackupEmail = ({
type = "success",
errorMessage,
date = "2023-05-01T00:00:00.000Z",
backupSize,
}: TemplateProps) => {
const previewText = `Dokploy instance backup was ${type === "success" ? "successful ✅" : "failed ❌"}`;
return (
<Html>
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
brand: "#007291",
},
},
},
}}
>
<Head />
<Body className="bg-white my-auto mx-auto font-sans px-2">
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
<Section className="mt-[32px]">
<Img
src={
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
}
width="100"
height="50"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Dokploy Instance Backup
</Heading>
<Text className="text-black text-[14px] leading-[24px]">
Hello,
</Text>
<Text className="text-black text-[14px] leading-[24px]">
Your Dokploy instance backup was{" "}
{type === "success"
? "successful ✅"
: "failed. Please check the error message below. ❌"}
.
</Text>
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
<Text className="!leading-3 font-bold">Details: </Text>
<Text className="!leading-3">
Backup Type: <strong>Complete Dokploy Instance</strong>
</Text>
<Text className="!leading-3">
Content: <strong>/etc/dokploy + PostgreSQL Database</strong>
</Text>
{backupSize && (
<Text className="!leading-3">
Backup Size: <strong>{backupSize}</strong>
</Text>
)}
<Text className="!leading-3">
Date: <strong>{date}</strong>
</Text>
<Text className="!leading-3">
Status:{" "}
<strong>{type === "success" ? "Successful" : "Failed"}</strong>
</Text>
</Section>
{type === "error" && errorMessage ? (
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
<Text className="!leading-3 font-bold">Reason: </Text>
<Text className="text-[12px] leading-[24px]">
{errorMessage || "Error message not provided"}
</Text>
</Section>
) : null}
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default DokployBackupEmail;

View File

@@ -22,7 +22,8 @@ export type TemplateProps = {
| "mongodb"
| "mariadb"
| "redis"
| "compose";
| "compose"
| "libsql";
type: "error" | "success";
errorMessage?: string;
backupSize?: string;

View File

@@ -1,6 +1,7 @@
export * from "./auth/random-password";
export * from "./constants/index";
export * from "./db/constants";
export * from "./db/validations/destination";
export * from "./db/validations/domain";
export * from "./db/validations/index";
export * from "./lib/auth";
@@ -22,6 +23,7 @@ export * from "./services/git-provider";
export * from "./services/gitea";
export * from "./services/github";
export * from "./services/gitlab";
export * from "./services/libsql";
export * from "./services/mariadb";
export * from "./services/mongo";
export * from "./services/mount";
@@ -67,6 +69,7 @@ export * from "./utils/access-log/types";
export * from "./utils/access-log/utils";
export * from "./utils/backups/compose";
export * from "./utils/backups/index";
export * from "./utils/backups/libsql";
export * from "./utils/backups/mariadb";
export * from "./utils/backups/mongo";
export * from "./utils/backups/mysql";
@@ -118,7 +121,7 @@ export * from "./utils/providers/raw";
export * from "./utils/schedules/index";
export * from "./utils/schedules/utils";
export * from "./utils/servers/remote-docker";
export * from "./utils/startup/cancell-deployments";
export * from "./utils/startup/cancel-deployments";
export * from "./utils/tracking/hubspot";
export * from "./utils/traefik/application";
export * from "./utils/traefik/domain";

View File

@@ -37,7 +37,7 @@ export const statements = {
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
@@ -102,7 +102,7 @@ export const ownerRole = ac.newRole({
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
@@ -139,7 +139,7 @@ export const adminRole = ac.newRole({
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],

View File

@@ -33,6 +33,7 @@ export const findBackupById = async (backupId: string) => {
mysql: true,
mariadb: true,
mongo: true,
libsql: true,
destination: true,
compose: true,
},
@@ -72,7 +73,7 @@ export const removeBackupById = async (backupId: string) => {
export const findBackupsByDbId = async (
id: string,
type: "postgres" | "mysql" | "mariadb" | "mongo",
type: "postgres" | "mysql" | "mariadb" | "mongo" | "libsql",
) => {
const result = await db.query.backups.findMany({
where: eq(backups[`${type}Id`], id),
@@ -81,6 +82,7 @@ export const findBackupsByDbId = async (
mysql: true,
mariadb: true,
mongo: true,
libsql: true,
destination: true,
},
});

View File

@@ -126,3 +126,36 @@ const createCertificateFiles = async (certificate: Certificate) => {
fs.writeFileSync(configFile, yamlConfig);
}
};
export const updateCertificate = async (
certificateId: string,
updates: {
name?: string;
certificateData?: string;
privateKey?: string;
},
) => {
const updated = await db
.update(certificates)
.set({
...updates,
})
.where(eq(certificates.certificateId, certificateId))
.returning();
if (!updated || updated[0] === undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to update the certificate",
});
}
const cert = updated[0];
// If cert data or private key changed, rewrite files
if (updates.certificateData || updates.privateKey) {
await createCertificateFiles(cert);
}
return cert;
};

View File

@@ -23,7 +23,7 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { format } from "date-fns";
import { desc, eq, and, inArray, or, sql } from "drizzle-orm";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import type { z } from "zod";
import {
type Application,
@@ -177,7 +177,7 @@ export const createDeployment = async (
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -257,7 +257,7 @@ export const createDeploymentPreview = async (
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -334,7 +334,7 @@ echo "Initializing deployment\n" >> ${logFilePath};
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -418,7 +418,7 @@ echo "Initializing backup\n" >> ${logFilePath};
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -493,7 +493,7 @@ export const createDeploymentSchedule = async (
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -578,7 +578,7 @@ export const createDeploymentVolumeBackup = async (
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})

View File

@@ -9,7 +9,7 @@ import type { z } from "zod";
export type Destination = typeof destinations.$inferSelect;
export const createDestintation = async (
export const createDestination = async (
input: z.infer<typeof apiCreateDestination>,
organizationId: string,
) => {

View File

@@ -371,6 +371,21 @@ export const containerRestart = async (containerId: string) => {
} catch {}
};
export const containerRemove = async (
containerId: string,
serverId?: string,
) => {
const command = `docker rm -f ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const getSwarmNodes = async (serverId?: string) => {
try {
let stdout = "";
@@ -397,7 +412,9 @@ export const getSwarmNodes = async (serverId?: string) => {
.split("\n")
.map((line) => JSON.parse(line));
return nodesArray;
} catch {}
} catch (error) {
console.error("getSwarmNodes error:", error);
}
};
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
@@ -448,6 +465,10 @@ export const getNodeApplications = async (serverId?: string) => {
return;
}
if (!stdout.trim()) {
return [];
}
const appArray = stdout
.trim()
.split("\n")
@@ -455,13 +476,19 @@ export const getNodeApplications = async (serverId?: string) => {
.filter((service) => !service.Name.startsWith("dokploy-"));
return appArray;
} catch {}
} catch (error) {
console.error("getNodeApplications error:", error);
return [];
}
};
export const getApplicationInfo = async (
appNames: string[],
serverId?: string,
) => {
if (appNames.length === 0) {
return [];
}
try {
let stdout = "";
let stderr = "";
@@ -482,11 +509,84 @@ export const getApplicationInfo = async (
return;
}
if (!stdout.trim()) {
return [];
}
const appArray = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return appArray;
} catch {}
} catch (error) {
console.error("getApplicationInfo error:", error);
return [];
}
};
export const getAllContainerStats = async (serverId?: string) => {
try {
let stdout = "";
const command =
'docker stats --no-stream --format \'{"BlockIO":"{{.BlockIO}}","CPUPerc":"{{.CPUPerc}}","Container":"{{.Container}}","ID":"{{.ID}}","MemPerc":"{{.MemPerc}}","MemUsage":"{{.MemUsage}}","Name":"{{.Name}}","NetIO":"{{.NetIO}}"}\'';
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
if (!stdout.trim()) {
return [];
}
const stats = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return stats;
} catch (error) {
console.error("getAllContainerStats error:", error);
return [];
}
};
export const uploadFileToContainer = async (
containerId: string,
fileBuffer: Buffer,
fileName: string,
destinationPath: string,
serverId?: string | null,
): Promise<void> => {
const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
if (!containerIdRegex.test(containerId)) {
throw new Error("Invalid container ID");
}
// Ensure destination path starts with /
const normalizedPath = destinationPath.startsWith("/")
? destinationPath
: `/${destinationPath}`;
const base64Content = fileBuffer.toString("base64");
const tempFileName = `dokploy-upload-${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
const tempPath = `/tmp/${tempFileName}`;
const command = `echo '${base64Content}' | base64 -d > "${tempPath}" && docker cp "${tempPath}" "${containerId}:${normalizedPath}" ; rm -f "${tempPath}"`;
try {
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
} catch (error) {
throw new Error(
`Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`,
);
}
};

View File

@@ -59,6 +59,7 @@ export const findEnvironmentById = async (environmentId: string) => {
applicationStatus: true,
description: true,
serverId: true,
icon: true,
},
},
mariadb: {
@@ -169,6 +170,24 @@ export const findEnvironmentById = async (environmentId: string) => {
serverId: true,
},
},
libsql: {
with: {
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
libsqlId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
project: true,
},
});
@@ -193,6 +212,7 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
postgres: true,
redis: true,
compose: true,
libsql: true,
project: true,
},
columns: {
@@ -211,6 +231,7 @@ const environmentHasServices = (
return (
(env.applications?.length ?? 0) > 0 ||
(env.compose?.length ?? 0) > 0 ||
(env.libsql?.length ?? 0) > 0 ||
(env.mariadb?.length ?? 0) > 0 ||
(env.mongo?.length ?? 0) > 0 ||
(env.mysql?.length ?? 0) > 0 ||

View File

@@ -1,7 +1,8 @@
import { db } from "@dokploy/server/db";
import { gitProvider } from "@dokploy/server/db/schema";
import { gitProvider, member } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
export type GitProvider = typeof gitProvider.$inferSelect;
@@ -41,3 +42,48 @@ export const updateGitProvider = async (
.returning()
.then((response) => response[0]);
};
export const getAccessibleGitProviderIds = async (session: {
userId: string;
activeOrganizationId: string;
}): Promise<Set<string>> => {
const { userId, activeOrganizationId } = session;
const allOrgProviders = await db.query.gitProvider.findMany({
where: eq(gitProvider.organizationId, activeOrganizationId),
columns: {
gitProviderId: true,
userId: true,
sharedWithOrganization: true,
},
});
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { accessedGitProviders: true, role: true },
});
if (memberRecord?.role === "owner" || memberRecord?.role === "admin") {
return new Set(allOrgProviders.map((p) => p.gitProviderId));
}
const licensed = await hasValidLicense(activeOrganizationId);
const assignedSet = licensed
? new Set(memberRecord?.accessedGitProviders ?? [])
: new Set<string>();
const result = new Set<string>();
for (const p of allOrgProviders) {
if (
p.userId === userId ||
p.sharedWithOrganization ||
assignedSet.has(p.gitProviderId)
) {
result.add(p.gitProviderId);
}
}
return result;
};

View File

@@ -0,0 +1,162 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateLibsql,
backups,
buildAppName,
libsql,
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
export type Libsql = typeof libsql.$inferSelect;
export const createLibsql = async (input: z.infer<typeof apiCreateLibsql>) => {
const appName = buildAppName("libsql", input.appName);
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
const newLibsql = await db
.insert(libsql)
.values({
...input,
databasePassword: input.databasePassword
? input.databasePassword
: generatePassword(),
appName,
})
.returning()
.then((value) => value[0]);
if (!newLibsql) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting libsql database",
});
}
return newLibsql;
};
// https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881
export const findLibsqlById = async (libsqlId: string) => {
const result = await db.query.libsql.findFirst({
where: eq(libsql.libsqlId, libsqlId),
with: {
environment: {
with: {
project: true,
},
},
mounts: true,
server: true,
backups: {
with: {
destination: true,
deployments: true,
},
},
},
});
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Libsql not found",
});
}
return result;
};
export const updateLibsqlById = async (
libsqlId: string,
libsqlData: Partial<Libsql>,
) => {
const { appName, ...rest } = libsqlData;
const result = await db
.update(libsql)
.set({
...rest,
})
.where(eq(libsql.libsqlId, libsqlId))
.returning();
return result[0];
};
export const removeLibsqlById = async (libsqlId: string) => {
const result = await db
.delete(libsql)
.where(eq(libsql.libsqlId, libsqlId))
.returning();
return result[0];
};
export const findLibsqlByBackupId = async (backupId: string) => {
const result = await db
.select({
...getTableColumns(libsql),
})
.from(libsql)
.innerJoin(backups, eq(libsql.libsqlId, backups.libsqlId))
.where(eq(backups.backupId, backupId))
.limit(1);
if (!result || !result[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Libsql not found",
});
}
return result[0];
};
export const deployLibsql = async (
libsqlId: string,
onData?: (data: any) => void,
) => {
const libsql = await findLibsqlById(libsqlId);
try {
await updateLibsqlById(libsqlId, {
applicationStatus: "running",
});
onData?.("Starting libsql deployment...");
if (libsql.serverId) {
await execAsyncRemote(
libsql.serverId,
`docker pull ${libsql.dockerImage}`,
onData,
);
} else {
await pullImage(libsql.dockerImage, onData);
}
await buildLibsql(libsql);
await updateLibsqlById(libsqlId, {
applicationStatus: "done",
});
onData?.("Deployment completed successfully!");
} catch (error) {
onData?.(`Error: ${error}`);
await updateLibsqlById(libsqlId, {
applicationStatus: "error",
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error on deploy libsql${error}`,
});
}
return libsql;
};

View File

@@ -32,8 +32,11 @@ export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
...(input.serviceType === "application" && {
applicationId: serviceId,
}),
...(input.serviceType === "postgres" && {
postgresId: serviceId,
...(input.serviceType === "compose" && {
composeId: serviceId,
}),
...(input.serviceType === "libsql" && {
libsqlId: serviceId,
}),
...(input.serviceType === "mariadb" && {
mariadbId: serviceId,
@@ -44,12 +47,12 @@ export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
...(input.serviceType === "mysql" && {
mysqlId: serviceId,
}),
...(input.serviceType === "postgres" && {
postgresId: serviceId,
}),
...(input.serviceType === "redis" && {
redisId: serviceId,
}),
...(input.serviceType === "compose" && {
composeId: serviceId,
}),
})
.returning()
.then((value) => value[0]);
@@ -115,7 +118,16 @@ export const findMountById = async (mountId: string) => {
},
},
},
postgres: {
compose: {
with: {
environment: {
with: {
project: true,
},
},
},
},
libsql: {
with: {
environment: {
with: {
@@ -151,7 +163,7 @@ export const findMountById = async (mountId: string) => {
},
},
},
redis: {
postgres: {
with: {
environment: {
with: {
@@ -160,7 +172,7 @@ export const findMountById = async (mountId: string) => {
},
},
},
compose: {
redis: {
with: {
environment: {
with: {
@@ -186,8 +198,11 @@ export const findMountOrganizationId = async (mountId: string) => {
if (mount.application) {
return mount.application.environment.project.organizationId;
}
if (mount.postgres) {
return mount.postgres.environment.project.organizationId;
if (mount.compose) {
return mount.compose.environment.project.organizationId;
}
if (mount.libsql) {
return mount.libsql.environment.project.organizationId;
}
if (mount.mariadb) {
return mount.mariadb.environment.project.organizationId;
@@ -198,13 +213,13 @@ export const findMountOrganizationId = async (mountId: string) => {
if (mount.mysql) {
return mount.mysql.environment.project.organizationId;
}
if (mount.postgres) {
return mount.postgres.environment.project.organizationId;
}
if (mount.redis) {
return mount.redis.environment.project.organizationId;
}
if (mount.compose) {
return mount.compose.environment.project.organizationId;
}
return null;
};
@@ -248,8 +263,8 @@ export const findMountsByApplicationId = async (
case "application":
sqlChunks.push(eq(mounts.applicationId, serviceId));
break;
case "postgres":
sqlChunks.push(eq(mounts.postgresId, serviceId));
case "libsql":
sqlChunks.push(eq(mounts.libsqlId, serviceId));
break;
case "mariadb":
sqlChunks.push(eq(mounts.mariadbId, serviceId));
@@ -260,6 +275,9 @@ export const findMountsByApplicationId = async (
case "mysql":
sqlChunks.push(eq(mounts.mysqlId, serviceId));
break;
case "postgres":
sqlChunks.push(eq(mounts.postgresId, serviceId));
break;
case "redis":
sqlChunks.push(eq(mounts.redisId, serviceId));
break;
@@ -362,6 +380,10 @@ export const getBaseFilesPath = async (mountId: string) => {
const { COMPOSE_PATH } = paths(!!mount.compose.serverId);
appName = mount.compose.appName;
absoluteBasePath = path.resolve(COMPOSE_PATH);
} else if (mount.serviceType === "libsql" && mount.libsql) {
const { APPLICATIONS_PATH } = paths(!!mount.libsql.serverId);
absoluteBasePath = path.resolve(APPLICATIONS_PATH);
appName = mount.libsql.appName;
}
directoryPath = path.join(absoluteBasePath, appName, "files");
@@ -391,6 +413,9 @@ export const getServerId = async (mount: MountNested) => {
if (mount.serviceType === "compose" && mount?.compose?.serverId) {
return mount.compose.serverId;
}
if (mount.serviceType === "libsql" && mount?.libsql?.serverId) {
return mount.libsql.serverId;
}
return null;
};

View File

@@ -5,6 +5,7 @@ import {
type apiCreateEmail,
type apiCreateGotify,
type apiCreateLark,
type apiCreateMattermost,
type apiCreateNtfy,
type apiCreatePushover,
type apiCreateResend,
@@ -16,6 +17,7 @@ import {
type apiUpdateEmail,
type apiUpdateGotify,
type apiUpdateLark,
type apiUpdateMattermost,
type apiUpdateNtfy,
type apiUpdatePushover,
type apiUpdateResend,
@@ -27,6 +29,7 @@ import {
email,
gotify,
lark,
mattermost,
notifications,
ntfy,
pushover,
@@ -70,6 +73,7 @@ export const createSlackNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -102,6 +106,7 @@ export const updateSlackNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -163,6 +168,7 @@ export const createTelegramNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -195,6 +201,7 @@ export const updateTelegramNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -256,6 +263,7 @@ export const createDiscordNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -288,6 +296,7 @@ export const updateDiscordNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -352,6 +361,7 @@ export const createEmailNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -384,6 +394,7 @@ export const updateEmailNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -449,6 +460,7 @@ export const createResendNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -481,6 +493,7 @@ export const updateResendNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -544,6 +557,7 @@ export const createGotifyNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -575,6 +589,7 @@ export const updateGotifyNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -636,6 +651,7 @@ export const createNtfyNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -667,6 +683,7 @@ export const updateNtfyNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -726,6 +743,8 @@ export const createCustomNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "custom",
@@ -757,6 +776,7 @@ export const updateCustomNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -797,6 +817,7 @@ export const findNotificationById = async (notificationId: string) => {
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
@@ -849,6 +870,8 @@ export const createLarkNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "lark",
@@ -880,6 +903,7 @@ export const updateLarkNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -938,6 +962,7 @@ export const createTeamsNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -970,6 +995,7 @@ export const updateTeamsNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -1015,6 +1041,102 @@ export const updateNotificationById = async (
return result[0];
};
export const createMattermostNotification = async (
input: z.infer<typeof apiCreateMattermost>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newMattermost = await tx
.insert(mattermost)
.values({
webhookUrl: input.webhookUrl,
channel: input.channel,
username: input.username,
})
.returning()
.then((value) => value[0]);
if (!newMattermost) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting mattermost",
});
}
const newDestination = await tx
.insert(notifications)
.values({
mattermostId: newMattermost.mattermostId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "mattermost",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateMattermostNotification = async (
input: z.infer<typeof apiUpdateMattermost>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(mattermost)
.set({
webhookUrl: input.webhookUrl,
channel: input.channel,
username: input.username,
})
.where(eq(mattermost.mattermostId, input.mattermostId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const createPushoverNotification = async (
input: z.infer<typeof apiCreatePushover>,
organizationId: string,
@@ -1047,6 +1169,7 @@ export const createPushoverNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -1079,6 +1202,7 @@ export const updatePushoverNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,

View File

@@ -2,6 +2,7 @@ import { db } from "@dokploy/server/db";
import {
type apiCreateProject,
applications,
libsql,
mariadb,
mongo,
mysql,
@@ -53,12 +54,13 @@ export const findProjectById = async (projectId: string) => {
environments: {
with: {
applications: true,
compose: true,
libsql: true,
mariadb: true,
mongo: true,
mysql: true,
postgres: true,
redis: true,
compose: true,
},
},
projectTags: {
@@ -109,6 +111,9 @@ export const validUniqueServerAppName = async (appName: string) => {
applications: {
where: eq(applications.appName, appName),
},
libsql: {
where: eq(libsql.appName, appName),
},
mariadb: {
where: eq(mariadb.appName, appName),
},
@@ -131,6 +136,7 @@ export const validUniqueServerAppName = async (appName: string) => {
const nonEmptyProjects = query.filter(
(project) =>
project.applications.length > 0 ||
project.libsql.length > 0 ||
project.mariadb.length > 0 ||
project.mongo.length > 0 ||
project.mysql.length > 0 ||

View File

@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db";
import { auditLog } from "@dokploy/server/db/schema";
import type { AuditAction, AuditResourceType } from "@dokploy/server/db/schema";
import { auditLog } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { and, desc, eq, gte, ilike, lte } from "drizzle-orm";

View File

@@ -1,11 +1,13 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateServer,
member,
organization,
server,
} from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { z } from "zod";
export type Server = typeof server.$inferSelect;
@@ -80,11 +82,12 @@ export const haveActiveServices = async (serverId: string) => {
with: {
applications: true,
compose: true,
redis: true,
libsql: true,
mariadb: true,
mongo: true,
mysql: true,
postgres: true,
redis: true,
},
});
@@ -95,11 +98,12 @@ export const haveActiveServices = async (serverId: string) => {
const total =
currentServer?.applications?.length +
currentServer?.compose?.length +
currentServer?.redis?.length +
currentServer?.libsql?.length +
currentServer?.mariadb?.length +
currentServer?.mongo?.length +
currentServer?.mysql?.length +
currentServer?.postgres?.length;
currentServer?.postgres?.length +
currentServer?.redis?.length;
if (total === 0) {
return false;
@@ -128,3 +132,37 @@ export const getAllServers = async () => {
const servers = await db.query.server.findMany();
return servers;
};
export const getAccessibleServerIds = async (session: {
userId: string;
activeOrganizationId: string;
}): Promise<Set<string>> => {
const { userId, activeOrganizationId } = session;
const allOrgServers = await db.query.server.findMany({
where: eq(server.organizationId, activeOrganizationId),
columns: {
serverId: true,
},
});
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { accessedServers: true, role: true },
});
if (memberRecord?.role === "owner" || memberRecord?.role === "admin") {
return new Set(allOrgServers.map((s) => s.serverId));
}
const licensed = await hasValidLicense(activeOrganizationId);
if (!licensed) {
return new Set(allOrgServers.map((s) => s.serverId));
}
return new Set(memberRecord?.accessedServers ?? []);
};

View File

@@ -383,12 +383,12 @@ export const readPorts = async (
const seenPorts = new Set<string>();
for (const key in parsedResult) {
if (Object.hasOwn(parsedResult, key)) {
const containerPortMapppings = parsedResult[key];
const containerPortMappings = parsedResult[key];
const protocol = key.split("/")[1];
const targetPort = Number.parseInt(key.split("/")[0] ?? "0", 10);
// Take only the first mapping to avoid duplicates (IPv4 and IPv6)
const firstMapping = containerPortMapppings[0];
const firstMapping = containerPortMappings[0];
if (firstMapping) {
const publishedPort = Number.parseInt(firstMapping.HostPort, 10);
const portKey = `${targetPort}-${publishedPort}-${protocol}`;

View File

@@ -1,6 +1,13 @@
import { db } from "@dokploy/server/db";
import { apikey, member, user } from "@dokploy/server/db/schema";
import {
account,
apikey,
invitation,
member,
user,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, eq } from "drizzle-orm";
import { auth } from "../lib/auth";
@@ -89,7 +96,7 @@ export const canPerformAccessService = async (
return false;
};
export const canPeformDeleteService = async (
export const canPerformDeleteService = async (
userId: string,
serviceId: string,
organizationId: string,
@@ -215,7 +222,7 @@ export const checkServiceAccess = async (
);
break;
case "delete":
hasPermission = await canPeformDeleteService(
hasPermission = await canPerformDeleteService(
userId,
serviceId,
organizationId,
@@ -389,6 +396,93 @@ export const findMemberById = async (
return result;
};
export const createOrganizationUserWithCredentials = async ({
organizationId,
email,
password,
role,
}: {
organizationId: string;
email: string;
password: string;
role: string;
}) => {
const normalizedEmail = email.trim().toLowerCase();
const now = new Date();
return await db.transaction(async (tx) => {
const existingUser = await tx.query.user.findFirst({
where: eq(user.email, normalizedEmail),
columns: {
id: true,
},
});
if (existingUser) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"This email already has an account. Use the invitation link flow for existing users.",
});
}
const createdUser = await tx
.insert(user)
.values({
email: normalizedEmail,
emailVerified: false,
updatedAt: now,
})
.returning({
id: user.id,
email: user.email,
})
.then((res) => res[0]);
if (!createdUser) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create user",
});
}
await tx.insert(account).values({
userId: createdUser.id,
providerId: "credential",
password: bcrypt.hashSync(password, 10),
createdAt: now,
updatedAt: now,
});
await tx.insert(member).values({
organizationId,
userId: createdUser.id,
role,
createdAt: now,
isDefault: true,
});
await tx
.update(invitation)
.set({
status: "canceled",
})
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.email, normalizedEmail),
eq(invitation.status, "pending"),
),
);
return {
userId: createdUser.id,
email: createdUser.email,
role,
};
});
};
export const updateUser = async (userId: string, userData: Partial<User>) => {
// Validate email if it's being updated
if (userData.email !== undefined) {

View File

@@ -75,6 +75,15 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
},
},
},
libsql: {
with: {
environment: {
with: {
project: true,
},
},
},
},
destination: true,
},
});

View File

@@ -115,9 +115,20 @@ SYS_ARCH=$(uname -m)
CURRENT_USER=$USER
echo "Installing requirements for: OS: $OS_TYPE"
if [ $EUID != 0 ]; then
echo "Please run this script as root or with sudo ❌"
exit
# Auto-detect sudo requirement
if [ "$EUID" -eq 0 ]; then
SUDO_CMD=""
echo "Running as root"
else
if sudo -n true 2>/dev/null; then
SUDO_CMD="sudo"
echo "Running as $CURRENT_USER with sudo privileges"
else
echo "Error: Non-root user requires passwordless sudo access. ❌"
echo "Configure with: echo '$CURRENT_USER ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/$CURRENT_USER"
exit 1
fi
fi
# Check if the OS is manjaro, if so, change it to arch
@@ -152,7 +163,7 @@ else
fi
if [ "$OS_TYPE" = 'amzn' ]; then
dnf install -y findutils >/dev/null
$SUDO_CMD dnf install -y findutils >/dev/null
fi
case "$OS_TYPE" in
@@ -218,6 +229,9 @@ ${installBuildpacks()}
echo -e "13. Installing Railpack"
${installRailpack()}
echo -e "14. Configuring permissions"
${setupPermissions()}
`
: `
echo -e "2. Installing Docker. "
@@ -235,6 +249,9 @@ ${installBuildpacks()}
echo -e "6. Installing Railpack"
${installRailpack()}
echo -e "7. Configuring permissions"
${setupPermissions()}
`
}
`;
@@ -352,16 +369,18 @@ const setupMainDirectory = () => `
echo "/etc/dokploy already exists ✅"
else
# Create the /etc/dokploy directory
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
$SUDO_CMD mkdir -p /etc/dokploy
echo "Directory /etc/dokploy created ✅"
fi
# Ensure the current user owns the directory
if [ -n "$SUDO_CMD" ]; then
$SUDO_CMD chown -R $CURRENT_USER:$CURRENT_USER /etc/dokploy
fi
`;
export const setupSwarm = () => `
# Check if the node is already part of a Docker Swarm
if docker info | grep -q 'Swarm: active'; then
if $SUDO_CMD docker info | grep -q 'Swarm: active'; then
echo "Already part of a Docker Swarm ✅"
else
# Get IP address
@@ -411,18 +430,18 @@ export const setupSwarm = () => `
echo "Advertise address: \$advertise_addr"
# Initialize Docker Swarm
docker swarm init --advertise-addr \$advertise_addr
$SUDO_CMD docker swarm init --advertise-addr \$advertise_addr
echo "Swarm initialized ✅"
fi
`;
const setupNetwork = () => `
# Check if the dokploy-network already exists
if docker network ls | grep -q 'dokploy-network'; then
if $SUDO_CMD docker network ls | grep -q 'dokploy-network'; then
echo "Network dokploy-network already exists ✅"
else
# Create the dokploy-network if it doesn't exist
if docker network create --driver overlay --attachable dokploy-network; then
if $SUDO_CMD docker network create --driver overlay --attachable dokploy-network; then
echo "Network created ✅"
else
echo "Failed to create dokploy-network ❌" >&2
@@ -447,33 +466,34 @@ const installUtilities = () => `
case "$OS_TYPE" in
arch)
pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true
$SUDO_CMD pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true
;;
alpine)
sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
apk update >/dev/null
apk add curl wget git git-lfs jq openssl sudo unzip tar >/dev/null
$SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
$SUDO_CMD apk update >/dev/null
$SUDO_CMD apk add curl wget git git-lfs jq openssl sudo unzip tar >/dev/null
;;
ubuntu | debian | raspbian)
DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl wget git git-lfs jq openssl >/dev/null
export DEBIAN_FRONTEND=noninteractive
$SUDO_CMD apt-get update -y >/dev/null
$SUDO_CMD apt-get install -y unzip curl wget git git-lfs jq openssl >/dev/null
;;
centos | fedora | rhel | ol | rocky | almalinux | opencloudos | amzn)
if [ "$OS_TYPE" = "amzn" ]; then
dnf install -y wget git git-lfs jq openssl >/dev/null
$SUDO_CMD dnf install -y wget git git-lfs jq openssl >/dev/null
else
if ! command -v dnf >/dev/null; then
yum install -y dnf >/dev/null
$SUDO_CMD yum install -y dnf >/dev/null
fi
if ! command -v curl >/dev/null; then
dnf install -y curl >/dev/null
$SUDO_CMD dnf install -y curl >/dev/null
fi
dnf install -y wget git git-lfs jq openssl unzip >/dev/null
$SUDO_CMD dnf install -y wget git git-lfs jq openssl unzip >/dev/null
fi
;;
sles | opensuse-leap | opensuse-tumbleweed)
zypper refresh >/dev/null
zypper install -y curl wget git git-lfs jq openssl >/dev/null
$SUDO_CMD zypper refresh >/dev/null
$SUDO_CMD zypper install -y curl wget git git-lfs jq openssl >/dev/null
;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now."
@@ -500,41 +520,41 @@ if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
case "$OS_TYPE" in
"almalinux" | "rocky" | "centos" | "rhel" | "ol")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
$SUDO_CMD dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
$SUDO_CMD dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
;;
"opencloudos")
# Special handling for OpenCloud OS
echo " - Installing Docker for OpenCloud OS..."
dnf install -y docker >/dev/null 2>&1
$SUDO_CMD dnf install -y docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
# Remove --live-restore parameter from Docker configuration if it exists
if [ -f "/etc/sysconfig/docker" ]; then
echo " - Removing --live-restore parameter from Docker configuration..."
sed -i 's/--live-restore[^[:space:]]*//' /etc/sysconfig/docker >/dev/null 2>&1
sed -i 's/--live-restore//' /etc/sysconfig/docker >/dev/null 2>&1
$SUDO_CMD sed -i 's/--live-restore[^[:space:]]*//' /etc/sysconfig/docker >/dev/null 2>&1
$SUDO_CMD sed -i 's/--live-restore//' /etc/sysconfig/docker >/dev/null 2>&1
# Clean up any double spaces that might be left
sed -i 's/ */ /g' /etc/sysconfig/docker >/dev/null 2>&1
$SUDO_CMD sed -i 's/ */ /g' /etc/sysconfig/docker >/dev/null 2>&1
fi
systemctl enable docker >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
echo " - Docker configured for OpenCloud OS"
;;
"alpine")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
service docker start >/dev/null 2>&1
$SUDO_CMD apk add docker docker-cli-compose >/dev/null 2>&1
$SUDO_CMD rc-update add docker default >/dev/null 2>&1
$SUDO_CMD service docker start >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with apk. Try to install it manually."
echo " Please visit https://wiki.alpinelinux.org/wiki/Docker for more information."
@@ -542,8 +562,8 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"arch")
pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
$SUDO_CMD pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
$SUDO_CMD systemctl enable docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
@@ -551,13 +571,13 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"amzn")
dnf install docker -y >/dev/null 2>&1
$SUDO_CMD dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=/usr/local/lib/docker
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
$SUDO_CMD curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
$SUDO_CMD chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with dnf. Try to install it manually."
echo " Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information."
@@ -567,18 +587,18 @@ if ! [ -x "$(command -v docker)" ]; then
"fedora")
if [ -x "$(command -v dnf5)" ]; then
# dnf5 is available
dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo --overwrite >/dev/null 2>&1
$SUDO_CMD dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo --overwrite >/dev/null 2>&1
else
# dnf5 is not available, use dnf
dnf config-manager --add-repo=https://download.docker.com/linux/fedora/docker-ce.repo >/dev/null 2>&1
$SUDO_CMD dnf config-manager --add-repo=https://download.docker.com/linux/fedora/docker-ce.repo >/dev/null 2>&1
fi
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
$SUDO_CMD dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
;;
*)
if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "24.10" ]; then
@@ -586,9 +606,9 @@ if ! [ -x "$(command -v docker)" ]; then
echo "Please install Docker manually."
exit 1
fi
if ! [ -x "$(command -v docker)" ]; then
curl -s https://get.docker.com | sh -s -- --version $DOCKER_VERSION 2>&1
curl -s https://get.docker.com | $SUDO_CMD sh -s -- --version $DOCKER_VERSION 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker installation failed."
echo " Maybe your OS is not supported?"
@@ -597,13 +617,13 @@ if ! [ -x "$(command -v docker)" ]; then
fi
fi
if [ "$OS_TYPE" = "rocky" ]; then
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
fi
if [ "$OS_TYPE" = "centos" ]; then
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
fi
@@ -647,7 +667,7 @@ export const installRClone = () => `
if command_exists rclone; then
echo "RClone already installed ✅"
else
curl https://rclone.org/install.sh | sudo bash
curl https://rclone.org/install.sh | $SUDO_CMD bash
RCLONE_VERSION=$(rclone --version | head -n 1 | awk '{print $2}' | sed 's/^v//')
echo "RClone version $RCLONE_VERSION installed ✅"
fi
@@ -656,19 +676,19 @@ export const installRClone = () => `
export const createTraefikInstance = () => {
const command = `
# Check if dokpyloy-traefik exists
if docker service inspect dokploy-traefik > /dev/null 2>&1; then
if $SUDO_CMD docker service inspect dokploy-traefik > /dev/null 2>&1; then
echo "Migrating Traefik to Standalone..."
docker service rm dokploy-traefik
$SUDO_CMD docker service rm dokploy-traefik
sleep 8
echo "Traefik migrated to Standalone ✅"
fi
if docker inspect dokploy-traefik > /dev/null 2>&1; then
if $SUDO_CMD docker inspect dokploy-traefik > /dev/null 2>&1; then
echo "Traefik already exists ✅"
else
# Create the dokploy-traefik container
TRAEFIK_VERSION=${TRAEFIK_VERSION}
docker run -d \
$SUDO_CMD docker run -d \
--name dokploy-traefik \
--restart always \
-v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \
@@ -679,7 +699,7 @@ export const createTraefikInstance = () => {
-p ${TRAEFIK_HTTP3_PORT}:${TRAEFIK_HTTP3_PORT}/udp \
traefik:v$TRAEFIK_VERSION
docker network connect dokploy-network dokploy-traefik;
$SUDO_CMD docker network connect dokploy-network dokploy-traefik;
echo "Traefik version $TRAEFIK_VERSION installed ✅"
fi
`;
@@ -692,7 +712,7 @@ const installNixpacks = () => `
echo "Nixpacks already installed ✅"
else
export NIXPACKS_VERSION=1.41.0
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
$SUDO_CMD bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
fi
`;
@@ -702,11 +722,28 @@ const installRailpack = () => `
echo "Railpack already installed ✅"
else
export RAILPACK_VERSION=0.15.4
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"
echo "Railpack version $RAILPACK_VERSION installed ✅"
fi
`;
const setupPermissions = () => `
# Add user to docker group if not root
if [ -n "$SUDO_CMD" ]; then
if ! groups $CURRENT_USER | grep -qw docker; then
$SUDO_CMD usermod -aG docker $CURRENT_USER
echo "User $CURRENT_USER added to docker group ✅"
else
echo "User $CURRENT_USER already in docker group ✅"
fi
# Ensure the user owns the dokploy directory
$SUDO_CMD chown -R $CURRENT_USER:$CURRENT_USER /etc/dokploy
echo "Permissions configured for $CURRENT_USER ✅"
else
echo "Running as root, no extra permissions needed ✅"
fi
`;
const installBuildpacks = () => `
SUFFIX=""
if [ "$SYS_ARCH" = "aarch64" ] || [ "$SYS_ARCH" = "arm64" ]; then
@@ -716,7 +753,7 @@ const installBuildpacks = () => `
echo "Buildpacks already installed ✅"
else
BUILDPACKS_VERSION=0.39.1
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | $SUDO_CMD tar -C /usr/local/bin/ --no-same-owner -xzv pack
echo "Buildpacks version $BUILDPACKS_VERSION installed ✅"
fi
`;

View File

@@ -79,6 +79,24 @@ export const validateDokployNetwork = () => `
fi
`;
export const validateSudoAccess = () => `
if [ "$(id -u)" -eq 0 ]; then
echo "root true"
elif sudo -n true 2>/dev/null; then
echo "sudo true"
else
echo "none false"
fi
`;
export const validateDockerGroup = () => `
if groups | grep -qw docker; then
echo true
else
echo false
fi
`;
export const serverValidate = async (serverId: string) => {
const client = new Client();
const server = await findServerById(serverId);
@@ -118,7 +136,11 @@ export const serverValidate = async (serverId: string) => {
isSwarmInstalled=$(${validateSwarm()})
isMainDirectoryInstalled=$(${validateMainDirectory()})
echo "{\\"docker\\": {\\"version\\": \\"$dockerVersion\\", \\"enabled\\": $dockerEnabled}, \\"rclone\\": {\\"version\\": \\"$rcloneVersion\\", \\"enabled\\": $rcloneEnabled}, \\"nixpacks\\": {\\"version\\": \\"$nixpacksVersion\\", \\"enabled\\": $nixpacksEnabled}, \\"buildpacks\\": {\\"version\\": \\"$buildpacksVersion\\", \\"enabled\\": $buildpacksEnabled}, \\"railpack\\": {\\"version\\": \\"$railpackVersion\\", \\"enabled\\": $railpackEnabled}, \\"isDokployNetworkInstalled\\": $isDokployNetworkInstalled, \\"isSwarmInstalled\\": $isSwarmInstalled, \\"isMainDirectoryInstalled\\": $isMainDirectoryInstalled}"
sudoAccessResult=$(${validateSudoAccess()})
privilegeMode=$(echo $sudoAccessResult | awk '{print $1}')
isDockerGroupMember=$(${validateDockerGroup()})
echo "{\\"docker\\": {\\"version\\": \\"$dockerVersion\\", \\"enabled\\": $dockerEnabled}, \\"rclone\\": {\\"version\\": \\"$rcloneVersion\\", \\"enabled\\": $rcloneEnabled}, \\"nixpacks\\": {\\"version\\": \\"$nixpacksVersion\\", \\"enabled\\": $nixpacksEnabled}, \\"buildpacks\\": {\\"version\\": \\"$buildpacksVersion\\", \\"enabled\\": $buildpacksEnabled}, \\"railpack\\": {\\"version\\": \\"$railpackVersion\\", \\"enabled\\": $railpackEnabled}, \\"isDokployNetworkInstalled\\": $isDokployNetworkInstalled, \\"isSwarmInstalled\\": $isSwarmInstalled, \\"isMainDirectoryInstalled\\": $isMainDirectoryInstalled, \\"privilegeMode\\": \\"$privilegeMode\\", \\"dockerGroupMember\\": $isDockerGroupMember}"
`;
client.exec(bashCommand, (err, stream) => {
if (err) {

View File

@@ -3,13 +3,13 @@ import { docker } from "../constants";
export const initializeSwarm = async () => {
const swarmInitialized = await dockerSwarmInitialized();
if (swarmInitialized) {
console.log("Swarm is already initilized");
console.log("Swarm is already initialized");
} else {
await docker.swarmInit({
AdvertiseAddr: "127.0.0.1",
ListenAddr: "0.0.0.0",
});
console.log("Swarm was initilized");
console.log("Swarm was initialized");
}
};
@@ -26,14 +26,14 @@ export const dockerSwarmInitialized = async () => {
export const initializeNetwork = async () => {
const networkInitialized = await dockerNetworkInitialized();
if (networkInitialized) {
console.log("Network is already initilized");
console.log("Network is already initialized");
} else {
docker.createNetwork({
Attachable: true,
Name: "dokploy-network",
Driver: "overlay",
});
console.log("Network was initilized");
console.log("Network was initialized");
}
};

View File

@@ -8,7 +8,12 @@ import { findEnvironmentById } from "@dokploy/server/services/environment";
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,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runComposeBackup = async (
compose: Compose,
@@ -19,7 +24,7 @@ export const runComposeBackup = async (
const project = await findProjectById(environment.projectId);
const { prefix, databaseType, serviceName } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({

View File

@@ -75,6 +75,7 @@ export const initCronJobs = async () => {
mariadb: true,
mysql: true,
mongo: true,
libsql: true,
user: true,
compose: true,
},
@@ -116,7 +117,8 @@ const getServiceAppName = (backup: BackupSchedule): string => {
backup.postgres?.appName ||
backup.mysql?.appName ||
backup.mariadb?.appName ||
backup.mongo?.appName;
backup.mongo?.appName ||
backup.libsql?.appName;
return serviceAppName || backup.appName;
};
@@ -133,8 +135,8 @@ export const keepLatestNBackups = async (
const appName = getServiceAppName(backup);
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
// --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}`;
// --include "*.bson.gz" or "*.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,bson.gz}"}" ${backupFilesPath}`;
// 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{}`;
// this command deletes the files

View File

@@ -0,0 +1,80 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Libsql } from "@dokploy/server/services/libsql";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runLibsqlBackup = async (
libsql: Libsql,
backup: BackupSchedule,
) => {
const { name, environmentId, appName } = libsql;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Initializing Backup",
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (libsql.serverId) {
await execAsyncRemote(libsql.serverId, backupCommand);
} else {
await execAsync(backupCommand, {
shell: "/bin/bash",
});
}
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "libsql",
type: "success",
organizationId: project.organizationId,
databaseName: backup.database,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "libsql",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
databaseName: backup.database,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};

View File

@@ -8,7 +8,12 @@ import type { Mariadb } from "@dokploy/server/services/mariadb";
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,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMariadbBackup = async (
mariadb: Mariadb,
@@ -19,7 +24,7 @@ export const runMariadbBackup = async (
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,

View File

@@ -8,7 +8,12 @@ import type { Mongo } from "@dokploy/server/services/mongo";
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,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const { environmentId, name, appName } = mongo;
@@ -16,7 +21,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,

View File

@@ -8,7 +8,12 @@ import type { MySql } from "@dokploy/server/services/mysql";
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,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { environmentId, name, appName } = mysql;
@@ -16,7 +21,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,

View File

@@ -8,7 +8,12 @@ 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,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runPostgresBackup = async (
postgres: Postgres,
@@ -25,7 +30,7 @@ export const runPostgresBackup = async (
});
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);

View File

@@ -4,6 +4,7 @@ import type { Destination } from "@dokploy/server/services/destination";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { keepLatestNBackups } from ".";
import { runComposeBackup } from "./compose";
import { runLibsqlBackup } from "./libsql";
import { runMariadbBackup } from "./mariadb";
import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql";
@@ -19,6 +20,7 @@ export const scheduleBackup = (backup: BackupSchedule) => {
mysql,
mongo,
mariadb,
libsql,
compose,
} = backup;
scheduleJob(backupId, schedule, async () => {
@@ -35,6 +37,9 @@ export const scheduleBackup = (backup: BackupSchedule) => {
} else if (databaseType === "mariadb" && mariadb) {
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb.serverId);
} else if (databaseType === "libsql" && libsql) {
await runLibsqlBackup(libsql, backup);
await keepLatestNBackups(backup, libsql.serverId);
} else if (databaseType === "web-server") {
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
@@ -51,6 +56,9 @@ export const removeScheduleBackup = (backupId: string) => {
currentJob?.cancel();
};
export const getBackupTimestamp = () =>
new Date().toISOString().replace(/[:.]/g, "-");
export const normalizeS3Path = (prefix: string) => {
// Trim whitespace and remove leading/trailing slashes
const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, "");
@@ -74,6 +82,10 @@ export const getS3Credentials = (destination: Destination) => {
rcloneFlags.unshift(`--s3-provider="${provider}"`);
}
if (destination.additionalFlags?.length) {
rcloneFlags.push(...destination.additionalFlags);
}
return rcloneFlags;
};
@@ -107,6 +119,10 @@ export const getMongoBackupCommand = (
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
};
export const getLibsqlBackupCommand = (database: string) => {
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`;
};
export const getServiceContainerCommand = (appName: string) => {
return `docker ps -q --filter "status=running" --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`;
};
@@ -123,12 +139,24 @@ export const getComposeContainerCommand = (
};
const getContainerSearchCommand = (backup: BackupSchedule) => {
const { backupType, postgres, mysql, mariadb, mongo, compose, serviceName } =
backup;
const {
backupType,
postgres,
mysql,
mariadb,
mongo,
libsql,
compose,
serviceName,
} = backup;
if (backupType === "database") {
const appName =
postgres?.appName || mysql?.appName || mariadb?.appName || mongo?.appName;
postgres?.appName ||
mysql?.appName ||
mariadb?.appName ||
mongo?.appName ||
libsql?.appName;
return getServiceContainerCommand(appName || "");
}
if (backupType === "compose") {
@@ -209,6 +237,12 @@ export const generateBackupCommand = (backup: BackupSchedule) => {
}
break;
}
case "libsql": {
if (backupType === "database") {
return getLibsqlBackupCommand(backup.database);
}
break;
}
default:
throw new Error(`Database type not supported: ${databaseType}`);
}

View File

@@ -1,5 +1,5 @@
import { createWriteStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
@@ -9,8 +9,18 @@ import {
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
import { execAsync } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path } from "./utils";
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
function formatBytes(bytes?: number) {
if (bytes === undefined) return "Unknown size";
if (bytes === 0) return "0 B";
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(2)} ${sizes[i]} (${bytes} bytes)`;
}
export const runWebServerBackup = async (backup: BackupSchedule) => {
if (IS_CLOUD) {
@@ -23,11 +33,11 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
description: "Web Server Backup",
});
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
let computedBackupSize: number | undefined;
try {
const destination = await findDestinationById(backup.destinationId);
const rcloneFlags = getS3Credentials(destination);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const timestamp = getBackupTimestamp();
const { BASE_PATH } = paths();
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
const backupFileName = `webserver-backup-${timestamp}.zip`;
@@ -79,11 +89,24 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
writeStream.write("Zipped database and filesystem\n");
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`;
const zipPath = join(tempDir, backupFileName);
try {
const { size } = await stat(zipPath);
computedBackupSize = size;
writeStream.write(`Backup size: ${size} bytes\n`);
} catch {
// If stat fails, keep undefined
}
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${zipPath}" "${s3Path}"`;
writeStream.write("Running command to upload backup to S3\n");
await execAsync(uploadCommand);
writeStream.write("Uploaded backup to S3 ✅\n");
writeStream.end();
await sendDokployBackupNotifications({
type: "success",
backupSize: formatBytes(computedBackupSize),
});
await updateDeploymentStatus(deployment.deploymentId, "done");
return true;
} finally {
@@ -100,6 +123,12 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
error instanceof Error ? error.message : "Unknown error\n",
);
writeStream.end();
await sendDokployBackupNotifications({
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
backupSize: formatBytes(computedBackupSize),
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}

View File

@@ -6,7 +6,7 @@ import { quote } from "shell-quote";
import { writeDomainsToCompose } from "../docker/domain";
import {
encodeBase64,
getEnviromentVariablesObject,
getEnvironmentVariablesObject,
prepareEnvironmentVariables,
} from "../docker/utils";
@@ -46,17 +46,17 @@ Compose Type: ${composeType} ✅`;
set -e
{
echo "${logBox}";
${newCompose}
${envCommand}
cd "${projectPath}";
${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; }
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
echo "Docker Compose Deployed: ✅";
} || {
echo "Error: ❌ Script execution failed";
@@ -131,7 +131,7 @@ echo "${encodedContent}" | base64 -d > "${envFilePath}";
const getExportEnvCommand = (compose: ComposeNested) => {
if (compose.composeType !== "stack") return "";
const envVars = getEnviromentVariablesObject(
const envVars = getEnvironmentVariablesObject(
compose.env,
compose.environment.project.env,
compose.environment.env,

View File

@@ -1,5 +1,5 @@
import {
getEnviromentVariablesObject,
getEnvironmentVariablesObject,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/utils/docker/utils";
import { quote } from "shell-quote";
@@ -52,7 +52,7 @@ export const getDockerCommand = (application: ApplicationNested) => {
commandArgs.push("--build-arg", arg);
}
const secrets = getEnviromentVariablesObject(
const secrets = getEnvironmentVariablesObject(
buildSecrets,
application.environment.project.env,
application.environment.env,
@@ -86,12 +86,12 @@ export const getDockerCommand = (application: ApplicationNested) => {
command += `
echo "Building ${appName}" ;
cd ${dockerContextPath} || {
cd ${dockerContextPath} || {
echo "❌ The path ${dockerContextPath} does not exist" ;
exit 1;
}
${joinedSecrets} docker ${commandArgs.join(" ")} || {
${joinedSecrets} docker ${commandArgs.join(" ")} || {
echo "❌ Docker build failed" ;
exit 1;
}

View File

@@ -0,0 +1,155 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions, PortConfig } from "dockerode";
import {
calculateResources,
generateBindMounts,
generateConfigContainer,
generateFileMounts,
generateVolumeMounts,
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
export type LibsqlNested = InferResultType<
"libsql",
{
mounts: true;
environment: { with: { project: true } };
}
>;
export const buildLibsql = async (libsql: LibsqlNested) => {
const {
appName,
env,
externalPort,
externalGRPCPort,
externalAdminPort,
memoryLimit,
memoryReservation,
databaseUser,
databasePassword,
sqldNode,
sqldPrimaryUrl,
cpuLimit,
cpuReservation,
command,
mounts,
enableNamespaces,
} = libsql;
const basicAuth = Buffer.from(
`${databaseUser}:${databasePassword}`,
"utf-8",
).toString("base64");
const defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${
env ? `\n${env}` : ""
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
const {
HealthCheck,
RestartPolicy,
Placement,
Labels,
Mode,
RollbackConfig,
UpdateConfig,
Networks,
} = generateConfigContainer(libsql);
const resources = calculateResources({
memoryLimit,
memoryReservation,
cpuLimit,
cpuReservation,
});
const envVariables = prepareEnvironmentVariables(
defaultLibsqlEnv,
libsql.environment.project.env,
libsql.environment.env,
);
const volumesMount = generateVolumeMounts(mounts);
const bindsMount = generateBindMounts(mounts);
const filesMount = generateFileMounts(appName, libsql);
const docker = await getRemoteDocker(libsql.serverId);
let finalCommand =
command ??
"sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000";
if (enableNamespaces) {
finalCommand += " --enable-namespaces";
}
const settings: CreateServiceOptions = {
Name: appName,
TaskTemplate: {
ContainerSpec: {
HealthCheck,
Image: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(finalCommand
? {
Command: ["/bin/sh"],
Args: ["-c", finalCommand],
}
: {}),
Labels,
},
Networks,
RestartPolicy,
Placement,
Resources: {
...resources,
},
},
Mode,
RollbackConfig,
EndpointSpec: {
Mode: "dnsrr",
Ports: [
...(externalPort
? [
{
Protocol: "tcp",
TargetPort: 8080,
PublishedPort: externalPort,
PublishMode: "host",
} as PortConfig,
]
: []),
...(externalGRPCPort
? [
{
Protocol: "tcp",
TargetPort: 5001,
PublishedPort: externalGRPCPort,
PublishMode: "host",
} as PortConfig,
]
: []),
...(externalAdminPort
? [
{
Protocol: "tcp",
TargetPort: 5000,
PublishedPort: externalAdminPort,
PublishMode: "host",
} as PortConfig,
]
: []),
],
},
UpdateConfig,
};
try {
const service = docker.getService(appName);
const inspect = await service.inspect();
await service.update({
version: Number.parseInt(inspect.Version.Index),
...settings,
});
} catch {
await docker.createService(settings);
}
};

View File

@@ -74,8 +74,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
StopGracePeriod: StopGracePeriod ?? 30_000_000_000,
...(command && {
Command: command.split(" "),
}),

View File

@@ -1,11 +1,13 @@
import { db } from "@dokploy/server/db";
import {
libsql,
mariadb,
mongo,
mysql,
postgres,
redis,
} from "@dokploy/server/db/schema";
import { deployLibsql } from "@dokploy/server/services/libsql";
import { deployMariadb } from "@dokploy/server/services/mariadb";
import { deployMongo } from "@dokploy/server/services/mongo";
import { deployMySql } from "@dokploy/server/services/mysql";
@@ -15,7 +17,13 @@ import { eq } from "drizzle-orm";
import { removeService } from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
type DatabaseType = "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
type DatabaseType =
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
export const rebuildDatabase = async (
databaseId: string,
@@ -41,31 +49,25 @@ export const rebuildDatabase = async (
}
}
if (type === "postgres") {
await deployPostgres(databaseId);
} else if (type === "mysql") {
await deployMySql(databaseId);
if (type === "libsql") {
await deployLibsql(databaseId);
} else if (type === "mariadb") {
await deployMariadb(databaseId);
} else if (type === "mongo") {
await deployMongo(databaseId);
} else if (type === "mysql") {
await deployMySql(databaseId);
} else if (type === "postgres") {
await deployPostgres(databaseId);
} else if (type === "redis") {
await deployRedis(databaseId);
}
};
const findDatabaseById = async (databaseId: string, type: DatabaseType) => {
if (type === "postgres") {
return await db.query.postgres.findFirst({
where: eq(postgres.postgresId, databaseId),
with: {
mounts: true,
},
});
}
if (type === "mysql") {
return await db.query.mysql.findFirst({
where: eq(mysql.mysqlId, databaseId),
if (type === "libsql") {
return await db.query.libsql.findFirst({
where: eq(libsql.libsqlId, databaseId),
with: {
mounts: true,
},
@@ -87,6 +89,22 @@ const findDatabaseById = async (databaseId: string, type: DatabaseType) => {
},
});
}
if (type === "mysql") {
return await db.query.mysql.findFirst({
where: eq(mysql.mysqlId, databaseId),
with: {
mounts: true,
},
});
}
if (type === "postgres") {
return await db.query.postgres.findFirst({
where: eq(postgres.postgresId, databaseId),
with: {
mounts: true,
},
});
}
if (type === "redis") {
return await db.query.redis.findFirst({
where: eq(redis.redisId, databaseId),

View File

@@ -172,8 +172,12 @@ export const addDomainToCompose = async (
);
}
const httpLabels = createDomainLabels(appName, domain, "web");
if (https) {
const httpLabels = createDomainLabels(
appName,
domain,
domain.customEntrypoint || "web",
);
if (!domain.customEntrypoint && https) {
const httpsLabels = createDomainLabels(appName, domain, "websecure");
httpLabels.push(...httpsLabels);
}
@@ -251,11 +255,12 @@ export const writeComposeFile = async (
export const createDomainLabels = (
appName: string,
domain: Domain,
entrypoint: "web" | "websecure",
entrypoint: string,
) => {
const {
host,
port,
customEntrypoint,
https,
uniqueConfigKey,
certificateType,
@@ -274,34 +279,45 @@ export const createDomainLabels = (
// Collect middlewares for this router
const middlewares: string[] = [];
const isRedirectRouter = entrypoint === "web" && https && !customEntrypoint;
// Add HTTPS redirect for web entrypoint (must be first)
if (entrypoint === "web" && https) {
// Web router with HTTPS only needs redirect — all other middlewares
// run on the websecure router where the request actually lands.
if (isRedirectRouter) {
middlewares.push("redirect-to-https@file");
}
// Add stripPath middleware if needed
if (stripPath && path && path !== "/") {
const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`;
// Only define middleware once (on web entrypoint)
if (entrypoint === "web") {
// Define middleware on web (or custom) entrypoint so Traefik registers it
if (entrypoint === "web" || customEntrypoint) {
labels.push(
`traefik.http.middlewares.${middlewareName}.stripprefix.prefixes=${path}`,
);
}
middlewares.push(middlewareName);
if (!isRedirectRouter) {
middlewares.push(middlewareName);
}
}
// Add internalPath middleware if needed
if (internalPath && internalPath !== "/" && internalPath.startsWith("/")) {
const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`;
// Only define middleware once (on web entrypoint)
if (entrypoint === "web") {
// Define middleware on web (or custom) entrypoint so Traefik registers it
if (entrypoint === "web" || customEntrypoint) {
labels.push(
`traefik.http.middlewares.${middlewareName}.addprefix.prefix=${internalPath}`,
);
}
middlewares.push(middlewareName);
if (!isRedirectRouter) {
middlewares.push(middlewareName);
}
}
// Add custom middlewares (skip for redirect-only router)
if (!isRedirectRouter && domain.middlewares?.length) {
middlewares.push(...domain.middlewares);
}
// Apply middlewares to router if any exist
@@ -312,7 +328,7 @@ export const createDomainLabels = (
}
// Add TLS configuration for websecure
if (entrypoint === "websecure") {
if (entrypoint === "websecure" || (customEntrypoint && https)) {
if (certificateType === "letsencrypt") {
labels.push(
`traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`,

View File

@@ -7,6 +7,7 @@ import type { ContainerInfo, ResourceRequirements } from "dockerode";
import { parse } from "dotenv";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
import type { LibsqlNested } from "../databases/libsql";
import type { MariadbNested } from "../databases/mariadb";
import type { MongoNested } from "../databases/mongo";
import type { MysqlNested } from "../databases/mysql";
@@ -258,6 +259,48 @@ export const cleanupSystem = async (serverId?: string) => {
}
};
export interface DockerDiskUsageItem {
type: string;
totalCount: number;
active: number;
size: string;
reclaimable: string;
sizeBytes: number;
}
const parseSizeToBytes = (size: string): number => {
const match = size.match(/^([\d.]+)\s*([KMGT]?B)$/i);
if (!match) return 0;
const value = Number.parseFloat(match[1] as string);
const unit = (match[2] as string).toUpperCase();
const multipliers: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
};
return value * (multipliers[unit] || 0);
};
export const getDockerDiskUsage = async (): Promise<DockerDiskUsageItem[]> => {
const command = "docker system df --format '{{json .}}'";
const { stdout } = await execAsync(command);
const lines = stdout.trim().split("\n").filter(Boolean);
return lines.map((line) => {
const data = JSON.parse(line);
return {
type: data.Type,
totalCount: Number.parseInt(data.TotalCount, 10) || 0,
active: Number.parseInt(data.Active, 10) || 0,
size: data.Size,
reclaimable: data.Reclaimable,
sizeBytes: parseSizeToBytes(data.Size),
};
});
};
/**
* Volume cleanup should always be performed manually by the user. The reason is that during automatic cleanup, a volume may be deleted due to a stopped container, which is a dangerous situation.
*
@@ -433,7 +476,7 @@ export const parseEnvironmentKeyValuePair = (
return [key, valueParts.join("=")];
};
export const getEnviromentVariablesObject = (
export const getEnvironmentVariablesObject = (
input: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
@@ -511,11 +554,6 @@ export const generateConfigContainer = (
ulimitsSwarm,
} = application;
const sanitizedStopGracePeriodSwarm =
typeof stopGracePeriodSwarm === "bigint"
? Number(stopGracePeriodSwarm)
: stopGracePeriodSwarm;
const haveMounts = mounts && mounts.length > 0;
return {
@@ -550,9 +588,15 @@ export const generateConfigContainer = (
},
},
}),
...(rollbackConfigSwarm && {
RollbackConfig: rollbackConfigSwarm,
}),
...(rollbackConfigSwarm
? { RollbackConfig: rollbackConfigSwarm }
: {
// default rollback config to match update config
RollbackConfig: {
Parallelism: 1,
Order: "start-first",
},
}),
...(updateConfigSwarm
? { UpdateConfig: updateConfigSwarm }
: {
@@ -560,11 +604,12 @@ export const generateConfigContainer = (
UpdateConfig: {
Parallelism: 1,
Order: "start-first",
FailureAction: "rollback",
},
}),
...(sanitizedStopGracePeriodSwarm !== null &&
sanitizedStopGracePeriodSwarm !== undefined && {
StopGracePeriod: sanitizedStopGracePeriodSwarm,
...(stopGracePeriodSwarm !== null &&
stopGracePeriodSwarm !== undefined && {
StopGracePeriod: stopGracePeriodSwarm,
}),
...(networkSwarm
? {
@@ -610,6 +655,7 @@ export const generateFileMounts = (
appName: string,
service:
| ApplicationNested
| LibsqlNested
| MongoNested
| MariadbNested
| MysqlNested

View File

@@ -10,6 +10,7 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
@@ -50,6 +51,7 @@ export const sendBuildErrorNotifications = async ({
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
@@ -66,6 +68,7 @@ export const sendBuildErrorNotifications = async ({
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
@@ -250,6 +253,26 @@ export const sendBuildErrorNotifications = async ({
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `:warning: **Build Failed**
**Project:** ${projectName}
**Application:** ${applicationName}
**Type:** ${applicationType}
**Time:** ${date.toLocaleString()}
**Error:**
\`\`\`
${errorMessage}
\`\`\`
[View Build Details](${buildLink})`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy Bot",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: "Build Error",

View File

@@ -11,6 +11,7 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
@@ -53,6 +54,7 @@ export const sendBuildSuccessNotifications = async ({
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
@@ -69,6 +71,7 @@ export const sendBuildSuccessNotifications = async ({
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
@@ -266,6 +269,14 @@ export const sendBuildSuccessNotifications = async ({
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `**✅ Build Success**\n\n**Project:** ${projectName}\n**Application:** ${applicationName}\n**Type:** ${applicationType}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}\n\n[View Build Details](${buildLink})`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: "Build Success",

View File

@@ -10,6 +10,7 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
@@ -29,7 +30,7 @@ export const sendDatabaseBackupNotifications = async ({
}: {
projectName: string;
applicationName: string;
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb";
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb" | "libsql";
type: "error" | "success";
organizationId: string;
errorMessage?: string;
@@ -50,6 +51,7 @@ export const sendDatabaseBackupNotifications = async ({
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
@@ -66,6 +68,7 @@ export const sendDatabaseBackupNotifications = async ({
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
@@ -272,6 +275,21 @@ export const sendDatabaseBackupNotifications = async ({
});
}
if (mattermost) {
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg =
type === "error" && errorMessage
? `\n\n**Error:**\n\`\`\`\n${errorMessage}\n\`\`\``
: "";
await sendMattermostNotification(mattermost, {
text: `**${statusEmoji} Database Backup ${typeStatus}**\n\n**Project:** ${projectName}\n**Application:** ${applicationName}\n**Type:** ${databaseType}\n**Database Name:** ${databaseName}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}${errorMsg}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Database Backup ${type === "success" ? "Successful" : "Failed"}`,

View File

@@ -10,6 +10,7 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
@@ -37,6 +38,7 @@ export const sendDockerCleanupNotifications = async (
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
@@ -53,6 +55,7 @@ export const sendDockerCleanupNotifications = async (
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
@@ -168,6 +171,14 @@ export const sendDockerCleanupNotifications = async (
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `**✅ Docker Cleanup**\n\n**Message:** ${message}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: "Docker Cleanup",

View File

@@ -0,0 +1,416 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DokployBackupEmail from "@dokploy/server/emails/emails/dokploy-backup";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import {
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
export const sendDokployBackupNotifications = async ({
type,
errorMessage,
backupSize,
}: {
type: "error" | "success";
errorMessage?: string;
backupSize?: string;
}) => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: eq(notifications.dokployBackup, true),
with: {
email: true,
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const {
email,
discord,
telegram,
slack,
resend,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
const template = await renderAsync(
DokployBackupEmail({
type,
errorMessage,
date: date.toLocaleString(),
backupSize,
}),
).catch();
if (email) {
await sendEmailNotification(
email,
"Dokploy instance backup",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Dokploy instance backup",
template,
);
}
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title:
type === "success"
? decorate(">", "`✅` Dokploy Backup Successful")
: decorate(">", "`❌` Dokploy Backup Failed"),
color: type === "success" ? 0x57f287 : 0xed4245,
fields: [
{
name: decorate("`📦`", "Backup Type"),
value: "Complete Dokploy Instance",
inline: true,
},
...(backupSize
? [
{
name: decorate("`💾`", "Backup Size"),
value: backupSize,
inline: true,
},
]
: []),
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Status"),
value: type
.replace("error", "Failed")
.replace("success", "Successful"),
inline: true,
},
...(type === "error" && errorMessage
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
},
]
: []),
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Instance Backup Notification",
},
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate(
type === "success" ? "✅" : "❌",
`Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
),
`${decorate("📦", "Backup Type: Complete Dokploy Instance")}` +
`${backupSize ? decorate("💾", `Backup Size: ${backupSize}`) : ""}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
`${type === "success" ? "white_check_mark" : "x"}`,
"",
"📦Backup Type: Complete Dokploy Instance\n" +
`${backupSize ? `💾Backup Size: ${backupSize}\n` : ""}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
}
if (telegram) {
const isError = type === "error" && errorMessage;
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
: "";
const sizeInfo = backupSize
? `\n<b>Backup Size:</b> ${backupSize}`
: "";
const messageText = `<b>${statusEmoji} Dokploy Backup ${typeStatus}</b>\n\n<b>Backup Type:</b> Complete Dokploy Instance${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: type === "success" ? "#00FF00" : "#FF0000",
pretext:
type === "success"
? ":white_check_mark: *Dokploy Backup Successful*"
: ":x: *Dokploy Backup Failed*",
fields: [
...(type === "error" && errorMessage
? [
{
title: "Error Message",
value: errorMessage,
short: false,
},
]
: []),
{
title: "Backup Type",
value: "Complete Dokploy Instance",
short: true,
},
...(backupSize
? [
{
title: "Backup Size",
value: backupSize,
short: true,
},
]
: []),
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
{
title: "Status",
value: type === "success" ? "Successful" : "Failed",
short: true,
},
],
},
],
});
}
if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage =
errorMessage && errorMessage.length > limitCharacter
? errorMessage.substring(0, limitCharacter)
: errorMessage;
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content:
type === "success"
? "✅ Dokploy Backup Successful"
: "❌ Dokploy Backup Failed",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: type === "success" ? "green" : "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content:
"**Backup Type:**\nComplete Dokploy Instance",
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Status:**\n${type === "success" ? "Successful" : "Failed"}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
...(backupSize
? [
{
tag: "markdown",
content: `**Backup Size:**\n${backupSize}`,
text_align: "left",
text_size: "normal_v2",
},
]
: []),
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
...(type === "error" && truncatedErrorMessage
? [
{
tag: "markdown",
content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``,
text_align: "left",
text_size: "normal_v2",
},
]
: []),
],
},
},
});
}
if (mattermost) {
const statusEmoji = type === "success" ? ":white_check_mark:" : ":x:";
const typeStatus = type === "success" ? "Successful" : "Failed";
await sendMattermostNotification(mattermost, {
text: `${statusEmoji} **Dokploy Backup ${typeStatus}**
**Backup Type:** Complete Dokploy Instance${backupSize ? `\n**Backup Size:** ${backupSize}` : ""}
**Date:** ${date.toLocaleString()}
**Status:** ${typeStatus}${type === "error" && errorMessage ? `\n\n**Error:**\n\`\`\`\n${errorMessage}\n\`\`\`` : ""}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy Bot",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
message: `Dokploy instance backup ${type === "success" ? "completed successfully" : "failed"}`,
backupType: "Complete Dokploy Instance",
...(backupSize ? { backupSize } : {}),
...(type === "error" && errorMessage ? { errorMessage } : {}),
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: type,
type: "dokploy-backup",
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
`Backup Type: Complete Dokploy Instance${backupSize ? `\nBackup Size: ${backupSize}` : ""}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `${type === "success" ? "✅" : "❌"} Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
facts: [
{ name: "Backup Type", value: "Complete Dokploy Instance" },
...(backupSize ? [{ name: "Backup Size", value: backupSize }] : []),
{ name: "Date", value: format(date, "PP pp") },
{
name: "Status",
value: type === "success" ? "Successful" : "Failed",
},
...(type === "error" && errorMessage
? [{ name: "Error Message", value: errorMessage }]
: []),
],
});
}
} catch (error) {
console.error(error);
}
}
};

View File

@@ -10,6 +10,7 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
@@ -32,6 +33,7 @@ export const sendDokployRestartNotifications = async () => {
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
@@ -48,6 +50,7 @@ export const sendDokployRestartNotifications = async () => {
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
@@ -158,6 +161,14 @@ export const sendDokployRestartNotifications = async () => {
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `**✅ Dokploy Server Restarted**\n\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (custom) {
try {
await sendCustomNotification(custom, {

View File

@@ -5,6 +5,7 @@ import {
sendCustomNotification,
sendDiscordNotification,
sendLarkNotification,
sendMattermostNotification,
sendPushoverNotification,
sendSlackNotification,
sendTeamsNotification,
@@ -38,6 +39,7 @@ export const sendServerThresholdNotifications = async (
discord: true,
telegram: true,
slack: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
@@ -49,63 +51,72 @@ export const sendServerThresholdNotifications = async (
const typeColor = 0xff0000; // Rojo para indicar alerta
for (const notification of notificationList) {
const { discord, telegram, slack, custom, lark, pushover, teams } =
notification;
const {
discord,
telegram,
slack,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
try {
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: decorate(">", `\`⚠️\` Server ${payload.Type} Alert`),
color: typeColor,
fields: [
{
name: decorate("`🏷️`", "Server Name"),
value: payload.ServerName,
inline: true,
await sendDiscordNotification(discord, {
title: decorate(">", `\`⚠️\` Server ${payload.Type} Alert`),
color: typeColor,
fields: [
{
name: decorate("`🏷️`", "Server Name"),
value: payload.ServerName,
inline: true,
},
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate(typeEmoji, "Type"),
value: payload.Type,
inline: true,
},
{
name: decorate("📊", "Current Value"),
value: `${payload.Value.toFixed(2)}%`,
inline: true,
},
{
name: decorate("⚠️", "Threshold"),
value: `${payload.Threshold.toFixed(2)}%`,
inline: true,
},
{
name: decorate("`📜`", "Message"),
value: `\`\`\`${payload.Message}\`\`\``,
},
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Server Monitoring Alert",
},
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate(typeEmoji, "Type"),
value: payload.Type,
inline: true,
},
{
name: decorate("📊", "Current Value"),
value: `${payload.Value.toFixed(2)}%`,
inline: true,
},
{
name: decorate("⚠️", "Threshold"),
value: `${payload.Threshold.toFixed(2)}%`,
inline: true,
},
{
name: decorate("`📜`", "Message"),
value: `\`\`\`${payload.Message}\`\`\``,
},
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Server Monitoring Alert",
},
});
}
});
}
if (telegram) {
await sendTelegramNotification(
telegram,
`
if (telegram) {
await sendTelegramNotification(
telegram,
`
<b>⚠️ Server ${payload.Type} Alert</b>
<b>Server Name:</b> ${payload.ServerName}
<b>Type:</b> ${payload.Type}
@@ -114,170 +125,181 @@ export const sendServerThresholdNotifications = async (
<b>Message:</b> ${payload.Message}
<b>Time:</b> ${date.toLocaleString()}
`,
);
}
);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: "#FF0000",
pretext: `:warning: *Server ${payload.Type} Alert*`,
fields: [
{
title: "Server Name",
value: payload.ServerName,
short: true,
},
{
title: "Type",
value: payload.Type,
short: true,
},
{
title: "Current Value",
value: `${payload.Value.toFixed(2)}%`,
short: true,
},
{
title: "Threshold",
value: `${payload.Threshold.toFixed(2)}%`,
short: true,
},
{
title: "Message",
value: payload.Message,
},
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
],
},
],
});
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: "#FF0000",
pretext: `:warning: *Server ${payload.Type} Alert*`,
fields: [
{
title: "Server Name",
value: payload.ServerName,
short: true,
},
{
title: "Type",
value: payload.Type,
short: true,
},
{
title: "Current Value",
value: `${payload.Value.toFixed(2)}%`,
short: true,
},
{
title: "Threshold",
value: `${payload.Threshold.toFixed(2)}%`,
short: true,
},
{
title: "Message",
value: payload.Message,
},
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
],
},
],
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Server ${payload.Type} Alert`,
message: payload.Message,
serverName: payload.ServerName,
type: payload.Type,
currentValue: payload.Value,
threshold: payload.Threshold,
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: "alert",
alertType: "server-threshold",
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `**⚠️ Server ${payload.Type} Alert**\n\n**Server Name:** ${payload.ServerName}\n**Type:** ${payload.Type}\n**Current Value:** ${payload.Value.toFixed(2)}%\n**Threshold:** ${payload.Threshold.toFixed(2)}%\n**Message:** ${payload.Message}\n**Time:** ${date.toLocaleString()}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
if (custom) {
await sendCustomNotification(custom, {
title: `Server ${payload.Type} Alert`,
message: payload.Message,
serverName: payload.ServerName,
type: payload.Type,
currentValue: payload.Value,
threshold: payload.Threshold,
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: "alert",
alertType: "server-threshold",
});
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
},
header: {
title: {
tag: "plain_text",
content: `⚠️ Server ${payload.Type} Alert`,
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Server Name:**\n${payload.ServerName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Current Value:**\n${payload.Value.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Message:**\n${payload.Message}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Type:**\n${payload.Type === "CPU" ? "🔲" : "💾"} ${payload.Type}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Threshold:**\n${payload.Threshold.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Time:**\n${date.toLocaleString()}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
header: {
title: {
tag: "plain_text",
content: `⚠️ Server ${payload.Type} Alert`,
},
],
subtitle: {
tag: "plain_text",
content: "",
},
template: "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Server Name:**\n${payload.ServerName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Current Value:**\n${payload.Value.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Message:**\n${payload.Message}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Type:**\n${payload.Type === "CPU" ? "🔲" : "💾"} ${payload.Type}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Threshold:**\n${payload.Threshold.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Time:**\n${date.toLocaleString()}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
},
});
}
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Server ${payload.Type} Alert`,
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
);
if (pushover) {
await sendPushoverNotification(
pushover,
`Server ${payload.Type} Alert`,
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
);
}
} catch (error) {
console.log(error);
}
if (teams) {

View File

@@ -4,6 +4,7 @@ import type {
email,
gotify,
lark,
mattermost,
ntfy,
pushover,
resend,
@@ -206,6 +207,33 @@ export const sendNtfyNotification = async (
}
};
export const sendMattermostNotification = async (
connection: typeof mattermost.$inferInsert,
message: any,
) => {
const payload = {
...message,
// Only include username if it's provided and not empty
...(message.username?.trim() && { username: message.username }),
// Only include channel if it's provided and not empty
...(message.channel?.trim() && {
channel: `#${message.channel.replace("#", "")}`,
}),
};
const response = await fetch(connection.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(
`Failed to send Mattermost notification: ${response.statusText}`,
);
}
};
export const sendCustomNotification = async (
connection: typeof custom.$inferInsert,
payload: Record<string, any>,

View File

@@ -9,6 +9,8 @@ import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
@@ -37,7 +39,8 @@ export const sendVolumeBackupNotifications = async ({
| "mongodb"
| "mariadb"
| "redis"
| "compose";
| "compose"
| "libsql";
type: "error" | "success";
organizationId: string;
errorMessage?: string;
@@ -58,9 +61,11 @@ export const sendVolumeBackupNotifications = async ({
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
custom: true,
},
});
@@ -73,278 +78,420 @@ export const sendVolumeBackupNotifications = async ({
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
custom,
} = notification;
if (email || resend) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
const htmlContent = await renderAsync(
VolumeBackupEmail({
try {
if (email || resend) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
const htmlContent = await renderAsync(
VolumeBackupEmail({
projectName,
applicationName,
volumeName,
serviceType,
type,
errorMessage,
backupSize,
date: date.toISOString(),
}),
);
if (email) {
await sendEmailNotification(email, subject, htmlContent);
}
if (resend) {
await sendResendNotification(resend, subject, htmlContent);
}
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title:
type === "success"
? decorate(">", "`✅` Volume Backup Successful")
: decorate(">", "`❌` Volume Backup Failed"),
color: type === "success" ? 0x57f287 : 0xed4245,
fields: [
{
name: decorate("`🛠️`", "Project"),
value: projectName,
inline: true,
},
{
name: decorate("`⚙️`", "Application"),
value: applicationName,
inline: true,
},
{
name: decorate("`💾`", "Volume Name"),
value: volumeName,
inline: true,
},
{
name: decorate("`🔧`", "Service Type"),
value: serviceType,
inline: true,
},
...(backupSize
? [
{
name: decorate("`📊`", "Backup Size"),
value: backupSize,
inline: true,
},
]
: []),
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Type"),
value: type
.replace("error", "Failed")
.replace("success", "Successful"),
inline: true,
},
...(type === "error" && errorMessage
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
},
]
: []),
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Volume Backup Notification",
},
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate(
type === "success" ? "✅" : "❌",
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
),
`${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("💾", `Volume Name: ${volumeName}`)}` +
`${decorate("🔧", `Service Type: ${serviceType}`)}` +
`${backupSize ? decorate("📊", `Backup Size: ${backupSize}`) : ""}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
`${type === "success" ? "white_check_mark" : "x"}`,
"",
`🛠Project: ${projectName}\n` +
`Application: ${applicationName}\n` +
`💾Volume Name: ${volumeName}\n` +
`🔧Service Type: ${serviceType}\n` +
`${backupSize ? `📊Backup Size: ${backupSize}\n` : ""}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
}
if (telegram) {
const isError = type === "error" && errorMessage;
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
: "";
const sizeInfo = backupSize
? `\n<b>Backup Size:</b> ${backupSize}`
: "";
const messageText = `<b>${statusEmoji} Volume Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Volume Name:</b> ${volumeName}\n<b>Service Type:</b> ${serviceType}${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: type === "success" ? "#00FF00" : "#FF0000",
pretext:
type === "success"
? ":white_check_mark: *Volume Backup Successful*"
: ":x: *Volume Backup Failed*",
fields: [
...(type === "error" && errorMessage
? [
{
title: "Error Message",
value: errorMessage,
short: false,
},
]
: []),
{
title: "Project",
value: projectName,
short: true,
},
{
title: "Application",
value: applicationName,
short: true,
},
{
title: "Volume Name",
value: volumeName,
short: true,
},
{
title: "Service Type",
value: serviceType,
short: true,
},
...(backupSize
? [
{
title: "Backup Size",
value: backupSize,
short: true,
},
]
: []),
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
{
title: "Type",
value: type,
short: true,
},
{
title: "Status",
value: type === "success" ? "Successful" : "Failed",
short: true,
},
],
},
],
});
}
if (mattermost) {
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg =
type === "error" && errorMessage
? `\n\n**Error:**\n\`\`\`\n${errorMessage}\n\`\`\``
: "";
const sizeInfo = backupSize ? `\n**Backup Size:** ${backupSize}` : "";
await sendMattermostNotification(mattermost, {
text: `**${statusEmoji} Volume Backup ${typeStatus}**\n\n**Project:** ${projectName}\n**Application:** ${applicationName}\n**Volume Name:** ${volumeName}\n**Service Type:** ${serviceType}${sizeInfo}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}${errorMsg}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage =
errorMessage && errorMessage.length > limitCharacter
? errorMessage.substring(0, limitCharacter)
: errorMessage;
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content:
type === "success"
? "✅ Volume Backup Successful"
: "❌ Volume Backup Failed",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: type === "success" ? "green" : "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Project:**\n${projectName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Volume Name:**\n${volumeName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Status:**\n${type === "success" ? "Successful" : "Failed"}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Application:**\n${applicationName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Service Type:**\n${serviceType}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
...(type === "error" && truncatedErrorMessage
? [
{
tag: "markdown",
content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``,
text_align: "left",
text_size: "normal_v2",
},
]
: []),
],
},
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
`Project: ${projectName}\nApplication: ${applicationName}\nVolume: ${volumeName}\nService Type: ${serviceType}${backupSize ? `\nBackup Size: ${backupSize}` : ""}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Volume Name", value: volumeName },
{ name: "Service Type", value: serviceType },
{ name: "Date", value: format(date, "PP pp") },
{
name: "Status",
value: type === "success" ? "Successful" : "Failed",
},
];
if (backupSize) {
facts.push({ name: "Backup Size", value: backupSize });
}
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Volume Backup Successful"
: "❌ Volume Backup Failed",
facts,
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
message:
type === "success"
? "Volume backup completed successfully"
: "Volume backup failed",
projectName,
applicationName,
volumeName,
serviceType,
type,
errorMessage,
backupSize,
date: date.toISOString(),
}),
);
if (email) {
await sendEmailNotification(email, subject, htmlContent);
errorMessage: errorMessage ?? "",
backupSize: backupSize ?? "",
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: type,
});
}
if (resend) {
await sendResendNotification(resend, subject, htmlContent);
}
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title:
type === "success"
? decorate(">", "`✅` Volume Backup Successful")
: decorate(">", "`❌` Volume Backup Failed"),
color: type === "success" ? 0x57f287 : 0xed4245,
fields: [
{
name: decorate("`🛠️`", "Project"),
value: projectName,
inline: true,
},
{
name: decorate("`⚙️`", "Application"),
value: applicationName,
inline: true,
},
{
name: decorate("`💾`", "Volume Name"),
value: volumeName,
inline: true,
},
{
name: decorate("`🔧`", "Service Type"),
value: serviceType,
inline: true,
},
...(backupSize
? [
{
name: decorate("`📊`", "Backup Size"),
value: backupSize,
inline: true,
},
]
: []),
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Type"),
value: type
.replace("error", "Failed")
.replace("success", "Successful"),
inline: true,
},
...(type === "error" && errorMessage
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage.substring(0, 1010)}\`\`\``,
},
]
: []),
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Volume Backup Notification",
},
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate(
type === "success" ? "✅" : "❌",
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
),
`${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("💾", `Volume Name: ${volumeName}`)}` +
`${decorate("🔧", `Service Type: ${serviceType}`)}` +
`${backupSize ? decorate("📊", `Backup Size: ${backupSize}`) : ""}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
`${type === "success" ? "white_check_mark" : "x"}`,
"",
`🛠Project: ${projectName}\n` +
`Application: ${applicationName}\n` +
`💾Volume Name: ${volumeName}\n` +
`🔧Service Type: ${serviceType}\n` +
`${backupSize ? `📊Backup Size: ${backupSize}\n` : ""}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
}
if (telegram) {
const isError = type === "error" && errorMessage;
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
: "";
const sizeInfo = backupSize ? `\n<b>Backup Size:</b> ${backupSize}` : "";
const messageText = `<b>${statusEmoji} Volume Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Volume Name:</b> ${volumeName}\n<b>Service Type:</b> ${serviceType}${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: type === "success" ? "#00FF00" : "#FF0000",
pretext:
type === "success"
? ":white_check_mark: *Volume Backup Successful*"
: ":x: *Volume Backup Failed*",
fields: [
...(type === "error" && errorMessage
? [
{
title: "Error Message",
value: errorMessage,
short: false,
},
]
: []),
{
title: "Project",
value: projectName,
short: true,
},
{
title: "Application",
value: applicationName,
short: true,
},
{
title: "Volume Name",
value: volumeName,
short: true,
},
{
title: "Service Type",
value: serviceType,
short: true,
},
...(backupSize
? [
{
title: "Backup Size",
value: backupSize,
short: true,
},
]
: []),
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
{
title: "Type",
value: type,
short: true,
},
{
title: "Status",
value: type === "success" ? "Successful" : "Failed",
short: true,
},
],
},
],
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
`Project: ${projectName}\nApplication: ${applicationName}\nVolume: ${volumeName}\nService Type: ${serviceType}${backupSize ? `\nBackup Size: ${backupSize}` : ""}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Volume Name", value: volumeName },
{ name: "Service Type", value: serviceType },
{ name: "Date", value: format(date, "PP pp") },
{ name: "Status", value: type === "success" ? "Successful" : "Failed" },
];
if (backupSize) {
facts.push({ name: "Backup Size", value: backupSize });
}
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Volume Backup Successful"
: "❌ Volume Backup Failed",
facts,
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
message:
type === "success"
? "Volume backup completed successfully"
: "Volume backup failed",
projectName,
applicationName,
volumeName,
serviceType,
type,
errorMessage: errorMessage ?? "",
backupSize: backupSize ?? "",
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: type,
});
} catch (error) {
console.log(error);
}
}
};

View File

@@ -170,7 +170,7 @@ export const cloneGiteaRepository = async ({
const repoClone = `${giteaOwner}/${giteaRepository}.git`;
const cloneUrl = buildGiteaCloneUrl(
giteaProvider.giteaUrl,
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl,
giteaProvider.accessToken!,
giteaOwner!,
giteaRepository!,

View File

@@ -5,8 +5,8 @@ import { findGithubById, type Github } from "@dokploy/server/services/github";
import type { InferResultType } from "@dokploy/server/types/with";
import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server";
import type { z } from "zod";
import { Octokit } from "octokit";
import type { z } from "zod";
export const authGithub = (githubProvider: Github): Octokit => {
if (!haveGithubRequirements(githubProvider)) {

View File

@@ -1,7 +1,6 @@
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { apiGitlabTestConnection } from "@dokploy/server/db/schema";
import type { z } from "zod";
import {
findGitlabById,
type Gitlab,
@@ -9,6 +8,7 @@ import {
} from "@dokploy/server/services/gitlab";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import type { z } from "zod";
export const refreshGitlabToken = async (gitlabProviderId: string) => {
const gitlabProvider = await findGitlabById(gitlabProviderId);
@@ -90,12 +90,14 @@ const getGitlabRepoClone = (
gitlab: GitlabInfo,
gitlabPathNamespace: string | null,
) => {
const repoClone = `${gitlab?.gitlabUrl.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
const url = gitlab?.gitlabInternalUrl || gitlab?.gitlabUrl;
const repoClone = `${url?.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
return repoClone;
};
const getGitlabCloneUrl = (gitlab: GitlabInfo, repoClone: string) => {
const isSecure = gitlab?.gitlabUrl.startsWith("https://");
const url = gitlab?.gitlabInternalUrl || gitlab?.gitlabUrl;
const isSecure = url?.startsWith("https://");
const cloneUrl = `http${isSecure ? "s" : ""}://oauth2:${gitlab?.accessToken}@${repoClone}`;
return cloneUrl;
};

View File

@@ -32,7 +32,7 @@ export const restoreComposeBackup = async (
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
}
let credentials: DatabaseCredentials;
let credentials: DatabaseCredentials = {};
switch (backupInput.databaseType) {
case "postgres":
@@ -62,7 +62,11 @@ export const restoreComposeBackup = async (
const restoreCommand = getRestoreCommand({
appName: appName,
serviceName: backupInput.metadata?.serviceName,
type: backupInput.databaseType,
type: backupInput.databaseType as
| "postgres"
| "mariadb"
| "mysql"
| "mongo",
credentials: {
database: backupInput.databaseName,
...credentials,

View File

@@ -1,4 +1,5 @@
export { restoreComposeBackup } from "./compose";
export { restoreLibsqlBackup } from "./libsql";
export { restoreMariadbBackup } from "./mariadb";
export { restoreMongoBackup } from "./mongo";
export { restoreMySqlBackup } from "./mysql";

View File

@@ -0,0 +1,49 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Libsql } from "@dokploy/server/services/libsql";
import type { z } from "zod";
import { getS3Credentials, getServiceContainerCommand } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
export const restoreLibsqlBackup = async (
libsql: Libsql,
destination: Destination,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
const { appName, serverId } = libsql;
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
const containerSearch = getServiceContainerCommand(appName);
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
const command = `CONTAINER_ID=$(${containerSearch}) && ${rcloneCommand} | ${restoreCommand}`;
emit(`Executing command: ${command}`);
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
emit("Restore completed successfully!");
} catch (error) {
emit(
`Error: ${
error instanceof Error ? error.message : "Error restoring libsql backup"
}`,
);
throw error;
}
};

View File

@@ -5,7 +5,7 @@ import { paths } from "@dokploy/server/constants";
import type { Domain } from "@dokploy/server/services/domain";
import { parse, stringify } from "yaml";
import { encodeBase64 } from "../docker/utils";
import { execAsyncRemote } from "../process/execAsync";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type { FileConfig, HttpLoadBalancerService } from "./file-types";
export const createTraefikConfig = (appName: string) => {
@@ -57,18 +57,16 @@ export const removeTraefikConfig = async (
try {
const { DYNAMIC_TRAEFIK_PATH } = paths(!!serverId);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
const command = `rm -f ${configPath}`;
if (serverId) {
await execAsyncRemote(serverId, `rm ${configPath}`);
await execAsyncRemote(serverId, command);
} else {
if (fs.existsSync(configPath)) {
await fs.promises.unlink(configPath);
}
await execAsync(command);
}
if (fs.existsSync(configPath)) {
await fs.promises.unlink(configPath);
}
} catch {}
} catch (error) {
console.error(`Error removing traefik config for ${appName}:`, error);
}
};
export const removeTraefikConfigRemote = async (
@@ -78,8 +76,13 @@ export const removeTraefikConfigRemote = async (
try {
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
await execAsyncRemote(serverId, `rm ${configPath}`);
} catch {}
await execAsyncRemote(serverId, `rm -f ${configPath}`);
} catch (error) {
console.error(
`Error removing remote traefik config for ${appName}:`,
error,
);
}
};
export const loadOrCreateConfig = (appName: string): FileConfig => {

View File

@@ -32,10 +32,10 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
config.http.routers[routerName] = await createRouterConfig(
app,
domain,
"web",
domain.customEntrypoint || "web",
);
if (domain.https) {
if (!domain.customEntrypoint && domain.https) {
config.http.routers[routerNameSecure] = await createRouterConfig(
app,
domain,
@@ -121,13 +121,20 @@ const toPunycode = (host: string): string => {
export const createRouterConfig = async (
app: ApplicationNested,
domain: Domain,
entryPoint: "web" | "websecure",
entryPoint: string,
) => {
const { appName, redirects, security } = app;
const { certificateType } = domain;
const { host, path, https, uniqueConfigKey, internalPath, stripPath } =
domain;
const {
host,
path,
https,
uniqueConfigKey,
internalPath,
stripPath,
customEntrypoint,
} = domain;
const punycodeHost = toPunycode(host);
const routerConfig: HttpRouter = {
rule: `Host(\`${punycodeHost}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
@@ -136,22 +143,24 @@ export const createRouterConfig = async (
entryPoints: [entryPoint],
};
// Add path rewriting middleware if needed
if (internalPath && internalPath !== "/" && internalPath !== path) {
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(pathMiddleware);
}
const isRedirectRouter = entryPoint === "web" && https && !customEntrypoint;
if (stripPath && path && path !== "/") {
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(stripMiddleware);
}
// Web router with HTTPS only needs redirect — all other middlewares
// run on the websecure router where the request actually lands.
if (isRedirectRouter) {
routerConfig.middlewares?.push("redirect-to-https");
} else {
// Add path rewriting middleware if needed
if (internalPath && internalPath !== "/" && internalPath !== path) {
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(pathMiddleware);
}
if (entryPoint === "web" && https) {
routerConfig.middlewares = ["redirect-to-https"];
}
if (stripPath && path && path !== "/") {
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(stripMiddleware);
}
if ((entryPoint === "websecure" && https) || !https) {
// redirects - skip for preview deployments as wildcard subdomains
// should not inherit parent redirect rules (e.g., www-redirect)
if (domain.domainType !== "preview") {
@@ -172,9 +181,14 @@ export const createRouterConfig = async (
}
routerConfig.middlewares?.push(middlewareName);
}
// custom middlewares from domain
if (domain.middlewares && domain.middlewares.length > 0) {
routerConfig.middlewares?.push(...domain.middlewares);
}
}
if (entryPoint === "websecure") {
if (entryPoint === "websecure" || (customEntrypoint && https)) {
if (certificateType === "letsencrypt") {
routerConfig.tls = { certResolver: "letsencrypt" };
} else if (certificateType === "custom" && domain.customCertResolver) {

View File

@@ -2,7 +2,11 @@ import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { findComposeById } from "@dokploy/server/services/compose";
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
import {
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "../backups/utils";
export const getVolumeServiceAppName = (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
@@ -18,7 +22,8 @@ export const getVolumeServiceAppName = (
volumeBackup.mysql?.appName ||
volumeBackup.mariadb?.appName ||
volumeBackup.mongo?.appName ||
volumeBackup.redis?.appName;
volumeBackup.redis?.appName ||
volumeBackup.libsql?.appName;
return serviceAppName || volumeBackup.appName;
};
@@ -31,7 +36,7 @@ export const backupVolume = async (
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
const destination = volumeBackup.destination;
const s3AppName = getVolumeServiceAppName(volumeBackup);
const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
const backupFileName = `${volumeName}-${getBackupTimestamp()}.tar`;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
const rcloneFlags = getS3Credentials(volumeBackup.destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

View File

@@ -26,6 +26,7 @@ const getProjectName = (
volumeBackup.mariadb,
volumeBackup.mongo,
volumeBackup.redis,
volumeBackup.libsql,
];
for (const service of services) {
@@ -48,6 +49,7 @@ const getOrganizationId = (
volumeBackup.mariadb,
volumeBackup.mongo,
volumeBackup.redis,
volumeBackup.libsql,
];
for (const service of services) {