Merge branch 'canary' into ulimits-at-0a401843

This commit is contained in:
Mauricio Siu
2026-02-08 23:30:14 -06:00
234 changed files with 69952 additions and 5923 deletions

View File

@@ -2,8 +2,14 @@ import path from "node:path";
import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const CLEANUP_CRON_JOB = "50 23 * * *";
export const docker = new Docker();
// When not set, use the legacy default so 2FA remains working for users who
// enabled it before BETTER_AUTH_SECRET was introduced .
export const BETTER_AUTH_SECRET =
process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789";
export const paths = (isServer = false) => {
const BASE_PATH =
isServer || process.env.NODE_ENV === "production"
@@ -25,5 +31,6 @@ export const paths = (isServer = false) => {
REGISTRY_PATH: `${BASE_PATH}/registry`,
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
};
};

View File

@@ -0,0 +1,47 @@
import fs from "node:fs";
export const {
DATABASE_URL,
POSTGRES_PASSWORD_FILE,
POSTGRES_USER = "dokploy",
POSTGRES_DB = "dokploy",
POSTGRES_HOST = "dokploy-postgres",
POSTGRES_PORT = "5432",
} = process.env;
function readSecret(path: string): string {
try {
return fs.readFileSync(path, "utf8").trim();
} catch {
throw new Error(`Cannot read secret at ${path}`);
}
}
export let dbUrl: string;
if (DATABASE_URL) {
// Compatibilidad legacy / overrides
dbUrl = DATABASE_URL;
} else if (POSTGRES_PASSWORD_FILE) {
const password = readSecret(POSTGRES_PASSWORD_FILE);
dbUrl = `postgres://${POSTGRES_USER}:${encodeURIComponent(
password,
)}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
} else {
if (process.env.NODE_ENV !== "test") {
console.warn(`
⚠️ [DEPRECATED DATABASE CONFIG]
You are using the legacy hardcoded database credentials.
This mode WILL BE REMOVED in a future release.
Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
`);
}
if (process.env.NODE_ENV === "production") {
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
} else {
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
}
}

View File

@@ -1,5 +1,6 @@
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { dbUrl } from "./constants";
import * as schema from "./schema";
declare global {
@@ -8,14 +9,16 @@ declare global {
export let db: PostgresJsDatabase<typeof schema>;
if (process.env.NODE_ENV === "production") {
db = drizzle(postgres(process.env.DATABASE_URL!), {
db = drizzle(postgres(dbUrl), {
schema,
});
} else {
if (!global.db)
global.db = drizzle(postgres(process.env.DATABASE_URL!), {
global.db = drizzle(postgres(dbUrl), {
schema,
});
db = global.db;
}
export { dbUrl };

View File

@@ -9,6 +9,7 @@ import {
import { nanoid } from "nanoid";
import { projects } from "./project";
import { server } from "./server";
import { ssoProvider } from "./sso";
import { user } from "./user";
export const account = pgTable("account", {
@@ -78,6 +79,7 @@ export const organizationRelations = relations(
servers: many(server),
projects: many(projects),
members: many(member),
ssoProviders: many(ssoProvider),
}),
);
@@ -153,6 +155,7 @@ export const invitation = pgTable("invitation", {
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
teamId: text("team_id"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const invitationRelations = relations(invitation, ({ one }) => ({

View File

@@ -49,7 +49,7 @@ import {
UpdateConfigSwarmSchema,
} from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
export const sourceType = pgEnum("sourceType", [
"docker",
"git",
@@ -138,6 +138,7 @@ export const applications = pgTable("application", {
giteaBuildPath: text("giteaBuildPath").default("/"),
// Bitbucket
bitbucketRepository: text("bitbucketRepository"),
bitbucketRepositorySlug: text("bitbucketRepositorySlug"),
bitbucketOwner: text("bitbucketOwner"),
bitbucketBranch: text("bitbucketBranch"),
bitbucketBuildPath: text("bitbucketBuildPath").default("/"),
@@ -180,7 +181,7 @@ export const applications = pgTable("application", {
.notNull()
.default("idle"),
buildType: buildType("buildType").notNull().default("nixpacks"),
railpackVersion: text("railpackVersion").default("0.2.2"),
railpackVersion: text("railpackVersion").default("0.15.4"),
herokuVersion: text("herokuVersion").default("24"),
publishDirectory: text("publishDirectory"),
isStaticSpa: boolean("isStaticSpa"),
@@ -289,7 +290,12 @@ export const applicationsRelations = relations(
);
const createSchema = createInsertSchema(applications, {
appName: z.string(),
appName: z
.string()
.min(1)
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
createdAt: z.string(),
applicationId: z.string(),
autoDeploy: z.boolean(),
@@ -455,6 +461,7 @@ export const apiSaveBitbucketProvider = createSchema
bitbucketBuildPath: true,
bitbucketOwner: true,
bitbucketRepository: true,
bitbucketRepositorySlug: true,
bitbucketId: true,
applicationId: true,
watchPaths: true,

View File

@@ -16,7 +16,7 @@ import { schedules } from "./schedule";
import { server } from "./server";
import { applicationStatus, triggerType } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
"git",
"github",
@@ -56,6 +56,7 @@ export const compose = pgTable("compose", {
gitlabPathNamespace: text("gitlabPathNamespace"),
// Bitbucket
bitbucketRepository: text("bitbucketRepository"),
bitbucketRepositorySlug: text("bitbucketRepositorySlug"),
bitbucketOwner: text("bitbucketOwner"),
bitbucketBranch: text("bitbucketBranch"),
// Gitea
@@ -146,6 +147,12 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
const createSchema = createInsertSchema(compose, {
name: z.string().min(1),
appName: z
.string()
.min(1)
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
description: z.string(),
env: z.string().optional(),
composeFile: z.string().optional(),

View File

@@ -11,6 +11,7 @@ export const gitea = pgTable("gitea", {
.primaryKey()
.$defaultFn(() => nanoid()),
giteaUrl: text("giteaUrl").default("https://gitea.com").notNull(),
giteaInternalUrl: text("giteaInternalUrl"),
redirectUri: text("redirect_uri"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
@@ -40,6 +41,7 @@ export const apiCreateGitea = createSchema.extend({
redirectUri: z.string().optional(),
name: z.string().min(1),
giteaUrl: z.string().min(1),
giteaInternalUrl: z.string().optional().nullable(),
giteaUsername: z.string().optional(),
accessToken: z.string().optional(),
refreshToken: z.string().optional(),
@@ -76,6 +78,7 @@ export const apiUpdateGitea = createSchema.extend({
name: z.string().min(1),
giteaId: z.string().min(1),
giteaUrl: z.string().min(1),
giteaInternalUrl: z.string().optional().nullable(),
giteaUsername: z.string().optional(),
accessToken: z.string().optional(),
refreshToken: z.string().optional(),

View File

@@ -11,6 +11,7 @@ export const gitlab = pgTable("gitlab", {
.primaryKey()
.$defaultFn(() => nanoid()),
gitlabUrl: text("gitlabUrl").default("https://gitlab.com").notNull(),
gitlabInternalUrl: text("gitlabInternalUrl"),
applicationId: text("application_id"),
redirectUri: text("redirect_uri"),
secret: text("secret"),
@@ -41,6 +42,7 @@ export const apiCreateGitlab = createSchema.extend({
authId: z.string().min(1),
name: z.string().min(1),
gitlabUrl: z.string().min(1),
gitlabInternalUrl: z.string().optional().nullable(),
});
export const apiFindOneGitlab = createSchema
@@ -70,4 +72,5 @@ export const apiUpdateGitlab = createSchema.extend({
name: z.string().min(1),
gitlabId: z.string().min(1),
gitlabUrl: z.string().min(1),
gitlabInternalUrl: z.string().optional().nullable(),
});

View File

@@ -32,6 +32,7 @@ export * from "./server";
export * from "./session";
export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./user";
export * from "./utils";
export * from "./volume-backups";

View File

@@ -28,7 +28,7 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { generateAppName } from "./utils";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
export const mariadb = pgTable("mariadb", {
mariadbId: text("mariadbId")
@@ -99,7 +99,12 @@ export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
const createSchema = createInsertSchema(mariadb, {
mariadbId: z.string(),
name: z.string().min(1),
appName: z.string().min(1),
appName: z
.string()
.min(1)
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
createdAt: z.string(),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
@@ -142,20 +147,18 @@ const createSchema = createInsertSchema(mariadb, {
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateMariaDB = createSchema
.pick({
name: true,
appName: true,
dockerImage: true,
databaseRootPassword: true,
environmentId: true,
description: true,
databaseName: true,
databaseUser: true,
databasePassword: true,
serverId: true,
})
.required();
export const apiCreateMariaDB = createSchema.pick({
name: true,
appName: true,
dockerImage: true,
databaseRootPassword: true,
environmentId: true,
description: true,
databaseName: true,
databaseUser: true,
databasePassword: true,
serverId: true,
});
export const apiFindOneMariaDB = createSchema
.pick({

View File

@@ -35,7 +35,7 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { generateAppName } from "./utils";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
export const mongo = pgTable("mongo", {
mongoId: text("mongoId")
@@ -101,7 +101,12 @@ export const mongoRelations = relations(mongo, ({ one, many }) => ({
}));
const createSchema = createInsertSchema(mongo, {
appName: z.string().min(1),
appName: z
.string()
.min(1)
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
createdAt: z.string(),
mongoId: z.string(),
name: z.string().min(1),
@@ -139,19 +144,17 @@ const createSchema = createInsertSchema(mongo, {
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateMongo = createSchema
.pick({
name: true,
appName: true,
dockerImage: true,
environmentId: true,
description: true,
databaseUser: true,
databasePassword: true,
serverId: true,
replicaSets: true,
})
.required();
export const apiCreateMongo = createSchema.pick({
name: true,
appName: true,
dockerImage: true,
environmentId: true,
description: true,
databaseUser: true,
databasePassword: true,
serverId: true,
replicaSets: true,
});
export const apiFindOneMongo = createSchema
.pick({

View File

@@ -28,7 +28,7 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { generateAppName } from "./utils";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
export const mysql = pgTable("mysql", {
mysqlId: text("mysqlId")
@@ -96,7 +96,12 @@ export const mysqlRelations = relations(mysql, ({ one, many }) => ({
const createSchema = createInsertSchema(mysql, {
mysqlId: z.string(),
appName: z.string().min(1),
appName: z
.string()
.min(1)
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
createdAt: z.string(),
name: z.string().min(1),
databaseName: z.string().min(1),
@@ -139,20 +144,18 @@ const createSchema = createInsertSchema(mysql, {
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateMySql = createSchema
.pick({
name: true,
appName: true,
dockerImage: true,
environmentId: true,
description: true,
databaseName: true,
databaseUser: true,
databasePassword: true,
databaseRootPassword: true,
serverId: true,
})
.required();
export const apiCreateMySql = createSchema.pick({
name: true,
appName: true,
dockerImage: true,
environmentId: true,
description: true,
databaseName: true,
databaseUser: true,
databasePassword: true,
databaseRootPassword: true,
serverId: true,
});
export const apiFindOneMySql = createSchema
.pick({

View File

@@ -17,8 +17,10 @@ export const notificationType = pgEnum("notificationType", [
"telegram",
"discord",
"email",
"resend",
"gotify",
"ntfy",
"pushover",
"custom",
"lark",
]);
@@ -52,6 +54,9 @@ export const notifications = pgTable("notification", {
emailId: text("emailId").references(() => email.emailId, {
onDelete: "cascade",
}),
resendId: text("resendId").references(() => resend.resendId, {
onDelete: "cascade",
}),
gotifyId: text("gotifyId").references(() => gotify.gotifyId, {
onDelete: "cascade",
}),
@@ -64,6 +69,9 @@ export const notifications = pgTable("notification", {
larkId: text("larkId").references(() => lark.larkId, {
onDelete: "cascade",
}),
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
onDelete: "cascade",
}),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -110,6 +118,16 @@ export const email = pgTable("email", {
toAddresses: text("toAddress").array().notNull(),
});
export const resend = pgTable("resend", {
resendId: text("resendId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
apiKey: text("apiKey").notNull(),
fromAddress: text("fromAddress").notNull(),
toAddresses: text("toAddress").array().notNull(),
});
export const gotify = pgTable("gotify", {
gotifyId: text("gotifyId")
.notNull()
@@ -149,6 +167,18 @@ export const lark = pgTable("lark", {
webhookUrl: text("webhookUrl").notNull(),
});
export const pushover = pgTable("pushover", {
pushoverId: text("pushoverId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
userKey: text("userKey").notNull(),
apiToken: text("apiToken").notNull(),
priority: integer("priority").notNull().default(0),
retry: integer("retry"),
expire: integer("expire"),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, {
fields: [notifications.slackId],
@@ -166,6 +196,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.emailId],
references: [email.emailId],
}),
resend: one(resend, {
fields: [notifications.resendId],
references: [resend.resendId],
}),
gotify: one(gotify, {
fields: [notifications.gotifyId],
references: [gotify.gotifyId],
@@ -182,6 +216,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.larkId],
references: [lark.larkId],
}),
pushover: one(pushover, {
fields: [notifications.pushoverId],
references: [pushover.pushoverId],
}),
organization: one(organization, {
fields: [notifications.organizationId],
references: [organization.id],
@@ -315,6 +353,36 @@ export const apiTestEmailConnection = apiCreateEmail.pick({
fromAddress: true,
});
export const apiCreateResend = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
apiKey: z.string().min(1),
fromAddress: z.string().min(1),
toAddresses: z.array(z.string()).min(1),
})
.required();
export const apiUpdateResend = apiCreateResend.partial().extend({
notificationId: z.string().min(1),
resendId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestResendConnection = apiCreateResend.pick({
apiKey: true,
fromAddress: true,
toAddresses: true,
});
export const apiCreateGotify = notificationsSchema
.pick({
appBuildError: true,
@@ -439,6 +507,69 @@ export const apiTestLarkConnection = apiCreateLark.pick({
webhookUrl: true,
});
export const apiCreatePushover = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
userKey: z.string().min(1),
apiToken: z.string().min(1),
priority: z.number().min(-2).max(2).default(0),
retry: z.number().min(30).nullish(),
expire: z.number().min(1).max(10800).nullish(),
})
.refine(
(data) =>
data.priority !== 2 || (data.retry != null && data.expire != null),
{
message: "Retry and expire are required for emergency priority (2)",
path: ["retry"],
},
);
export const apiUpdatePushover = z.object({
notificationId: z.string().min(1),
pushoverId: z.string().min(1),
organizationId: z.string().optional(),
userKey: z.string().min(1).optional(),
apiToken: z.string().min(1).optional(),
priority: z.number().min(-2).max(2).optional(),
retry: z.number().min(30).nullish(),
expire: z.number().min(1).max(10800).nullish(),
appBuildError: z.boolean().optional(),
databaseBackup: z.boolean().optional(),
volumeBackup: z.boolean().optional(),
dokployRestart: z.boolean().optional(),
name: z.string().optional(),
appDeploy: z.boolean().optional(),
dockerCleanup: z.boolean().optional(),
serverThreshold: z.boolean().optional(),
});
export const apiTestPushoverConnection = z
.object({
userKey: z.string().min(1),
apiToken: z.string().min(1),
priority: z.number().min(-2).max(2),
retry: z.number().min(30).nullish(),
expire: z.number().min(1).max(10800).nullish(),
})
.refine(
(data) =>
data.priority !== 2 || (data.retry != null && data.expire != null),
{
message: "Retry and expire are required for emergency priority (2)",
path: ["retry"],
},
);
export const apiSendTest = notificationsSchema
.extend({
botToken: z.string(),
@@ -451,6 +582,7 @@ export const apiSendTest = notificationsSchema
username: z.string(),
password: z.string(),
toAddresses: z.array(z.string()),
apiKey: z.string(),
serverUrl: z.string(),
topic: z.string(),
appToken: z.string(),

View File

@@ -28,7 +28,7 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { generateAppName } from "./utils";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
export const postgres = pgTable("postgres", {
postgresId: text("postgresId")
@@ -97,6 +97,12 @@ export const postgresRelations = relations(postgres, ({ one, many }) => ({
const createSchema = createInsertSchema(postgres, {
postgresId: z.string(),
name: z.string().min(1),
appName: z
.string()
.min(1)
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
@@ -105,7 +111,7 @@ const createSchema = createInsertSchema(postgres, {
}),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
dockerImage: z.string().default("postgres:15"),
dockerImage: z.string().default("postgres:18"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
@@ -132,19 +138,17 @@ const createSchema = createInsertSchema(postgres, {
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreatePostgres = createSchema
.pick({
name: true,
appName: true,
databaseName: true,
databaseUser: true,
databasePassword: true,
dockerImage: true,
environmentId: true,
description: true,
serverId: true,
})
.required();
export const apiCreatePostgres = createSchema.pick({
name: true,
appName: true,
databaseName: true,
databaseUser: true,
databasePassword: true,
dockerImage: true,
environmentId: true,
description: true,
serverId: true,
});
export const apiFindOnePostgres = createSchema
.pick({

View File

@@ -27,7 +27,7 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { generateAppName } from "./utils";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
export const redis = pgTable("redis", {
redisId: text("redisId")
@@ -91,7 +91,12 @@ export const redisRelations = relations(redis, ({ one, many }) => ({
const createSchema = createInsertSchema(redis, {
redisId: z.string(),
appName: z.string().min(1),
appName: z
.string()
.min(1)
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
createdAt: z.string(),
name: z.string().min(1),
databasePassword: z.string(),
@@ -121,17 +126,15 @@ const createSchema = createInsertSchema(redis, {
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
});
export const apiCreateRedis = createSchema
.pick({
name: true,
appName: true,
databasePassword: true,
dockerImage: true,
environmentId: true,
description: true,
serverId: true,
})
.required();
export const apiCreateRedis = createSchema.pick({
name: true,
appName: true,
databasePassword: true,
dockerImage: true,
environmentId: true,
description: true,
serverId: true,
});
export const apiFindOneRedis = createSchema
.pick({

View File

@@ -471,6 +471,7 @@ table git_provider {
table gitea {
giteaId text [pk, not null]
giteaUrl text [not null, default: 'https://gitea.com']
giteaInternalUrl text
redirect_uri text
client_id text
client_secret text
@@ -497,6 +498,7 @@ table github {
table gitlab {
gitlabId text [pk, not null]
gitlabUrl text [not null, default: 'https://gitlab.com']
gitlabInternalUrl text
application_id text
redirect_uri text
secret text

View File

@@ -175,7 +175,7 @@ export const NetworkSwarmSchema = z.array(
.object({
Target: z.string().optional(),
Aliases: z.array(z.string()).optional(),
DriverOpts: z.object({}).optional(),
DriverOpts: z.record(z.string()).optional(),
})
.strict(),
);

View File

@@ -0,0 +1,132 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
export const ssoProvider = pgTable("sso_provider", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
providerId: text("provider_id").notNull().unique(),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
});
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
organization: one(organization, {
fields: [ssoProvider.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
}));
const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
export const ssoProviderBodySchema = z.object({
providerId: z.string({}),
issuer: z.string({}),
domains: z
.string()
.array()
.transform((val) =>
Array.from(
new Set(val.map((d) => d.trim().toLowerCase()).filter(Boolean)),
),
)
.refine((val) => val.every((d) => domainRegex.test(d)), {
message: "Invalid domain",
path: ["domains"],
}),
oidcConfig: z
.object({
clientId: z.string({}),
clientSecret: z.string({}),
authorizationEndpoint: z.string({}).optional(),
tokenEndpoint: z.string({}).optional(),
userInfoEndpoint: z.string({}).optional(),
tokenEndpointAuthentication: z
.enum(["client_secret_post", "client_secret_basic"])
.optional(),
jwksEndpoint: z.string({}).optional(),
discoveryEndpoint: z.string().optional(),
skipDiscovery: z.boolean().optional(),
scopes: z.array(z.string()).optional(),
pkce: z.boolean().default(true).optional(),
mapping: z
.object({
id: z.string({}),
email: z.string({}),
emailVerified: z.string({}).optional(),
name: z.string({}),
image: z.string({}).optional(),
extraFields: z.record(z.string(), z.any()).optional(),
})
.optional(),
})
.optional(),
samlConfig: z
.object({
entryPoint: z.string({}),
cert: z.string({}),
callbackUrl: z.string({}),
audience: z.string().optional(),
idpMetadata: z
.object({
metadata: z.string().optional(),
entityID: z.string().optional(),
cert: z.string().optional(),
privateKey: z.string().optional(),
privateKeyPass: z.string().optional(),
isAssertionEncrypted: z.boolean().optional(),
encPrivateKey: z.string().optional(),
encPrivateKeyPass: z.string().optional(),
singleSignOnService: z
.array(
z.object({
Binding: z.string(),
Location: z.string(),
}),
)
.optional(),
})
.optional(),
spMetadata: z.object({
metadata: z.string().optional(),
entityID: z.string().optional(),
binding: z.string().optional(),
privateKey: z.string().optional(),
privateKeyPass: z.string().optional(),
isAssertionEncrypted: z.boolean().optional(),
encPrivateKey: z.string().optional(),
encPrivateKeyPass: z.string().optional(),
}),
wantAssertionsSigned: z.boolean().optional(),
authnRequestsSigned: z.boolean().optional(),
signatureAlgorithm: z.string().optional(),
digestAlgorithm: z.string().optional(),
identifierFormat: z.string().optional(),
privateKey: z.string().optional(),
decryptionPvk: z.string().optional(),
additionalParams: z.record(z.string(), z.any()).optional(),
mapping: z
.object({
id: z.string({}),
email: z.string({}),
emailVerified: z.string({}).optional(),
name: z.string({}),
firstName: z.string({}).optional(),
lastName: z.string({}).optional(),
extraFields: z.record(z.string(), z.any()).optional(),
})
.optional(),
})
.optional(),
organizationId: z.string({}).optional(),
overrideUserInfo: z.boolean({}).default(false).optional(),
});

View File

@@ -14,6 +14,7 @@ import { account, apikey, organization } from "./account";
import { backups } from "./backups";
import { projects } from "./project";
import { schedules } from "./schedule";
import { ssoProvider } from "./sso";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@@ -53,9 +54,18 @@ export const user = pgTable("user", {
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
// Enterprise / proprietary features
enableEnterpriseFeatures: boolean("enableEnterpriseFeatures")
.notNull()
.default(false),
licenseKey: text("licenseKey"),
isValidEnterpriseLicense: boolean("isValidEnterpriseLicense")
.notNull()
.default(false),
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
trustedOrigins: text("trustedOrigins").array(),
});
export const usersRelations = relations(user, ({ one, many }) => ({
@@ -66,6 +76,7 @@ export const usersRelations = relations(user, ({ one, many }) => ({
organizations: many(organization),
projects: many(projects),
apiKeys: many(apikey),
ssoProviders: many(ssoProvider),
backups: many(backups),
schedules: many(schedules),
}));
@@ -75,6 +86,8 @@ const createSchema = createInsertSchema(user, {
isRegistered: z.boolean().optional(),
}).omit({
role: true,
trustedOrigins: true,
isValidEnterpriseLicense: true,
});
export const apiCreateUserInvitation = createSchema.pick({}).extend({
@@ -214,6 +227,6 @@ export const apiUpdateUser = createSchema.partial().extend({
.optional(),
password: z.string().optional(),
currentPassword: z.string().optional(),
name: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
});

View File

@@ -6,6 +6,12 @@ const alphabet = "abcdefghijklmnopqrstuvwxyz123456789";
const customNanoid = customAlphabet(alphabet, 6);
/** App name: letters, numbers, dots, underscores, hyphens only (no spaces). Safe for shell/Docker. */
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";
export const generateAppName = (type: string) => {
const verb = faker.hacker.verb().replace(/ /g, "-");
const adjective = faker.hacker.adjective().replace(/ /g, "-");

View File

@@ -131,7 +131,10 @@ export const apiAssignDomain = z
.object({
host: z.string(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
letsEncryptEmail: z.string().email().optional().nullable(),
letsEncryptEmail: z
.union([z.string().email(), z.literal("")])
.optional()
.nullable(),
https: z.boolean().optional(),
})
.required()

View File

@@ -1,5 +1,6 @@
export * from "./auth/random-password";
export * from "./constants/index";
export * from "./db/constants";
export * from "./db/validations/domain";
export * from "./db/validations/index";
export * from "./lib/auth";
@@ -30,6 +31,7 @@ export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
export * from "./services/proprietary/sso";
export * from "./services/redirect";
export * from "./services/redis";
export * from "./services/registry";
@@ -78,6 +80,7 @@ export * from "./utils/builders/paketo";
export * from "./utils/builders/static";
export * from "./utils/builders/utils";
export * from "./utils/cluster/upload";
export * from "./utils/crons/enterprise";
export * from "./utils/databases/rebuild";
export * from "./utils/docker/collision";
export * from "./utils/docker/compose";

View File

@@ -1,14 +1,15 @@
import type { IncomingMessage } from "node:http";
import { sso } from "@better-auth/sso";
import * as bcrypt from "bcrypt";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api";
import { admin, apiKey, organization, twoFactor } from "better-auth/plugins";
import { and, desc, eq } from "drizzle-orm";
import { IS_CLOUD } from "../constants";
import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants";
import { db } from "../db";
import * as schema from "../db/schema";
import { getUserByToken } from "../services/admin";
import { getTrustedOrigins, getUserByToken } from "../services/admin";
import {
getWebServerSettings,
updateWebServerSettings,
@@ -22,6 +23,26 @@ const { handler, api } = betterAuth({
provider: "pg",
schema: schema,
}),
disabledPaths: [
"/sso/register",
"/organization/create",
"/organization/update",
"/organization/delete",
],
secret: BETTER_AUTH_SECRET,
...(!IS_CLOUD
? {
advanced: {
useSecureCookies: false,
defaultCookieAttributes: {
sameSite: "lax",
secure: false,
httpOnly: true,
path: "/",
},
},
}
: {}),
appName: "Dokploy",
socialProviders: {
github: {
@@ -36,18 +57,27 @@ const { handler, api } = betterAuth({
logger: {
disabled: process.env.NODE_ENV === "production",
},
...(!IS_CLOUD && {
async trustedOrigins() {
const settings = await getWebServerSettings();
if (!settings) {
return [];
}
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
];
},
}),
async trustedOrigins() {
const trustedOrigins = await getTrustedOrigins();
if (IS_CLOUD) {
return trustedOrigins;
}
const settings = await getWebServerSettings();
if (!settings) {
return [];
}
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...(process.env.NODE_ENV === "development"
? [
"http://localhost:3000",
"https://absolutely-handy-falcon.ngrok-free.app",
]
: []),
...trustedOrigins,
];
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
@@ -100,6 +130,10 @@ const { handler, api } = betterAuth({
});
}
} else {
const isSSORequest = context?.path.includes("/sso");
if (isSSORequest) {
return;
}
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
@@ -112,6 +146,7 @@ const { handler, api } = betterAuth({
}
},
after: async (user, context) => {
const isSSORequest = context?.path.includes("/sso");
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
@@ -167,6 +202,29 @@ const { handler, api } = betterAuth({
isDefault: true, // Mark first organization as default
});
});
} else if (isSSORequest) {
const providerId = context?.params?.providerId;
if (!providerId) {
throw new APIError("BAD_REQUEST", {
message: "Provider ID is required",
});
}
const provider = await db.query.ssoProvider.findFirst({
where: eq(schema.ssoProvider.providerId, providerId),
});
if (!provider) {
throw new APIError("BAD_REQUEST", {
message: "Provider not found",
});
}
await db.insert(schema.member).values({
userId: user.id,
organizationId: provider?.organizationId || "",
role: "member",
createdAt: new Date(),
isDefault: true,
});
}
},
},
@@ -228,12 +286,23 @@ const { handler, api } = betterAuth({
input: true,
defaultValue: "",
},
enableEnterpriseFeatures: {
type: "boolean",
required: false,
input: false,
},
isValidEnterpriseLicense: {
type: "boolean",
required: false,
input: false,
},
},
},
plugins: [
apiKey({
enableMetadata: true,
}),
sso(),
twoFactor(),
organization({
async sendInvitationEmail(data, _request) {
@@ -267,6 +336,7 @@ const { handler, api } = betterAuth({
export const auth = {
handler,
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
};
export const validateRequest = async (request: IncomingMessage) => {
@@ -346,6 +416,8 @@ export const validateRequest = async (request: IncomingMessage) => {
twoFactorEnabled: userFromDb.twoFactorEnabled,
role: member?.role || "member",
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
enableEnterpriseFeatures: userFromDb.enableEnterpriseFeatures,
isValidEnterpriseLicense: userFromDb.isValidEnterpriseLicense,
},
};
@@ -384,10 +456,15 @@ export const validateRequest = async (request: IncomingMessage) => {
),
with: {
organization: true,
user: true,
},
});
session.user.role = member?.role || "member";
session.user.enableEnterpriseFeatures =
member?.user.enableEnterpriseFeatures || false;
session.user.isValidEnterpriseLicense =
member?.user.isValidEnterpriseLicense || false;
if (member) {
session.user.ownerId = member.organization.ownerId;
} else {

View File

@@ -116,3 +116,22 @@ export const getDokployUrl = async () => {
}
return `http://${settings?.serverIp}:${process.env.PORT}`;
};
export const getTrustedOrigins = async () => {
const members = await db.query.member.findMany({
where: eq(member.role, "owner"),
with: {
user: true,
},
});
if (members.length === 0) {
return [];
}
const trustedOrigins = members.flatMap(
(member) => member.user.trustedOrigins || [],
);
return Array.from(new Set(trustedOrigins));
};

View File

@@ -174,6 +174,10 @@ export const deployApplication = async ({
}) => {
const application = await findApplicationById(applicationId);
const serverId = application.buildServerId || application.serverId;
const applicationEntity = {
...application,
serverId: serverId,
};
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
const deployment = await createDeployment({
@@ -185,15 +189,15 @@ export const deployApplication = async ({
try {
let command = "set -e;";
if (application.sourceType === "github") {
command += await cloneGithubRepository(application);
command += await cloneGithubRepository(applicationEntity);
} else if (application.sourceType === "gitlab") {
command += await cloneGitlabRepository(application);
command += await cloneGitlabRepository(applicationEntity);
} else if (application.sourceType === "gitea") {
command += await cloneGiteaRepository(application);
command += await cloneGiteaRepository(applicationEntity);
} else if (application.sourceType === "bitbucket") {
command += await cloneBitbucketRepository(application);
command += await cloneBitbucketRepository(applicationEntity);
} else if (application.sourceType === "git") {
command += await cloneGitRepository(application);
command += await cloneGitRepository(applicationEntity);
} else if (application.sourceType === "docker") {
command += await buildRemoteDocker(application);
}
@@ -253,8 +257,11 @@ export const deployApplication = async ({
} finally {
// Only extract commit info for non-docker sources
if (application.sourceType !== "docker") {
const commitInfo = await getGitCommitInfo(application);
const commitInfo = await getGitCommitInfo({
appName: application.appName,
type: "application",
serverId: serverId,
});
if (commitInfo) {
await updateDeployment(deployment.deploymentId, {
title: commitInfo.message,
@@ -452,6 +459,137 @@ export const deployPreviewApplication = async ({
return true;
};
export const rebuildPreviewApplication = async ({
applicationId,
titleLog = "Rebuild Preview Deployment",
descriptionLog = "",
previewDeploymentId,
}: {
applicationId: string;
titleLog: string;
descriptionLog: string;
previewDeploymentId: string;
}) => {
const application = await findApplicationById(applicationId);
const previewDeployment =
await findPreviewDeploymentById(previewDeploymentId);
const deployment = await createDeploymentPreview({
title: titleLog,
description: descriptionLog,
previewDeploymentId: previewDeploymentId,
});
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
const issueParams = {
owner: application?.owner || "",
repository: application?.repository || "",
issue_number: previewDeployment.pullRequestNumber,
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
githubId: application?.githubId || "",
};
try {
const commentExists = await issueCommentExists({
...issueParams,
});
if (!commentExists) {
const result = await createPreviewDeploymentComment({
...issueParams,
previewDomain,
appName: previewDeployment.appName,
githubId: application?.githubId || "",
previewDeploymentId,
});
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Pull request comment not found",
});
}
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
}
const buildingComment = getIssueComment(
application.name,
"running",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
});
// Set application properties for preview deployment
application.appName = previewDeployment.appName;
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.rollbackActive = false;
application.buildRegistry = null;
application.rollbackRegistry = null;
application.registry = null;
const serverId = application.serverId;
let command = "set -e;";
// Only rebuild, don't clone repository
command += await getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (serverId) {
await execAsyncRemote(serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
await mechanizeDockerContainer(application);
const successComment = getIssueComment(
application.name,
"success",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${successComment}`,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "done",
});
} catch (error) {
let command = "";
// Only log details for non-ExecError errors
if (!(error instanceof ExecError)) {
const message = error instanceof Error ? error.message : String(error);
const encodedMessage = encodeBase64(message);
command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`;
}
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
const serverId = application.buildServerId || application.serverId;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
const comment = getIssueComment(application.name, "error", previewDomain);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${comment}`,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "error",
});
throw error;
}
return true;
};
export const getApplicationStats = async (appName: string) => {
if (appName === "dokploy") {
return await getAdvancedStats(appName);

View File

@@ -109,7 +109,7 @@ export const getContainersByAppNameMatch = async (
try {
let result: string[] = [];
const cmd =
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}'";
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}} | Status: {{.Status}}'";
const command =
appType === "docker-compose"
@@ -148,10 +148,14 @@ export const getContainersByAppNameMatch = async (
const state = parts[2]
? parts[2].replace("State: ", "").trim()
: "No state";
const status = parts[3] ? parts[3].replace("Status: ", "").trim() : "";
return {
containerId,
name,
state,
status,
};
});
@@ -168,7 +172,9 @@ export const getStackContainersByAppName = async (
try {
let result: string[] = [];
const command = `docker stack ps ${appName} --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}}'`;
const command = `docker stack ps ${appName} --no-trunc --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
console.log("command ", command);
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(serverId, command);
@@ -205,11 +211,17 @@ export const getStackContainersByAppName = async (
const node = parts[3]
? parts[3].replace("Node: ", "").trim()
: "No specific node";
const currentState = parts[4]
? parts[4].replace("CurrentState: ", "").trim()
: "";
const error = parts[5] ? parts[5].replace("Error: ", "").trim() : "";
return {
containerId,
name,
state,
node,
currentState,
error,
};
});
@@ -226,8 +238,7 @@ export const getServiceContainersByAppName = async (
try {
let result: string[] = [];
const command = `docker service ps ${appName} --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}}'`;
const command = `docker service ps ${appName} --no-trunc --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(serverId, command);
@@ -265,11 +276,18 @@ export const getServiceContainersByAppName = async (
const node = parts[3]
? parts[3].replace("Node: ", "").trim()
: "No specific node";
const currentState = parts[4]
? parts[4].replace("CurrentState: ", "").trim()
: "";
const error = parts[5] ? parts[5].replace("Error: ", "").trim() : "";
return {
containerId,
name,
state,
currentState,
node,
error,
};
});

View File

@@ -1,9 +1,9 @@
import dns from "node:dns";
import { promisify } from "node:util";
import { db } from "@dokploy/server/db";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { generateRandomDomain } from "@dokploy/server/templates";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { type apiCreateDomain, domains } from "../db/schema";

View File

@@ -18,7 +18,7 @@ export type Mariadb = typeof mariadb.$inferSelect;
export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
const appName = buildAppName("mariadb", input.appName);
const valid = await validUniqueServerAppName(input.appName);
const valid = await validUniqueServerAppName(appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",

View File

@@ -6,6 +6,8 @@ import {
type apiCreateGotify,
type apiCreateLark,
type apiCreateNtfy,
type apiCreatePushover,
type apiCreateResend,
type apiCreateSlack,
type apiCreateTelegram,
type apiUpdateCustom,
@@ -14,6 +16,8 @@ import {
type apiUpdateGotify,
type apiUpdateLark,
type apiUpdateNtfy,
type apiUpdatePushover,
type apiUpdateResend,
type apiUpdateSlack,
type apiUpdateTelegram,
custom,
@@ -23,6 +27,8 @@ import {
lark,
notifications,
ntfy,
pushover,
resend,
slack,
telegram,
} from "@dokploy/server/db/schema";
@@ -409,6 +415,100 @@ export const updateEmailNotification = async (
});
};
export const createResendNotification = async (
input: typeof apiCreateResend._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newResend = await tx
.insert(resend)
.values({
apiKey: input.apiKey,
fromAddress: input.fromAddress,
toAddresses: input.toAddresses,
})
.returning()
.then((value) => value[0]);
if (!newResend) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting resend",
});
}
const newDestination = await tx
.insert(notifications)
.values({
resendId: newResend.resendId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "resend",
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 updateResendNotification = async (
input: typeof apiUpdateResend._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
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(resend)
.set({
apiKey: input.apiKey,
fromAddress: input.fromAddress,
toAddresses: input.toAddresses,
})
.where(eq(resend.resendId, input.resendId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const createGotifyNotification = async (
input: typeof apiCreateGotify._type,
organizationId: string,
@@ -690,10 +790,12 @@ export const findNotificationById = async (notificationId: string) => {
telegram: true,
discord: true,
email: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
lark: true,
pushover: true,
},
});
if (!notification) {
@@ -817,3 +919,99 @@ export const updateNotificationById = async (
return result[0];
};
export const createPushoverNotification = async (
input: typeof apiCreatePushover._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newPushover = await tx
.insert(pushover)
.values({
userKey: input.userKey,
apiToken: input.apiToken,
priority: input.priority,
retry: input.retry,
expire: input.expire,
})
.returning()
.then((value) => value[0]);
if (!newPushover) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting pushover",
});
}
const newDestination = await tx
.insert(notifications)
.values({
pushoverId: newPushover.pushoverId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
notificationType: "pushover",
organizationId: organizationId,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updatePushoverNotification = async (
input: typeof apiUpdatePushover._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
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(pushover)
.set({
userKey: input.userKey,
apiToken: input.apiToken,
priority: input.priority,
retry: input.retry,
expire: input.expire,
})
.where(eq(pushover.pushoverId, input.pushoverId));
return newDestination;
});
};

View File

@@ -0,0 +1,35 @@
import { db } from "@dokploy/server/db";
export const getSSOProviders = async () => {
const providers = await db.query.ssoProvider.findMany({
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
},
});
return providers;
};
export const requestToHeaders = (req: {
headers?: Record<string, string | string[] | undefined>;
}): Headers => {
const headers = new Headers();
if (req?.headers) {
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined && key.toLowerCase() !== "host") {
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
}
}
return headers;
};
export const normalizeTrustedOrigin = (value: string): string => {
// Keep it simple: trim and remove trailing slashes.
// e.g. "https://example.com/" -> "https://example.com"
return value.trim().replace(/\/+$/, "");
};

View File

@@ -1,16 +1,19 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { docker } from "@dokploy/server/constants";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { and, eq } from "drizzle-orm";
import semver from "semver";
import { db } from "../db";
import { compose } from "../db/schema";
import {
initializeStandaloneTraefik,
initializeTraefikService,
type TraefikOptions,
} from "../setup/traefik-setup";
export interface IUpdateData {
latestVersion: string | null;
updateAvailable: boolean;
@@ -26,19 +29,6 @@ export const getDokployImageTag = () => {
return process.env.RELEASE_TAG || "latest";
};
export const getDokployImage = () => {
return `dokploy/dokploy:${getDokployImageTag()}`;
};
export const pullLatestRelease = async () => {
const stream = await docker.pull(getDokployImage());
await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err, res) =>
err ? reject(err) : resolve(res),
);
});
};
/** Returns Dokploy docker service image digest */
export const getServiceImageDigest = async () => {
const { stdout } = await execAsync(
@@ -55,56 +45,95 @@ export const getServiceImageDigest = async () => {
};
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
export const getUpdateData = async (): Promise<IUpdateData> => {
let currentDigest: string;
export const getUpdateData = async (
currentVersion: string,
): Promise<IUpdateData> => {
try {
currentDigest = await getServiceImageDigest();
} catch (error) {
// TODO: Docker versions 29.0.0 change the way to get the service image digest, so we need to update this in the future we upgrade to that version.
return DEFAULT_UPDATE_DATA;
}
const baseUrl =
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
let url: string | null = `${baseUrl}?page_size=100`;
let allResults: { digest: string; name: string }[] = [];
const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
let url: string | null = `${baseUrl}?page_size=100`;
let allResults: { digest: string; name: string }[] = [];
while (url) {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
// Fetch all tags from Docker Hub
while (url) {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const data = (await response.json()) as {
next: string | null;
results: { digest: string; name: string }[];
};
const data = (await response.json()) as {
next: string | null;
results: { digest: string; name: string }[];
};
allResults = allResults.concat(data.results);
url = data?.next;
}
allResults = allResults.concat(data.results);
url = data?.next;
}
const imageTag = getDokployImageTag();
const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest;
const currentImageTag = getDokployImageTag();
if (!searchedDigest) {
return DEFAULT_UPDATE_DATA;
}
// Special handling for canary and feature branches
// For development versions (canary/feature), don't perform update checks
// These are unstable versions that change frequently, and users on these
// branches are expected to manually manage updates
if (currentImageTag === "canary" || currentImageTag === "feature") {
const currentDigest = await getServiceImageDigest();
const latestDigest = allResults.find(
(t) => t.name === currentImageTag,
)?.digest;
if (!latestDigest) {
return DEFAULT_UPDATE_DATA;
}
if (currentDigest !== latestDigest) {
return {
latestVersion: currentImageTag,
updateAvailable: true,
};
}
return {
latestVersion: currentImageTag,
updateAvailable: false,
};
}
if (imageTag === "latest") {
const versionedTag = allResults.find(
(t) => t.digest === searchedDigest && t.name.startsWith("v"),
);
// For stable versions, use semver comparison
// Find the "latest" tag and get its digest
const latestTag = allResults.find((t) => t.name === "latest");
if (!versionedTag) {
if (!latestTag) {
return DEFAULT_UPDATE_DATA;
}
const { name: latestVersion, digest } = versionedTag;
const updateAvailable = digest !== currentDigest;
// Find the versioned tag (v0.x.x) that has the same digest as "latest"
const latestVersionTag = allResults.find(
(t) => t.digest === latestTag.digest && t.name.startsWith("v"),
);
return { latestVersion, updateAvailable };
if (!latestVersionTag) {
return DEFAULT_UPDATE_DATA;
}
const latestVersion = latestVersionTag.name;
// Use semver to compare versions for stable releases
const cleanedCurrent = semver.clean(currentVersion);
const cleanedLatest = semver.clean(latestVersion);
if (!cleanedCurrent || !cleanedLatest) {
return DEFAULT_UPDATE_DATA;
}
// Check if the latest version is greater than the current version
const updateAvailable = semver.gt(cleanedLatest, cleanedCurrent);
return {
latestVersion,
updateAvailable,
};
} catch (error) {
console.error("Error fetching update data:", error);
return DEFAULT_UPDATE_DATA;
}
const updateAvailable = searchedDigest !== currentDigest;
return { latestVersion: imageTag, updateAvailable };
};
interface TreeDataItem {
@@ -254,11 +283,22 @@ fi`;
export const reloadDockerResource = async (
resourceName: string,
serverId?: string,
version?: string,
) => {
const resourceType = await getDockerResourceType(resourceName, serverId);
let command = "";
if (resourceType === "service") {
command = `docker service update --force ${resourceName}`;
if (resourceName === "dokploy") {
const currentImageTag = getDokployImageTag();
let imageTag = version;
if (currentImageTag === "canary" || currentImageTag === "feature") {
imageTag = currentImageTag;
}
command = `docker service update --force --image dokploy/dokploy:${imageTag} ${resourceName}`;
} else {
command = `docker service update --force ${resourceName}`;
}
} else if (resourceType === "standalone") {
command = `docker restart ${resourceName}`;
} else {
@@ -402,13 +442,40 @@ export const writeTraefikSetup = async (input: TraefikOptions) => {
additionalPorts: input.additionalPorts,
serverId: input.serverId,
});
await reconnectServicesToTraefik(input.serverId);
} else if (resourceType === "standalone") {
await initializeStandaloneTraefik({
env: input.env,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
});
await reconnectServicesToTraefik(input.serverId);
} else {
throw new Error("Traefik resource type not found");
}
};
export const reconnectServicesToTraefik = async (serverId?: string) => {
const composeResult = await db.query.compose.findMany({
where: and(
...(serverId ? [eq(compose.serverId, serverId)] : []),
eq(compose.isolatedDeployment, true),
),
});
if (!composeResult) {
return;
}
let commands = "";
for (const compose of composeResult) {
commands += `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n`;
}
if (serverId) {
await execAsyncRemote(serverId, commands);
} else {
await execAsync(commands);
}
};

View File

@@ -1,10 +1,14 @@
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import { getDokployUrl } from "@dokploy/server/services/admin";
import {
createServerDeployment,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findServerById } from "@dokploy/server/services/server";
import {
findServerById,
updateServerById,
} from "@dokploy/server/services/server";
import {
getDefaultMiddlewares,
getDefaultServerTraefikConfig,
@@ -16,6 +20,15 @@ import {
import slug from "slugify";
import { Client } from "ssh2";
import { recreateDirectory } from "../utils/filesystem/directory";
import { setupMonitoring } from "./monitoring-setup";
const generateToken = () => {
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
};
export const slugify = (text: string | undefined) => {
if (!text) {
@@ -59,6 +72,29 @@ export const serverSetup = async (
);
await installRequirements(serverId, onData);
if (IS_CLOUD) {
onData?.("\nConfiguring Monitoring: 🔄\n");
const baseUrl = await getDokployUrl();
const token = generateToken();
const urlCallback = `${baseUrl}/api/trpc/notification.receiveNotification`;
// Update server with monitoring configuration
await updateServerById(serverId, {
metricsConfig: {
server: {
...server.metricsConfig.server,
token: token,
urlCallback: urlCallback,
},
containers: server.metricsConfig.containers,
},
});
await setupMonitoring(serverId);
onData?.("\nMonitoring Configured: ✅\n");
}
await updateDeploymentStatus(deployment.deploymentId, "done");
onData?.("\nSetup Server: ✅\n");
@@ -629,7 +665,7 @@ const installNixpacks = () => `
if command_exists nixpacks; then
echo "Nixpacks already installed ✅"
else
export NIXPACKS_VERSION=1.39.0
export NIXPACKS_VERSION=1.41.0
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
fi
@@ -639,7 +675,7 @@ const installRailpack = () => `
if command_exists railpack; then
echo "Railpack already installed ✅"
else
export RAILPACK_VERSION=0.2.2
export RAILPACK_VERSION=0.15.4
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
echo "Railpack version $RAILPACK_VERSION installed ✅"
fi
@@ -653,8 +689,8 @@ const installBuildpacks = () => `
if command_exists pack; then
echo "Buildpacks already installed ✅"
else
BUILDPACKS_VERSION=0.35.0
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
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
echo "Buildpacks version $BUILDPACKS_VERSION installed ✅"
fi
`;

View File

@@ -20,7 +20,7 @@ export const TRAEFIK_PORT =
Number.parseInt(process.env.TRAEFIK_PORT!, 10) || 80;
export const TRAEFIK_HTTP3_PORT =
Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443;
export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.1";
export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.7";
export interface TraefikOptions {
env?: string[];

View File

@@ -71,8 +71,9 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
return createOpenAICompatible({
name: "gemini",
baseURL: config.apiUrl,
queryParams: { key: config.apiKey },
headers: {},
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
case "custom":
return createOpenAICompatible({
@@ -102,7 +103,7 @@ export const getProviderHeaders = (
// Mistral
if (apiUrl.includes("mistral")) {
return {
Authorization: apiKey,
Authorization: `Bearer ${apiKey}`,
};
}

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
import { member } from "@dokploy/server/db/schema";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import { getAllServers } from "@dokploy/server/services/server";
@@ -29,7 +30,7 @@ export const initCronJobs = async () => {
const webServerSettings = await getWebServerSettings();
if (webServerSettings?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
scheduleJob("docker-cleanup", CLEANUP_CRON_JOB, async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
);
@@ -45,7 +46,7 @@ export const initCronJobs = async () => {
for (const server of servers) {
const { serverId, enableDockerCleanup, name } = server;
if (enableDockerCleanup) {
scheduleJob(serverId, "0 0 * * *", async () => {
scheduleJob(serverId, CLEANUP_CRON_JOB, async () => {
console.log(
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${name}`,
);

View File

@@ -134,6 +134,7 @@ const getExportEnvCommand = (compose: ComposeNested) => {
const envVars = getEnviromentVariablesObject(
compose.env,
compose.environment.project.env,
compose.environment.env,
);
const exports = Object.entries(envVars)
.map(([key, value]) => `${key}=${quote([value])}`)

View File

@@ -1,25 +1,6 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { encodeBase64, prepareEnvironmentVariables } from "../docker/utils";
export const createEnvFile = (
directory: string,
env: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
) => {
const envFilePath = join(dirname(directory), ".env");
if (!existsSync(dirname(envFilePath))) {
mkdirSync(dirname(envFilePath), { recursive: true });
}
const envFileContent = prepareEnvironmentVariables(
env,
projectEnv,
environmentEnv,
).join("\n");
writeFileSync(envFilePath, envFileContent);
};
export const createEnvFileCommand = (
directory: string,
env: string | null,

View File

@@ -0,0 +1,68 @@
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
import { and, eq, isNotNull } from "drizzle-orm";
import { scheduleJob } from "node-schedule";
import { db } from "../../db/index";
import { user as userSchema } from "../../db/schema/user";
export const LICENSE_KEY_URL =
process.env.NODE_ENV === "development"
? "http://localhost:4002"
: "https://licenses.dokploy.com";
export const initEnterpriseBackupCronJobs = async () => {
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
const users = await db.query.user.findMany({
where: and(
isNotNull(userSchema.licenseKey),
isNotNull(userSchema.enableEnterpriseFeatures),
eq(userSchema.isValidEnterpriseLicense, true),
),
});
for (const user of users) {
if (user.isValidEnterpriseLicense) {
console.log(
"Validating license key....",
user.firstName,
user.lastName,
);
try {
const isValid = await validateLicenseKey(user.licenseKey || "");
if (!isValid) {
throw new Error("License key is invalid");
}
} catch (error) {
await db
.update(userSchema)
.set({ isValidEnterpriseLicense: false })
.where(eq(userSchema.id, user.id));
}
}
}
});
};
export const validateLicenseKey = async (licenseKey: string) => {
try {
const ip = await getPublicIpWithFallback();
const result = await fetch(`${LICENSE_KEY_URL}/licenses/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, ip }),
});
if (!result.ok) {
const errorData = await result.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to validate license key");
}
const data = await result.json();
return data.valid;
} catch (error) {
console.error(
error instanceof Error ? error.message : "Failed to validate license key",
);
throw error;
}
};

View File

@@ -330,6 +330,7 @@ export const addDokployNetworkToService = (
) => {
let networks = networkService;
const network = "dokploy-network";
const defaultNetwork = "default";
if (!networks) {
networks = [];
}
@@ -338,10 +339,16 @@ export const addDokployNetworkToService = (
if (!networks.includes(network)) {
networks.push(network);
}
if (!networks.includes(defaultNetwork)) {
networks.push(defaultNetwork);
}
} else if (networks && typeof networks === "object") {
if (!(network in networks)) {
networks[network] = {};
}
if (!(defaultNetwork in networks)) {
networks[defaultNetwork] = {};
}
}
return networks;

View File

@@ -102,7 +102,8 @@ export const removeMonitoringDirectory = async (
};
export const getBuildAppDirectory = (application: Application) => {
const { APPLICATIONS_PATH } = paths(!!application.serverId);
const serverId = application.buildServerId || application.serverId;
const { APPLICATIONS_PATH } = paths(!!serverId);
const { appName, buildType, sourceType, customGitBuildPath, dockerfile } =
application;
let buildPath = "";

View File

@@ -11,6 +11,8 @@ import {
sendGotifyNotification,
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -44,18 +46,30 @@ export const sendBuildErrorNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
lark: true,
pushover: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
custom,
lark,
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
BuildFailedEmail({
projectName,
@@ -66,11 +80,22 @@ export const sendBuildErrorNotifications = async ({
date: date.toLocaleString(),
}),
).catch();
await sendEmailNotification(
email,
"Build failed for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Build failed for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Build failed for dokploy",
template,
);
}
}
if (discord) {
@@ -349,6 +374,14 @@ export const sendBuildErrorNotifications = async ({
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
"Build Failed",
`Project: ${projectName}\nApplication: ${applicationName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}\nError: ${errorMessage}`,
);
}
} catch (error) {
console.log(error);
}

View File

@@ -12,6 +12,8 @@ import {
sendGotifyNotification,
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -47,18 +49,30 @@ export const sendBuildSuccessNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
lark: true,
pushover: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
custom,
lark,
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
BuildSuccessEmail({
projectName,
@@ -69,11 +83,22 @@ export const sendBuildSuccessNotifications = async ({
environmentName,
}),
).catch();
await sendEmailNotification(
email,
"Build success for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Build success for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Build success for dokploy",
template,
);
}
}
if (discord) {
@@ -363,6 +388,14 @@ export const sendBuildSuccessNotifications = async ({
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
"Build Success",
`Project: ${projectName}\nApplication: ${applicationName}\nEnvironment: ${environmentName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}`,
);
}
} catch (error) {
console.log(error);
}

View File

@@ -11,6 +11,8 @@ import {
sendGotifyNotification,
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -44,18 +46,30 @@ export const sendDatabaseBackupNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
lark: true,
pushover: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
custom,
lark,
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
DatabaseBackupEmail({
projectName,
@@ -66,11 +80,22 @@ export const sendDatabaseBackupNotifications = async ({
date: date.toLocaleString(),
}),
).catch();
await sendEmailNotification(
email,
"Database backup for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Database backup for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Database backup for dokploy",
template,
);
}
}
if (discord) {
@@ -377,6 +402,14 @@ export const sendDatabaseBackupNotifications = async ({
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Database Backup ${type === "success" ? "Successful" : "Failed"}`,
`Project: ${projectName}\nApplication: ${applicationName}\nDatabase: ${databaseType}\nDatabase Name: ${databaseName}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
} catch (error) {
console.log(error);
}

View File

@@ -11,6 +11,8 @@ import {
sendGotifyNotification,
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -31,27 +33,49 @@ export const sendDockerCleanupNotifications = async (
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
lark: true,
pushover: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
custom,
lark,
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
DockerCleanupEmail({ message, date: date.toLocaleString() }),
).catch();
await sendEmailNotification(
email,
"Docker cleanup for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Docker cleanup for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Docker cleanup for dokploy",
template,
);
}
}
if (discord) {
@@ -230,6 +254,14 @@ export const sendDockerCleanupNotifications = async (
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
"Docker Cleanup",
`Date: ${date.toLocaleString()}\nMessage: ${message}`,
);
}
} catch (error) {
console.log(error);
}

View File

@@ -11,6 +11,8 @@ import {
sendGotifyNotification,
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -25,28 +27,50 @@ export const sendDokployRestartNotifications = async () => {
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
custom: true,
lark: true,
pushover: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
custom,
lark,
pushover,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
DokployRestartEmail({ date: date.toLocaleString() }),
).catch();
await sendEmailNotification(
email,
"Dokploy Server Restarted",
template,
);
if (email) {
await sendEmailNotification(
email,
"Dokploy Server Restarted",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Dokploy Server Restarted",
template,
);
}
}
if (discord) {
@@ -219,6 +243,14 @@ export const sendDokployRestartNotifications = async () => {
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
"Dokploy Server Restarted",
`Date: ${date.toLocaleString()}`,
);
}
} catch (error) {
console.log(error);
}

View File

@@ -5,6 +5,7 @@ import {
sendCustomNotification,
sendDiscordNotification,
sendLarkNotification,
sendPushoverNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -38,6 +39,7 @@ export const sendServerThresholdNotifications = async (
slack: true,
custom: true,
lark: true,
pushover: true,
},
});
@@ -45,7 +47,7 @@ export const sendServerThresholdNotifications = async (
const typeColor = 0xff0000; // Rojo para indicar alerta
for (const notification of notificationList) {
const { discord, telegram, slack, custom, lark } = notification;
const { discord, telegram, slack, custom, lark, pushover } = notification;
if (discord) {
const decorate = (decoration: string, text: string) =>
@@ -266,5 +268,13 @@ export const sendServerThresholdNotifications = async (
},
});
}
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()}`,
);
}
}
};

View File

@@ -5,10 +5,13 @@ import type {
gotify,
lark,
ntfy,
pushover,
resend,
slack,
telegram,
} from "@dokploy/server/db/schema";
import nodemailer from "nodemailer";
import { Resend } from "resend";
export const sendEmailNotification = async (
connection: typeof email.$inferInsert,
@@ -45,6 +48,32 @@ export const sendEmailNotification = async (
}
};
export const sendResendNotification = async (
connection: typeof resend.$inferInsert,
subject: string,
htmlContent: string,
) => {
try {
const client = new Resend(connection.apiKey);
const result = await client.emails.send({
from: connection.fromAddress,
to: connection.toAddresses,
subject,
html: htmlContent,
});
if (result.error) {
throw new Error(result.error.message);
}
} catch (err) {
console.log(err);
throw new Error(
`Failed to send Resend notification ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
};
export const sendDiscordNotification = async (
connection: typeof discord.$inferInsert,
embed: any,
@@ -223,3 +252,33 @@ export const sendLarkNotification = async (
console.log(err);
}
};
export const sendPushoverNotification = async (
connection: typeof pushover.$inferInsert,
title: string,
message: string,
) => {
const formData = new URLSearchParams();
formData.append("token", connection.apiToken);
formData.append("user", connection.userKey);
formData.append("title", title);
formData.append("message", message);
formData.append("priority", connection.priority?.toString() || "0");
// For emergency priority (2), retry and expire are required
if (connection.priority === 2) {
formData.append("retry", connection.retry?.toString() || "30");
formData.append("expire", connection.expire?.toString() || "3600");
}
const response = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Failed to send Pushover notification: ${response.statusText}`,
);
}
};

View File

@@ -9,6 +9,8 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -51,15 +53,18 @@ export const sendVolumeBackupNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
pushover: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy } = notification;
const { email, resend, discord, telegram, slack, gotify, ntfy, pushover } =
notification;
if (email) {
if (email || resend) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
const htmlContent = await renderAsync(
VolumeBackupEmail({
@@ -73,7 +78,12 @@ export const sendVolumeBackupNotifications = async ({
date: date.toISOString(),
}),
);
await sendEmailNotification(email, subject, htmlContent);
if (email) {
await sendEmailNotification(email, subject, htmlContent);
}
if (resend) {
await sendResendNotification(resend, subject, htmlContent);
}
}
if (discord) {
@@ -270,5 +280,13 @@ export const sendVolumeBackupNotifications = async ({
],
});
}
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}` : ""}`,
);
}
}
};

View File

@@ -79,6 +79,7 @@ export const getBitbucketHeaders = (bitbucketProvider: Bitbucket) => {
interface CloneBitbucketRepository {
appName: string;
bitbucketRepository: string | null;
bitbucketRepositorySlug?: string | null;
bitbucketOwner: string | null;
bitbucketBranch: string | null;
bitbucketId: string | null;
@@ -117,7 +118,8 @@ export const cloneBitbucketRepository = async ({
const outputPath = join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`;
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
command += `git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
@@ -137,6 +139,7 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => {
let repositories: {
name: string;
url: string;
slug: string;
owner: { username: string };
}[] = [];
@@ -159,6 +162,7 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => {
const mappedData = data.values.map((repo: any) => ({
name: repo.name,
url: repo.links.html.href,
slug: repo.slug,
owner: {
username: repo.workspace.slug,
},

View File

@@ -49,7 +49,9 @@ export const refreshGiteaToken = async (giteaProviderId: string) => {
}
// Token is expired or about to expire, refresh it
const tokenEndpoint = `${giteaProvider.giteaUrl}/login/oauth/access_token`;
// Use internal URL when Gitea is on same instance as Dokploy
const baseUrl = giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl;
const tokenEndpoint = `${baseUrl}/login/oauth/access_token`;
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: giteaProvider.refreshToken,

View File

@@ -21,7 +21,9 @@ export const refreshGitlabToken = async (gitlabProviderId: string) => {
return;
}
const response = await fetch(`${gitlabProvider.gitlabUrl}/oauth/token`, {
// Use internal URL for token refresh when GitLab is on same instance as Dokploy
const baseUrl = gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl;
const response = await fetch(`${baseUrl}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",

View File

@@ -1,6 +1,6 @@
import { createWriteStream } from "node:fs";
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import type { Schedule } from "@dokploy/server/db/schema/schedule";
import {
createDeploymentSchedule,
@@ -93,6 +93,13 @@ export const runCommand = async (scheduleId: string) => {
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
try {
if (IS_CLOUD) {
writeStream.write(
"This feature is not available in the cloud version.",
);
writeStream.end();
return;
}
writeStream.write(
`docker exec ${containerId} ${shellType} -c ${command}\n`,
);

View File

@@ -10,7 +10,7 @@ export const backupVolume = async (
const { serviceType, volumeName, turnOff, prefix } = volumeBackup;
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
const destination = volumeBackup.destination;
const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
@@ -45,16 +45,56 @@ export const backupVolume = async (
return baseCommand;
}
const serviceLockId =
serviceType === "application"
? volumeBackup.application?.appName
: `${volumeBackup.compose?.appName}_${volumeBackup.serviceName}`;
const lockPath = `${VOLUME_BACKUP_LOCK_PATH}-${serviceLockId}`;
const lockWrapper = (body: string) => `
set -e
LOCK_PATH="${lockPath}"
echo "Waiting for volume backup lock: $LOCK_PATH"
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_PATH"
flock 9
else
LOCK_DIR="$LOCK_PATH.dir"
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
echo "Waiting for volume backup lock: $LOCK_PATH"
sleep 5
done
trap 'rm -rf "$LOCK_DIR"' EXIT
fi
echo "Volume backup lock acquired"
${body}
echo "Volume backup lock released"
`;
console.log(
lockWrapper(`
echo "Volume backup lock acquired"
echo "Volume backup lock released"
`),
);
if (serviceType === "application") {
return `
return lockWrapper(`
echo "Stopping application to 0 replicas"
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
echo "Actual replicas: $ACTUAL_REPLICAS"
docker service scale ${volumeBackup.application?.appName}=0
docker service update --replicas=0 ${volumeBackup.application?.appName}
${baseCommand}
echo "Starting application to $ACTUAL_REPLICAS replicas"
docker service scale ${volumeBackup.application?.appName}=$ACTUAL_REPLICAS
`;
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}
`);
}
if (serviceType === "compose") {
const compose = await findComposeById(
@@ -69,25 +109,27 @@ export const backupVolume = async (
echo "Service name: ${compose.appName}_${volumeBackup.serviceName}"
ACTUAL_REPLICAS=$(docker service inspect ${compose.appName}_${volumeBackup.serviceName} --format "{{.Spec.Mode.Replicated.Replicas}}")
echo "Actual replicas: $ACTUAL_REPLICAS"
docker service scale ${compose.appName}_${volumeBackup.serviceName}=0`;
docker service update --replicas=0 ${compose.appName}_${volumeBackup.serviceName}`;
startCommand = `
echo "Starting compose to $ACTUAL_REPLICAS replicas"
docker service scale ${compose.appName}_${volumeBackup.serviceName}=$ACTUAL_REPLICAS`;
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${compose.appName}_${volumeBackup.serviceName}`;
} else {
stopCommand = `
echo "Stopping compose container"
ID=$(docker ps -q --filter "label=com.docker.compose.project=${compose.appName}" --filter "label=com.docker.compose.service=${volumeBackup.serviceName}")
docker stop $ID`;
startCommand = `
echo "Starting compose container"
docker start $ID
echo "Compose container started"
`;
}
return `
return lockWrapper(`
${stopCommand}
${baseCommand}
${startCommand}
`;
`);
}
};