Merge branch 'canary' into feat/domain-enable-disable

This commit is contained in:
Narciso
2026-08-10 22:38:27 -04:00
375 changed files with 112830 additions and 27203 deletions
+2
View File
@@ -37,7 +37,9 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-secrets-manager": "^3.1097.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23",
"@better-auth/sso": "1.6.23",
"@better-auth/utils": "0.4.2",
+32
View File
@@ -8,6 +8,7 @@ import {
timestamp,
} from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { network } from "./network";
import { projects } from "./project";
import { server } from "./server";
import { ssoProvider } from "./sso";
@@ -108,6 +109,7 @@ export const organizationRelations = relations(
references: [user.id],
}),
servers: many(server),
networks: many(network),
projects: many(projects),
members: many(member),
ssoProviders: many(ssoProvider),
@@ -221,6 +223,36 @@ export const twoFactor = pgTable("two_factor", {
lockedUntil: timestamp("locked_until"),
});
export const passkey = pgTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: boolean("backed_up").notNull(),
transports: text("transports"),
createdAt: timestamp("created_at"),
aaguid: text("aaguid"),
},
(table) => [
index("passkey_userId_idx").on(table.userId),
index("passkey_credentialID_idx").on(table.credentialID),
],
);
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
}));
export const apikey = pgTable("apikey", {
id: text("id").primaryKey(),
name: text("name"),
+9
View File
@@ -54,6 +54,15 @@ export const apiUpdateAi = createSchema
})
.omit({ organizationId: true });
export const aiCustomProviderSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
});
export const apiSaveAiCustomProviders = z.object({
providers: z.array(aiCustomProviderSchema),
});
export const deploySuggestionSchema = z.object({
environmentId: z.string().min(1),
id: z.string().min(1),
@@ -233,6 +233,10 @@ export const applications = pgTable("application", {
onDelete: "set null",
},
),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const applicationsRelations = relations(
@@ -374,6 +378,8 @@ const createSchema = createInsertSchema(applications, {
previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional().optional(),
previewLabels: z.array(z.string()).optional(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
+3 -1
View File
@@ -91,4 +91,6 @@ export type AuditResourceType =
| "cluster"
| "mount"
| "application"
| "compose";
| "compose"
| "network"
| "vaultProvider";
+38 -2
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -80,6 +87,7 @@ export const compose = pgTable("compose", {
),
command: text("command").notNull().default(""),
//
createEnvFile: boolean("createEnvFile").notNull().default(true),
enableSubmodules: boolean("enableSubmodules").notNull().default(false),
composePath: text("composePath").notNull().default("./docker-compose.yml"),
suffix: text("suffix").notNull().default(""),
@@ -91,6 +99,7 @@ export const compose = pgTable("compose", {
.default(false),
triggerType: triggerType("triggerType").default("push"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
icon: text("icon"),
environmentId: text("environmentId")
.notNull()
.references(() => environments.environmentId, { onDelete: "cascade" }),
@@ -113,6 +122,15 @@ export const compose = pgTable("compose", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
serviceNetworks: jsonb("serviceNetworks")
.$type<
Array<{
serviceName: string;
networkIds: string[];
detachDokployNetwork: boolean;
}>
>()
.default([]),
});
export const composeRelations = relations(compose, ({ one, many }) => ({
@@ -166,6 +184,7 @@ const createSchema = createInsertSchema(compose, {
environmentId: z.string(),
customGitSSHKeyId: z.string().optional(),
command: z.string().optional(),
createEnvFile: z.boolean().optional(),
composePath: z.string().min(1),
composeType: z.enum(["docker-compose", "stack"]).optional(),
watchPaths: z.array(z.string()).optional(),
@@ -174,6 +193,20 @@ const createSchema = createInsertSchema(compose, {
.optional(),
triggerType: z.enum(["push", "tag"]).optional(),
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
icon: z
.string()
.max(2 * 1024 * 1024, "Icon must be less than 2MB")
.nullable()
.optional(),
serviceNetworks: z
.array(
z.object({
serviceName: z.string(),
networkIds: z.array(z.string()),
detachDokployNetwork: z.boolean(),
}),
)
.optional(),
});
export const apiCreateCompose = createSchema.pick({
@@ -235,7 +268,10 @@ export const apiSaveEnvironmentVariablesCompose = createSchema
composeId: true,
env: true,
})
.required();
.required()
.extend({
createEnvFile: z.boolean().optional(),
});
export const apiRandomizeCompose = createSchema
.pick({
+5
View File
@@ -16,6 +16,7 @@ export const github = pgTable("github", {
githubInstallationId: text("githubInstallationId"),
githubPrivateKey: text("githubPrivateKey"),
githubWebhookSecret: text("githubWebhookSecret"),
githubUrl: text("githubUrl").default("https://github.com").notNull(),
gitProviderId: text("gitProviderId")
.notNull()
.references(() => gitProvider.gitProviderId, { onDelete: "cascade" }),
@@ -36,6 +37,10 @@ export const apiCreateGithub = z.object({
githubInstallationId: z.string().optional(),
githubPrivateKey: z.string().optional(),
githubWebhookSecret: z.string().nullable(),
githubUrl: z
.url()
.startsWith("https://", "Only https is supported for GitHub instances")
.optional(),
gitProviderId: z.string().optional(),
name: z.string().min(1),
});
+2
View File
@@ -20,6 +20,7 @@ export * from "./mariadb";
export * from "./mongo";
export * from "./mount";
export * from "./mysql";
export * from "./network";
export * from "./notification";
export * from "./patch";
export * from "./port";
@@ -41,5 +42,6 @@ export * from "./sso";
export * from "./tag";
export * from "./user";
export * from "./utils";
export * from "./vault-provider";
export * from "./volume-backups";
export * from "./web-server-settings";
+6
View File
@@ -83,6 +83,10 @@ export const libsql = pgTable("libsql", {
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
replicas: integer("replicas").default(1).notNull(),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
@@ -146,6 +150,8 @@ const createSchema = createInsertSchema(libsql, {
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateLibsql = createSchema
+14 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
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";
@@ -88,6 +95,10 @@ export const mariadb = pgTable("mariadb", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
@@ -148,6 +159,8 @@ const createSchema = createInsertSchema(mariadb, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateMariaDB = createSchema.pick({
+6
View File
@@ -92,6 +92,10 @@ export const mongo = pgTable("mongo", {
onDelete: "cascade",
}),
replicaSets: boolean("replicaSets").default(false),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const mongoRelations = relations(mongo, ({ one, many }) => ({
@@ -146,6 +150,8 @@ const createSchema = createInsertSchema(mongo, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateMongo = createSchema.pick({
+14 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
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";
@@ -86,6 +93,10 @@ export const mysql = pgTable("mysql", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
@@ -145,6 +156,8 @@ const createSchema = createInsertSchema(mysql, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateMySql = createSchema.pick({
+135
View File
@@ -0,0 +1,135 @@
import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { server } from "./server";
export const networkDriver = pgEnum("networkDriver", ["bridge", "overlay"]);
export const network = pgTable("network", {
networkId: text("networkId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
driver: networkDriver("driver").notNull().default("bridge"),
internal: boolean("internal").notNull().default(false),
attachable: boolean("attachable").notNull().default(false),
enableIPv4: boolean("enableIPv4").notNull().default(true),
enableIPv6: boolean("enableIPv6").notNull().default(false),
mtu: integer("mtu"),
ipam: jsonb("ipam")
.$type<{
driver?: string;
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
}>()
.default({}),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
});
export const networkRelations = relations(network, ({ one }) => ({
organization: one(organization, {
fields: [network.organizationId],
references: [organization.id],
}),
server: one(server, {
fields: [network.serverId],
references: [server.serverId],
}),
}));
const createSchema = createInsertSchema(network, {
networkId: z.string().min(1),
name: z.string().min(1),
driver: z.enum(["bridge", "overlay"]).optional(),
internal: z.boolean().optional(),
attachable: z.boolean().optional(),
enableIPv4: z.boolean().optional(),
enableIPv6: z.boolean().optional(),
mtu: z.number().int().min(68).max(65535).optional().nullable(),
ipam: z
.object({
driver: z.string().optional(),
config: z
.array(
z.object({
subnet: z.string().optional(),
gateway: z.string().optional(),
ipRange: z.string().optional(),
}),
)
.optional(),
})
.optional(),
organizationId: z.string().min(1),
serverId: z.string().optional().nullable(),
});
const validateNetworkInput = (
input: {
enableIPv4?: boolean;
enableIPv6?: boolean;
ipam?: {
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
} | null;
},
ctx: z.RefinementCtx,
) => {
if (input.enableIPv4 === false && input.enableIPv6 !== true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["enableIPv4"],
message: "IPv4 or IPv6 must be enabled",
});
}
for (const [index, entry] of (input.ipam?.config ?? []).entries()) {
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ipam", "config", index, "subnet"],
message: "Gateway and IP range require a subnet",
});
}
}
};
export const apiCreateNetwork = createSchema
.pick({
name: true,
driver: true,
internal: true,
attachable: true,
enableIPv4: true,
enableIPv6: true,
mtu: true,
ipam: true,
serverId: true,
})
.partial()
.required({ name: true })
.superRefine(validateNetworkInput);
export const apiFindOneNetwork = z.object({
networkId: z.string().min(1),
});
export const apiRemoveNetwork = z.object({
networkId: z.string().min(1),
});
+14 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
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";
@@ -86,6 +93,10 @@ export const postgres = pgTable("postgres", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const postgresRelations = relations(postgres, ({ one, many }) => ({
@@ -140,6 +151,8 @@ const createSchema = createInsertSchema(postgres, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreatePostgres = createSchema.pick({
+14 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
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";
@@ -80,6 +87,10 @@ export const redis = pgTable("redis", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const redisRelations = relations(redis, ({ one, many }) => ({
@@ -129,6 +140,8 @@ const createSchema = createInsertSchema(redis, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateRedis = createSchema.pick({
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Application } from "@dokploy/server/services/application";
import type { Environment } from "@dokploy/server/services/environment";
import type { Mount } from "@dokploy/server/services/mount";
import type { Port } from "@dokploy/server/services/port";
import type { Project } from "@dokploy/server/services/project";
@@ -27,7 +28,7 @@ export const rollbacks = pgTable("rollback", {
.$defaultFn(() => new Date().toISOString()),
fullContext: jsonb("fullContext").$type<
Application & {
environment: {
environment: Environment & {
project: Project;
};
mounts: Mount[];
+4 -1
View File
@@ -57,6 +57,7 @@ export const schedules = pgTable("schedule", {
});
export type Schedule = typeof schedules.$inferSelect;
export type ScheduleType = Schedule["scheduleType"];
export const schedulesRelations = relations(schedules, ({ one, many }) => ({
application: one(applications, {
@@ -78,7 +79,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
deployments: many(deployments),
}));
export const createScheduleSchema = createInsertSchema(schedules);
export const createScheduleSchema = createInsertSchema(schedules, {
scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]),
});
export const updateScheduleSchema = createScheduleSchema.extend({
scheduleId: z.string().min(1),
+2
View File
@@ -19,6 +19,7 @@ import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { network } from "./network";
import { postgres } from "./postgres";
import { redis } from "./redis";
import { schedules } from "./schedule";
@@ -125,6 +126,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
mysql: many(mysql),
postgres: many(postgres),
certificates: many(certificates),
networks: many(network),
organization: one(organization, {
fields: [server.organizationId],
references: [organization.id],
+6
View File
@@ -41,6 +41,12 @@ 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";
/** Docker volume name: must start alphanumeric, then letters/numbers/._- only. Safe for shell. */
export const VOLUME_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const VOLUME_NAME_MESSAGE =
"Volume name must start with a letter or number and contain only letters, numbers, dots, underscores and hyphens";
/** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */
export const DATABASE_PASSWORD_REGEX =
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;
@@ -0,0 +1,145 @@
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
export const vaultProviderType = pgEnum("VaultProviderType", [
"hashicorp",
"infisical",
"aws",
"doppler",
"azure",
]);
export const hashicorpVaultConfigSchema = z.object({
providerType: z.literal("hashicorp"),
url: z.string().url(),
token: z.string().min(1),
namespace: z.string().optional(),
mount: z.string().min(1).default("secret"),
});
export const infisicalVaultConfigSchema = z.object({
providerType: z.literal("infisical"),
siteUrl: z.string().url().default("https://app.infisical.com"),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
projectId: z.string().min(1),
environmentSlug: z.string().min(1),
secretPath: z.string().default("/"),
});
export const awsVaultConfigSchema = z.object({
providerType: z.literal("aws"),
region: z.string().min(1),
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
endpoint: z.string().url().optional(),
});
export const dopplerVaultConfigSchema = z.object({
providerType: z.literal("doppler"),
serviceToken: z.string().min(1),
project: z.string().optional(),
config: z.string().optional(),
});
export const azureVaultConfigSchema = z.object({
providerType: z.literal("azure"),
vaultUri: z.string().url(),
tenantId: z.string().min(1),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
});
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
hashicorpVaultConfigSchema,
infisicalVaultConfigSchema,
awsVaultConfigSchema,
dopplerVaultConfigSchema,
azureVaultConfigSchema,
]);
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
export const vaultProviderAssignmentSchema = z.object({
projectId: z.string().min(1),
environmentIds: z.array(z.string().min(1)).default([]),
});
export type VaultProviderAssignment = z.infer<
typeof vaultProviderAssignmentSchema
>;
export const vaultProvider = pgTable(
"vault_provider",
{
vaultProviderId: text("vaultProviderId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
providerType: vaultProviderType("providerType").notNull(),
config: jsonb("config").$type<VaultProviderConfig>().notNull(),
assignments: jsonb("assignments")
.$type<VaultProviderAssignment[]>()
.notNull()
.default([]),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
},
(table) => [
uniqueIndex("vault_provider_org_name_idx").on(
table.organizationId,
table.name,
),
],
);
const vaultProviderNameSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-zA-Z0-9_-]+$/,
"Name can only contain letters, numbers, dashes and underscores",
);
const createSchema = createInsertSchema(vaultProvider);
export const apiCreateVaultProvider = createSchema.pick({}).extend({
name: vaultProviderNameSchema,
config: vaultProviderConfigSchema,
assignments: z.array(vaultProviderAssignmentSchema),
});
export const apiUpdateVaultProvider = createSchema.pick({}).extend({
vaultProviderId: z.string().min(1),
name: vaultProviderNameSchema,
config: vaultProviderConfigSchema,
assignments: z.array(vaultProviderAssignmentSchema),
});
export const apiFindOneVaultProvider = z.object({
vaultProviderId: z.string().min(1),
});
export const apiRemoveVaultProvider = z.object({
vaultProviderId: z.string().min(1),
});
export const apiTestVaultProvider = z.object({
vaultProviderId: z.string().min(1).optional(),
config: vaultProviderConfigSchema.optional(),
});
export const apiListVaultSecretNames = z.object({
vaultProviderId: z.string().min(1),
projectId: z.string().min(1),
environmentId: z.string().optional(),
});
@@ -14,7 +14,11 @@ import { serviceType } from "./mount";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { redis } from "./redis";
import { generateAppName } from "./utils";
import {
generateAppName,
VOLUME_NAME_MESSAGE,
VOLUME_NAME_REGEX,
} from "./utils";
export const volumeBackups = pgTable("volume_backup", {
volumeBackupId: text("volumeBackupId")
@@ -113,7 +117,9 @@ export const volumeBackupsRelations = relations(
}),
);
export const createVolumeBackupSchema = createInsertSchema(volumeBackups).omit({
export const createVolumeBackupSchema = createInsertSchema(volumeBackups, {
volumeName: z.string().regex(VOLUME_NAME_REGEX, VOLUME_NAME_MESSAGE),
}).omit({
volumeBackupId: true,
});
+4 -1
View File
@@ -17,6 +17,7 @@ export * from "./services/compose";
export * from "./services/deployment";
export * from "./services/destination";
export * from "./services/docker";
export * from "./services/docker-volume";
export * from "./services/domain";
export * from "./services/environment";
export * from "./services/git-provider";
@@ -28,6 +29,7 @@ export * from "./services/mariadb";
export * from "./services/mongo";
export * from "./services/mount";
export * from "./services/mysql";
export * from "./services/network";
export * from "./services/notification";
export * from "./services/patch";
export * from "./services/patch-repo";
@@ -49,13 +51,13 @@ export * from "./services/server";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/user";
export * from "./services/vault-provider";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
export * from "./setup/config-paths";
export * from "./setup/forward-auth-setup";
export * from "./setup/monitoring-setup";
export * from "./setup/postgres-setup";
export * from "./setup/redis-setup";
export * from "./setup/server-audit";
export * from "./setup/server-setup";
export * from "./setup/server-validate";
@@ -101,6 +103,7 @@ export * from "./utils/docker/compose/volume";
export * from "./utils/docker/domain";
export * from "./utils/docker/types";
export * from "./utils/docker/utils";
export * from "./utils/vault";
export * from "./utils/filesystem/directory";
export * from "./utils/filesystem/ssh";
export * from "./utils/git-branch-validation";
@@ -48,6 +48,7 @@ export const statements = {
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
} as const;
/**
@@ -74,6 +75,7 @@ export const enterpriseOnlyResources = new Set<string>([
"logs",
"monitoring",
"auditLog",
"vaultProvider",
]);
export const ac = createAccessControl(statements);
@@ -113,6 +115,7 @@ export const ownerRole = ac.newRole({
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
});
/**
@@ -150,6 +153,7 @@ export const adminRole = ac.newRole({
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
});
/**
@@ -192,4 +196,6 @@ export const memberRole = ac.newRole({
notification: [],
tag: ["read"],
auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"],
});
+2
View File
@@ -1,4 +1,5 @@
import { apiKey } from "@better-auth/api-key";
import { passkey } from "@better-auth/passkey";
import { scim } from "@better-auth/scim";
import { sso } from "@better-auth/sso";
import { betterAuth } from "better-auth";
@@ -33,6 +34,7 @@ export const auth = betterAuth({
apiKey({ enableMetadata: true, references: "user" }),
sso(),
twoFactor(),
passkey(),
organization({
ac,
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
+3 -1
View File
@@ -1,5 +1,6 @@
import type { IncomingMessage } from "node:http";
import { apiKey } from "@better-auth/api-key";
import { passkey } from "@better-auth/passkey";
import { scim } from "@better-auth/scim";
import { sso } from "@better-auth/sso";
import * as bcrypt from "bcrypt";
@@ -419,7 +420,7 @@ const createBetterAuth = () =>
enableMetadata: true,
references: "user",
}),
sso(),
sso({ trustEmailVerified: true }),
scim({
beforeSCIMTokenGenerated: async ({ user }) => {
const dbUser = await db.query.user.findFirst({
@@ -435,6 +436,7 @@ const createBetterAuth = () =>
},
}),
twoFactor(),
passkey(),
organization({
ac,
roles: {
+41
View File
@@ -0,0 +1,41 @@
const IO_UNIT_FACTORS: Record<string, number> = {
B: 1 / 1e6,
kB: 1e-3,
KB: 1e-3,
MB: 1,
GB: 1e3,
TB: 1e6,
KiB: 1024 / 1e6,
MiB: 1024 ** 2 / 1e6,
GiB: 1024 ** 3 / 1e6,
TiB: 1024 ** 4 / 1e6,
};
export const toMb = (value: number | string | undefined): number => {
if (typeof value === "number") {
return value;
}
if (!value) {
return 0;
}
const parsed = Number.parseFloat(value);
if (Number.isNaN(parsed)) {
return 0;
}
const unit = value.replace(/[0-9.\s]/g, "");
return parsed * (IO_UNIT_FACTORS[unit] ?? 1);
};
export const parseIoToMb = (raw: string | undefined): number =>
Number(toMb(raw).toFixed(6));
export const formatMb = (value: number | string | undefined): string => {
const mb = toMb(value);
if (mb >= 1000) {
return `${(mb / 1000).toFixed(2)} GB`;
}
if (mb < 0.01 && mb > 0) {
return `${(mb * 1000).toFixed(2)} kB`;
}
return `${mb.toFixed(2)} MB`;
};
+5 -4
View File
@@ -1,6 +1,7 @@
import { promises } from "node:fs";
import { OSUtils } from "node-os-utils";
import { paths } from "../constants";
import { parseIoToMb } from "./units";
export interface Container {
BlockIO: string;
@@ -28,13 +29,13 @@ export const recordAdvancedStats = async (
});
await updateStatsFile(appName, "block", {
readMb: stats.BlockIO.split(" ")[0],
writeMb: stats.BlockIO.split(" ")[2],
readMb: parseIoToMb(stats.BlockIO.split(" ")[0]),
writeMb: parseIoToMb(stats.BlockIO.split(" ")[2]),
});
await updateStatsFile(appName, "network", {
inputMb: stats.NetIO.split(" ")[0],
outputMb: stats.NetIO.split(" ")[2],
inputMb: parseIoToMb(stats.NetIO.split(" ")[0]),
outputMb: parseIoToMb(stats.NetIO.split(" ")[2]),
});
if (appName === "dokploy") {
+67 -1
View File
@@ -1,5 +1,6 @@
import { db } from "@dokploy/server/db";
import { ai } from "@dokploy/server/db/schema";
import { ai, organization } from "@dokploy/server/db/schema";
import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText, Output } from "ai";
@@ -48,9 +49,74 @@ export const getAiSettingById = async (aiId: string) => {
return aiSetting;
};
type AiCustomProvider = z.infer<typeof aiCustomProviderSchema>;
const parseOrgMetadata = (metadata: string | null) => {
try {
const parsed = JSON.parse(metadata || "{}");
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
};
export const getCustomAiProviders = async (organizationId: string) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const metadata = parseOrgMetadata(org?.metadata ?? null);
const result = z
.array(aiCustomProviderSchema)
.safeParse(metadata.aiProviders);
return result.success ? result.data : [];
};
export const saveCustomAiProviders = async (
organizationId: string,
providers: AiCustomProvider[],
) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
const metadata = parseOrgMetadata(org.metadata);
metadata.aiProviders = providers;
await db
.update(organization)
.set({ metadata: JSON.stringify(metadata) })
.where(eq(organization.id, organizationId));
return providers;
};
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const saveAiSettings = async (organizationId: string, settings: any) => {
const aiId = settings.aiId;
if (settings.apiUrl) {
const customProviders = await getCustomAiProviders(organizationId);
if (customProviders.length > 0) {
const isAllowed = customProviders.some(
(provider) =>
normalizeApiUrl(provider.apiUrl) === normalizeApiUrl(settings.apiUrl),
);
if (!isAllowed) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"This API URL is not in your organization's allowed AI providers",
});
}
}
}
return db
.insert(ai)
.values({
+18 -4
View File
@@ -102,10 +102,24 @@ export const findApplicationById = async (applicationId: string) => {
redirects: true,
security: true,
ports: true,
gitlab: true,
github: true,
bitbucket: true,
gitea: true,
gitlab: {
columns: { secret: false, accessToken: false, refreshToken: false },
},
github: {
columns: {
githubClientSecret: false,
githubPrivateKey: false,
githubWebhookSecret: false,
},
},
bitbucket: { columns: { appPassword: false, apiToken: false } },
gitea: {
columns: {
clientSecret: false,
accessToken: false,
refreshToken: false,
},
},
server: true,
previewDeployments: true,
registry: { columns: { password: false } },
+6 -5
View File
@@ -9,6 +9,7 @@ import {
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import { stringify } from "yaml";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
@@ -63,7 +64,7 @@ export const removeCertificateById = async (certificateId: string) => {
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
if (certificate.serverId) {
await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`);
await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`);
} else {
await removeDirectoryIfExistsContent(certDir);
}
@@ -108,10 +109,10 @@ const createCertificateFiles = async (certificate: Certificate) => {
const certificateData = encodeBase64(certificate.certificateData);
const privateKey = encodeBase64(certificate.privateKey);
const command = `
mkdir -p ${certDir};
echo "${certificateData}" | base64 -d > "${crtPath}";
echo "${privateKey}" | base64 -d > "${keyPath}";
echo "${yamlConfig}" > "${configFile}";
mkdir -p ${quote([certDir])};
echo "${certificateData}" | base64 -d > ${quote([crtPath])};
echo "${privateKey}" | base64 -d > ${quote([keyPath])};
echo "${yamlConfig}" > ${quote([configFile])};
`;
await execAsyncRemote(certificate.serverId, command);
+2 -1
View File
@@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab";
import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin";
@@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => {
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const path =
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${quote([compose.appName])} -f ${quote([path])} up -d`;
if (compose.composeType === "docker-compose") {
if (compose.serverId) {
await execAsyncRemote(
+11 -2
View File
@@ -83,8 +83,17 @@ export const findDeploymentById = async (deploymentId: string) => {
const deployment = await db.query.deployments.findFirst({
where: eq(deployments.deploymentId, deploymentId),
with: {
application: true,
compose: true,
application: {
columns: {
applicationId: true,
appName: true,
name: true,
serverId: true,
},
},
compose: {
columns: { composeId: true, appName: true, name: true, serverId: true },
},
schedule: true,
},
});
@@ -0,0 +1,161 @@
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { quote } from "shell-quote";
import { CONTAINER_FILE_SIZE_LIMIT } from "./docker";
export interface DockerVolume {
Name: string;
Driver: string;
Scope: string;
Mountpoint: string;
Labels: string;
Availability?: string;
Group?: string;
Links?: string;
Size?: string;
Status?: string;
}
const VOLUME_MOUNT = "/__volume";
export const getVolumes = async (serverId?: string) => {
try {
const command = "docker volume ls --format '{{json .}}'";
const { stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as DockerVolume);
} catch (error) {
console.error(error);
return [];
}
};
export const getVolumesSize = async (serverId?: string) => {
try {
const command = "docker system df -v --format '{{json .}}'";
const { stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
const diskUsage = JSON.parse(stdout.trim());
return ((diskUsage?.Volumes ?? []) as Partial<DockerVolume>[])
.filter((volume) => volume?.Name)
.map((volume) => ({
name: volume.Name as string,
size: volume.Size ?? null,
}));
} catch (error) {
console.error(error);
return [];
}
};
export const getVolumeConfig = async (
volumeName: string,
serverId?: string,
) => {
const command = `docker volume inspect ${quote([String(volumeName ?? "")])}`;
const { stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
return JSON.parse(stdout.trim())[0];
};
export const removeVolume = async (volumeName: string, serverId?: string) => {
const command = `docker volume rm ${quote([String(volumeName ?? "")])}`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};
export const listVolumeFiles = async (
volumeName: string,
path: string,
serverId?: string,
) => {
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox ls -1Ap ${quote([`${VOLUME_MOUNT}${path}`])}`;
const { stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
return stdout
.split("\n")
.filter(Boolean)
.map((entry) => ({
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
isDirectory: entry.endsWith("/"),
}))
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
};
export const readVolumeFile = async (
volumeName: string,
filePath: string,
serverId?: string,
) => {
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox cat ${quote([`${VOLUME_MOUNT}${filePath}`])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`;
const { stdout, stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr && !stdout) {
throw new Error(stderr);
}
const buffer = Buffer.from(stdout.trim(), "base64");
return {
content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"),
truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT,
};
};
export const writeVolumeFile = async (
volumeName: string,
filePath: string,
content: string,
serverId?: string,
) => {
const base64Content = Buffer.from(content, "utf8").toString("base64");
if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) {
throw new Error("File is too large to save from the editor (max 512KB)");
}
const innerCommand = `printf '%s' ${base64Content} | base64 -d > ${quote([`${VOLUME_MOUNT}${filePath}`])}`;
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox sh -c ${quote([innerCommand])}`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};
export const deleteVolumeFile = async (
volumeName: string,
path: string,
serverId?: string,
) => {
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox rm -rf ${quote([`${VOLUME_MOUNT}${path}`])}`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};
+176 -2
View File
@@ -1,7 +1,9 @@
import {
ExecError,
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { quote } from "shell-quote";
export const getContainers = async (serverId?: string | null) => {
try {
@@ -107,6 +109,9 @@ export const getContainersByAppNameMatch = async (
serverId?: string,
) => {
try {
if (appType === "stack") {
return await getStackTaskContainers(appName, serverId);
}
let result: string[] = [];
const cmd =
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}} | Status: {{.Status}}'";
@@ -165,6 +170,66 @@ export const getContainersByAppNameMatch = async (
return [];
};
const getStackTaskContainers = async (appName: string, serverId?: string) => {
try {
const divider = "__DOKPLOY_DIVIDER__";
const tasksCommand = `docker stack ps ${appName} --no-trunc --filter "desired-state=running" --format 'TASK : {{.ID}} | Name: {{.Name}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
const inspectCommand = `docker stack ps ${appName} -q --no-trunc --filter "desired-state=running" | xargs -r docker inspect --format '{{if .Status.ContainerStatus}}TASK : {{.ID}} | ContainerId: {{.Status.ContainerStatus.ContainerID}}{{end}}' 2>/dev/null`;
const command = `${tasksCommand} && echo "${divider}" && (${inspectCommand} || true)`;
let stdout = "";
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
if (!stdout) return [];
const [tasksSection = "", inspectSection = ""] = stdout.split(divider);
const containerIdByTask = new Map<string, string>();
for (const line of inspectSection.trim().split("\n")) {
const parts = line.split(" | ");
const taskId = parts[0]?.replace("TASK : ", "").trim();
const containerId = parts[1]?.replace("ContainerId: ", "").trim();
if (taskId && containerId) {
containerIdByTask.set(taskId, containerId);
}
}
const containers = [];
for (const line of tasksSection.trim().split("\n")) {
if (!line) continue;
const parts = line.split(" | ");
const taskId = parts[0]?.replace("TASK : ", "").trim() ?? "";
const name = parts[1]?.replace("Name: ", "").trim() ?? "";
const node = parts[2]?.replace("Node: ", "").trim() ?? "";
const currentState = parts[3]
? parts[3].replace("CurrentState: ", "").trim()
: "";
const error = parts[4] ? parts[4].replace("Error: ", "").trim() : "";
const containerId = containerIdByTask.get(taskId) ?? "";
containers.push({
containerId: containerId.slice(0, 12),
name: `${name}.${taskId}`,
node,
state: currentState.split(" ")[0]?.toLowerCase() ?? "No state",
status: error ? `${currentState} (${error})` : currentState,
});
}
return containers;
} catch {}
return [];
};
export const getStackContainersByAppName = async (
appName: string,
serverId?: string,
@@ -174,7 +239,6 @@ export const getStackContainersByAppName = async (
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);
@@ -519,7 +583,7 @@ export const getSwarmNodes = async (serverId?: string) => {
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
try {
const command = `docker node inspect ${nodeId} --format '{{json .}}'`;
const command = `docker node inspect ${quote([nodeId])} --format '{{json .}}'`;
let stdout = "";
let stderr = "";
if (serverId) {
@@ -697,3 +761,113 @@ export const uploadFileToContainer = async (
);
}
};
export const CONTAINER_FILE_SIZE_LIMIT = 512 * 1024;
const NO_SHELL_UTILITIES_ERROR =
"This container image has no shell utilities and its filesystem is not accessible from the Dokploy host.";
const isMissingBinaryError = (error: unknown) =>
error instanceof ExecError &&
(error.exitCode === 126 ||
error.exitCode === 127 ||
!!error.stderr?.includes("executable file not found"));
export const listContainerFiles = async (
containerId: string,
path: string,
serverId?: string,
) => {
const command = `docker exec ${quote([containerId])} ls -1Ap ${quote([path])}`;
let stdout: string;
try {
({ stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command));
} catch (error) {
if (isMissingBinaryError(error)) {
throw new Error(NO_SHELL_UTILITIES_ERROR);
}
throw error;
}
return stdout
.split("\n")
.filter(Boolean)
.map((entry) => ({
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
isDirectory: entry.endsWith("/"),
}))
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
};
export const readContainerFile = async (
containerId: string,
filePath: string,
serverId?: string,
) => {
const command = `docker exec ${quote([containerId])} cat ${quote([filePath])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`;
const { stdout, stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr && !stdout) {
if (stderr.includes("executable file not found")) {
throw new Error(NO_SHELL_UTILITIES_ERROR);
}
throw new Error(stderr);
}
const buffer = Buffer.from(stdout.trim(), "base64");
return {
content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"),
truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT,
};
};
export const writeContainerFile = async (
containerId: string,
filePath: string,
content: string,
serverId?: string,
) => {
const base64Content = Buffer.from(content, "utf8").toString("base64");
if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) {
throw new Error("File is too large to save from the editor (max 512KB)");
}
const tempPath = `/tmp/dokploy-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const command = `printf '%s' ${quote([base64Content])} | base64 -d > ${quote([tempPath])} && docker cp ${quote([tempPath])} ${quote([`${containerId}:${filePath}`])}; status=$?; rm -f ${quote([tempPath])}; exit $status`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};
export const deleteContainerFile = async (
containerId: string,
path: string,
serverId?: string,
) => {
const command = `docker exec ${quote([containerId])} rm -rf ${quote([path])}`;
try {
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
} catch (error) {
if (isMissingBinaryError(error)) {
throw new Error(NO_SHELL_UTILITIES_ERROR);
}
throw error;
}
};
+9 -3
View File
@@ -80,7 +80,9 @@ export const findDomainById = async (domainId: string) => {
const domain = await db.query.domains.findFirst({
where: eq(domains.domainId, domainId),
with: {
application: true,
application: {
columns: { applicationId: true, appName: true, name: true },
},
},
});
if (!domain) {
@@ -96,7 +98,9 @@ export const findDomainsByApplicationId = async (applicationId: string) => {
const domainsArray = await db.query.domains.findMany({
where: eq(domains.applicationId, applicationId),
with: {
application: true,
application: {
columns: { applicationId: true, appName: true, name: true },
},
},
});
@@ -107,7 +111,9 @@ export const findDomainsByComposeId = async (composeId: string) => {
const domainsArray = await db.query.domains.findMany({
where: eq(domains.composeId, composeId),
with: {
compose: true,
compose: {
columns: { composeId: true, appName: true, name: true },
},
},
});
+47 -8
View File
@@ -168,6 +168,7 @@ export const findEnvironmentById = async (environmentId: string) => {
composeStatus: true,
description: true,
serverId: true,
icon: true,
},
},
libsql: {
@@ -201,18 +202,56 @@ export const findEnvironmentById = async (environmentId: string) => {
};
export const findEnvironmentsByProjectId = async (projectId: string) => {
const serviceColumns = {
name: true,
description: true,
appName: true,
createdAt: true,
serverId: true,
applicationStatus: true,
} as const;
const projectEnvironments = await db.query.environments.findMany({
where: eq(environments.projectId, projectId),
orderBy: asc(environments.createdAt),
with: {
applications: true,
mariadb: true,
mongo: true,
mysql: true,
postgres: true,
redis: true,
compose: true,
libsql: true,
applications: {
columns: { ...serviceColumns, applicationId: true, icon: true },
with: { server: { columns: { name: true } } },
},
mariadb: {
columns: { ...serviceColumns, mariadbId: true },
with: { server: { columns: { name: true } } },
},
mongo: {
columns: { ...serviceColumns, mongoId: true },
with: { server: { columns: { name: true } } },
},
mysql: {
columns: { ...serviceColumns, mysqlId: true },
with: { server: { columns: { name: true } } },
},
postgres: {
columns: { ...serviceColumns, postgresId: true },
with: { server: { columns: { name: true } } },
},
redis: {
columns: { ...serviceColumns, redisId: true },
with: { server: { columns: { name: true } } },
},
compose: {
columns: {
...serviceColumns,
composeId: true,
composeStatus: true,
icon: true,
},
with: { server: { columns: { name: true } } },
},
libsql: {
columns: { ...serviceColumns, libsqlId: true },
with: { server: { columns: { name: true } } },
},
project: true,
},
columns: {
@@ -119,3 +119,30 @@ export const getAccessibleGitProviderIds = async (session: {
}
return result;
};
/**
* Authorizes read access to a specific git provider for the current session.
* Throws if the provider belongs to a different organization (cross-org IDOR)
* or if the caller is not entitled to it within the active organization.
* Must be called before returning any git-provider record that carries secrets
* (OAuth tokens, app private keys, webhook secrets).
*/
export const assertGitProviderAccess = async (
session: { userId: string; activeOrganizationId: string },
provider: { gitProviderId: string; organizationId: string },
) => {
if (provider.organizationId !== session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Git provider not found",
});
}
const accessibleIds = await getAccessibleGitProviderIds(session);
if (!accessibleIds.has(provider.gitProviderId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this git provider",
});
}
};
+2 -1
View File
@@ -11,6 +11,7 @@ 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 { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -140,7 +141,7 @@ export const deployLibsql = async (
if (libsql.serverId) {
await execAsyncRemote(
libsql.serverId,
`docker pull ${libsql.dockerImage}`,
`docker pull ${quote([libsql.dockerImage])}`,
onData,
);
} else {
+2 -1
View File
@@ -11,6 +11,7 @@ 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 { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -145,7 +146,7 @@ export const deployMariadb = async (
if (mariadb.serverId) {
await execAsyncRemote(
mariadb.serverId,
`docker pull ${mariadb.dockerImage}`,
`docker pull ${quote([mariadb.dockerImage])}`,
onData,
);
} else {
+2 -1
View File
@@ -12,6 +12,7 @@ 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 { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -160,7 +161,7 @@ export const deployMongo = async (
if (mongo.serverId) {
await execAsyncRemote(
mongo.serverId,
`docker pull ${mongo.dockerImage}`,
`docker pull ${quote([mongo.dockerImage])}`,
onData,
);
} else {
+23 -74
View File
@@ -18,6 +18,7 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, type SQL, sql } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
export type Mount = typeof mounts.$inferSelect;
@@ -106,81 +107,29 @@ export const createFileMount = async (mountId: string) => {
};
export const findMountById = async (mountId: string) => {
const serviceWith = {
columns: { serverId: true, appName: true },
with: {
environment: {
columns: {},
with: {
project: { columns: { organizationId: true } },
},
},
},
} as const;
const mount = await db.query.mounts.findFirst({
where: eq(mounts.mountId, mountId),
with: {
application: {
with: {
environment: {
with: {
project: true,
},
},
},
},
compose: {
with: {
environment: {
with: {
project: true,
},
},
},
},
libsql: {
with: {
environment: {
with: {
project: true,
},
},
},
},
mariadb: {
with: {
environment: {
with: {
project: true,
},
},
},
},
mongo: {
with: {
environment: {
with: {
project: true,
},
},
},
},
mysql: {
with: {
environment: {
with: {
project: true,
},
},
},
},
postgres: {
with: {
environment: {
with: {
project: true,
},
},
},
},
redis: {
with: {
environment: {
with: {
project: true,
},
},
},
},
application: serviceWith,
compose: serviceWith,
libsql: serviceWith,
mariadb: serviceWith,
mongo: serviceWith,
mysql: serviceWith,
postgres: serviceWith,
redis: serviceWith,
},
});
if (!mount) {
@@ -317,7 +266,7 @@ export const updateFileMount = async (mountId: string) => {
try {
const serverId = await getServerId(mount);
const encodedContent = encodeBase64(mount.content || "");
const command = `echo "${encodedContent}" | base64 -d > ${fullPath}`;
const command = `echo "${encodedContent}" | base64 -d > ${quote([fullPath])}`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
@@ -337,7 +286,7 @@ export const deleteFileMount = async (mountId: string) => {
try {
const serverId = await getServerId(mount);
if (serverId) {
const command = `rm -rf ${fullPath}`;
const command = `rm -rf ${quote([fullPath])}`;
await execAsyncRemote(serverId, command);
} else {
await removeFileOrDirectory(fullPath);
+2 -1
View File
@@ -11,6 +11,7 @@ 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 { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -143,7 +144,7 @@ export const deployMySql = async (
if (mysql.serverId) {
await execAsyncRemote(
mysql.serverId,
`docker pull ${mysql.dockerImage}`,
`docker pull ${quote([mysql.dockerImage])}`,
onData,
);
} else {
+369
View File
@@ -0,0 +1,369 @@
import { db } from "@dokploy/server/db";
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, eq, inArray, isNull } from "drizzle-orm";
import type { z } from "zod";
import { IS_CLOUD } from "../constants";
import type { ApplicationNested } from "../utils/builders";
import { getRemoteDocker } from "../utils/servers/remote-docker";
const RESERVED_NETWORKS = [
"bridge",
"host",
"none",
"ingress",
"docker_gwbridge",
"dokploy-network",
];
type DockerNetworkInfo = {
Name: string;
Driver: string;
Internal?: boolean;
Attachable?: boolean;
Ingress?: boolean;
EnableIPv4?: boolean;
EnableIPv6?: boolean;
Options?: Record<string, string> | null;
IPAM?: {
Driver?: string;
Config?: Array<{
Subnet?: string;
Gateway?: string;
IPRange?: string;
}> | null;
};
};
const parseMtu = (value: string | undefined) => {
const mtu = Number.parseInt(value ?? "", 10);
return Number.isNaN(mtu) ? null : mtu;
};
const isImportableDockerNetwork = (dockerNetwork: DockerNetworkInfo) =>
!RESERVED_NETWORKS.includes(dockerNetwork.Name) &&
!dockerNetwork.Ingress &&
(dockerNetwork.Driver === "bridge" || dockerNetwork.Driver === "overlay");
const mapDockerNetworkToRow = (
dockerNetwork: DockerNetworkInfo,
organizationId: string,
serverId: string | null,
) => ({
name: dockerNetwork.Name,
driver: dockerNetwork.Driver as "bridge" | "overlay",
internal: dockerNetwork.Internal ?? false,
attachable: dockerNetwork.Attachable ?? false,
// Older daemons don't report EnableIPv4; IPv4 is always on there
enableIPv4: dockerNetwork.EnableIPv4 ?? true,
enableIPv6: dockerNetwork.EnableIPv6 ?? false,
mtu: parseMtu(dockerNetwork.Options?.["com.docker.network.driver.mtu"]),
ipam: {
driver: dockerNetwork.IPAM?.Driver,
config: (dockerNetwork.IPAM?.Config ?? []).map((c) => ({
subnet: c.Subnet,
gateway: c.Gateway,
ipRange: c.IPRange,
})),
},
organizationId,
serverId,
});
const findNetworksByServer = async (
organizationId: string,
serverId: string | null,
) => {
return await db.query.network.findMany({
where: and(
eq(network.organizationId, organizationId),
serverId ? eq(network.serverId, serverId) : isNull(network.serverId),
),
});
};
export const findNetworksToSync = async (
organizationId: string,
serverId: string | null,
) => {
if (IS_CLOUD && !serverId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Server is required",
});
}
const docker = await getRemoteDocker(serverId);
let dockerNetworks: DockerNetworkInfo[] = [];
try {
dockerNetworks = (await docker.listNetworks()) as DockerNetworkInfo[];
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Failed to list Docker networks",
cause: error,
});
}
const existing = await findNetworksByServer(organizationId, serverId);
const existingNames = new Set(existing.map((row) => row.name));
const dockerNames = new Set(dockerNetworks.map((d) => d.Name));
const importable = dockerNetworks
.filter(
(dockerNetwork) =>
isImportableDockerNetwork(dockerNetwork) &&
!existingNames.has(dockerNetwork.Name),
)
.map((dockerNetwork) => ({
name: dockerNetwork.Name,
driver: dockerNetwork.Driver,
internal: dockerNetwork.Internal ?? false,
attachable: dockerNetwork.Attachable ?? false,
subnets: (dockerNetwork.IPAM?.Config ?? [])
.map((c) => c.Subnet)
.filter((s): s is string => !!s),
}));
// Rows in Dokploy whose network no longer exists in Docker
const missing = existing
.filter((row) => !dockerNames.has(row.name))
.map((row) => ({ networkId: row.networkId, name: row.name }));
return { importable, missing };
};
export const importDockerNetworks = async (
organizationId: string,
serverId: string | null,
names: string[],
) => {
if (IS_CLOUD && !serverId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Server is required",
});
}
const docker = await getRemoteDocker(serverId);
const existing = await findNetworksByServer(organizationId, serverId);
const existingNames = new Set(existing.map((row) => row.name));
const imported: string[] = [];
const errors: { name: string; error: string }[] = [];
for (const name of names) {
if (existingNames.has(name)) {
errors.push({ name, error: "Already imported" });
continue;
}
try {
const info = (await docker
.getNetwork(name)
.inspect()) as DockerNetworkInfo;
if (!isImportableDockerNetwork(info)) {
errors.push({ name, error: "Network is reserved or not supported" });
continue;
}
await db
.insert(network)
.values(mapDockerNetworkToRow(info, organizationId, serverId));
imported.push(name);
} catch (error) {
errors.push({
name,
error:
error instanceof Error ? error.message : "Failed to inspect network",
});
}
}
return { imported, errors };
};
export const findNetworkById = async (networkId: string) => {
const [row] = await db
.select()
.from(network)
.where(eq(network.networkId, networkId))
.limit(1);
if (!row) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return row;
};
export const createNetwork = async (
input: z.infer<typeof apiCreateNetwork>,
organizationId: string,
) => {
if (IS_CLOUD) {
if (!input.serverId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Server is required",
});
}
}
const created = await db.transaction(async (tx) => {
const [row] = await tx
.insert(network)
.values({
...input,
organizationId,
})
.returning();
if (!row) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create network",
});
}
await createDockerNetworkFromRow(row);
return row;
});
return created;
};
const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
const ipam = row.ipam ?? {};
const ipamConfig = (ipam.config ?? [])
.map((c) => {
const entry: Record<string, string> = {};
if (c.subnet) entry.Subnet = c.subnet;
if (c.gateway) entry.Gateway = c.gateway;
if (c.ipRange) entry.IPRange = c.ipRange;
return entry;
})
.filter((e) => Object.keys(e).length > 0);
const docker = await getRemoteDocker(row.serverId ?? null);
try {
await docker.createNetwork({
Name: row.name,
Driver: row.driver,
CheckDuplicate: true,
Internal: row.internal,
Attachable: row.attachable,
// EnableIPv4 is missing from dockerode's types but supported by
// the daemon (API >= 1.47); the body is sent as-is
EnableIPv4: row.enableIPv4,
EnableIPv6: row.enableIPv6,
Options: row.mtu
? { "com.docker.network.driver.mtu": String(row.mtu) }
: undefined,
IPAM: {
Driver: ipam.driver || "default",
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
},
} as Parameters<typeof docker.createNetwork>[0]);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Failed to create Docker network",
cause: error,
});
}
};
export const recreateNetwork = async (networkId: string) => {
const row = await findNetworkById(networkId);
await createDockerNetworkFromRow(row);
return row;
};
export const resolveServiceNetworks = async (
application: Partial<ApplicationNested>,
) => {
if (application.networkSwarm) {
return application.networkSwarm;
}
const { networkIds, detachDokployNetwork } = application;
const rows =
networkIds && networkIds.length > 0
? await db.query.network.findMany({
where: and(
inArray(network.networkId, networkIds),
eq(network.driver, "overlay"),
),
columns: { name: true },
})
: [];
const networks = detachDokployNetwork ? [] : ["dokploy-network"];
for (const row of rows) {
networks.push(row.name);
}
return networks.map((name) => ({ Target: name }));
};
export const inspectNetwork = async (networkId: string) => {
const row = await findNetworkById(networkId);
const docker = await getRemoteDocker(row.serverId ?? null);
try {
return await docker.getNetwork(row.name).inspect();
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Failed to inspect Docker network",
cause: error,
});
}
};
export const removeNetwork = async (networkId: string) => {
const row = await findNetworkById(networkId);
const docker = await getRemoteDocker(row.serverId ?? null);
try {
await docker.getNetwork(row.name).remove();
} catch (error) {
const statusCode = (error as { statusCode?: number })?.statusCode;
if (statusCode !== 404) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Failed to remove Docker network",
cause: error,
});
}
}
const [deleted] = await db
.delete(network)
.where(eq(network.networkId, networkId))
.returning();
if (!deleted) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return deleted;
};
+3 -2
View File
@@ -1,6 +1,7 @@
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { cloneBitbucketRepository } from "../utils/providers/bitbucket";
import { cloneGitRepository } from "../utils/providers/git";
@@ -85,7 +86,7 @@ export const readPatchRepoDirectory = async (
serverId?: string | null,
): Promise<DirectoryEntry[]> => {
// Use git ls-tree to get tracked files only
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
const command = `cd ${quote([repoPath])} && git ls-tree -r --name-only HEAD`;
let stdout: string;
try {
@@ -168,7 +169,7 @@ export const readPatchRepoFile = async (
const repoPath = join(PATCH_REPOS_PATH, type, application.appName);
const fullPath = join(repoPath, filePath);
const command = `cat "${fullPath}"`;
const command = `cat ${quote([fullPath])}`;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
+2 -6
View File
@@ -30,12 +30,8 @@ export const finPortById = async (portId: string) => {
where: eq(ports.portId, portId),
with: {
application: {
with: {
environment: {
with: {
project: true,
},
},
columns: {
applicationId: true,
},
},
},
+2 -1
View File
@@ -11,6 +11,7 @@ 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 { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -155,7 +156,7 @@ export const deployPostgres = async (
if (postgres.serverId) {
await execAsyncRemote(
postgres.serverId,
`docker pull ${postgres.dockerImage}`,
`docker pull ${quote([postgres.dockerImage])}`,
onData,
);
} else {
@@ -17,7 +17,7 @@ import { manageDomain } from "../utils/traefik/domain";
import { findApplicationById } from "./application";
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
import { createDomain } from "./domain";
import { type Github, getIssueComment } from "./github";
import { findGithubById, getIssueComment } from "./github";
import { getWebServerSettings } from "./web-server-settings";
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
@@ -142,7 +142,17 @@ export const createPreviewDeployment = async (
org?.ownerId || "",
);
const octokit = authGithub(application?.github as Github);
if (!application.githubId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Github Account not configured correctly",
});
}
// `findApplicationById` redacts `githubPrivateKey` from the `github`
// relation, so the provider must be refetched to authenticate.
const githubProvider = await findGithubById(application.githubId);
const octokit = authGithub(githubProvider);
const runningComment = getIssueComment(
application.name,
+71 -8
View File
@@ -48,19 +48,60 @@ export const createProject = async (
};
export const findProjectById = async (projectId: string) => {
const serviceColumns = {
name: true,
description: true,
appName: true,
createdAt: true,
serverId: true,
applicationStatus: true,
} as const;
const project = await db.query.projects.findFirst({
where: eq(projects.projectId, projectId),
with: {
environments: {
with: {
applications: true,
compose: true,
libsql: true,
mariadb: true,
mongo: true,
mysql: true,
postgres: true,
redis: true,
applications: {
columns: {
...serviceColumns,
applicationId: true,
icon: true,
},
with: { server: { columns: { name: true } } },
},
compose: {
columns: {
...serviceColumns,
composeId: true,
composeStatus: true,
},
with: { server: { columns: { name: true } } },
},
libsql: {
columns: { ...serviceColumns, libsqlId: true },
with: { server: { columns: { name: true } } },
},
mariadb: {
columns: { ...serviceColumns, mariadbId: true },
with: { server: { columns: { name: true } } },
},
mongo: {
columns: { ...serviceColumns, mongoId: true },
with: { server: { columns: { name: true } } },
},
mysql: {
columns: { ...serviceColumns, mysqlId: true },
with: { server: { columns: { name: true } } },
},
postgres: {
columns: { ...serviceColumns, postgresId: true },
with: { server: { columns: { name: true } } },
},
redis: {
columns: { ...serviceColumns, redisId: true },
with: { server: { columns: { name: true } } },
},
},
},
projectTags: {
@@ -107,27 +148,49 @@ export const updateProjectById = async (
export const validUniqueServerAppName = async (appName: string) => {
const query = await db.query.environments.findMany({
columns: { environmentId: true },
with: {
applications: {
where: eq(applications.appName, appName),
columns: {
appName: true,
},
},
libsql: {
where: eq(libsql.appName, appName),
columns: {
appName: true,
},
},
mariadb: {
where: eq(mariadb.appName, appName),
columns: {
appName: true,
},
},
mongo: {
where: eq(mongo.appName, appName),
columns: {
appName: true,
},
},
mysql: {
where: eq(mysql.appName, appName),
columns: {
appName: true,
},
},
postgres: {
where: eq(postgres.appName, appName),
columns: {
appName: true,
},
},
redis: {
where: eq(redis.appName, appName),
columns: {
appName: true,
},
},
},
});
+2 -1
View File
@@ -10,6 +10,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -110,7 +111,7 @@ export const deployRedis = async (
if (redis.serverId) {
await execAsyncRemote(
redis.serverId,
`docker pull ${redis.dockerImage}`,
`docker pull ${quote([redis.dockerImage])}`,
onData,
);
} else {
+17 -20
View File
@@ -17,9 +17,12 @@ import {
} from "../utils/docker/utils";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { getRemoteDocker } from "../utils/servers/remote-docker";
import { withResolvedVaultRefs } from "../utils/vault";
import { type Application, findApplicationById } from "./application";
import { findDeploymentById } from "./deployment";
import type { Environment } from "./environment";
import type { Mount } from "./mount";
import { resolveServiceNetworks } from "./network";
import type { Port } from "./port";
import type { Project } from "./project";
import {
@@ -105,19 +108,7 @@ export const findRollbackById = async (rollbackId: string) => {
const result = await db.query.rollbacks.findFirst({
where: eq(rollbacks.rollbackId, rollbackId),
with: {
deployment: {
with: {
application: {
with: {
environment: {
with: {
project: true,
},
},
},
},
},
},
deployment: true,
},
});
@@ -213,7 +204,7 @@ const rollbackApplication = async (
image: string,
serverId?: string | null,
fullContext?: Application & {
environment: {
environment: Environment & {
project: Project;
};
mounts: Mount[];
@@ -225,7 +216,9 @@ const rollbackApplication = async (
throw new Error("Full context is required for rollback");
}
const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
const resolvedContext = await withResolvedVaultRefs(fullContext);
const rollbackRegistry = resolvedContext.rollbackRegistry ?? undefined;
// Ensure Docker daemon is authenticated with the rollback registry
// before updating the swarm service. The authconfig in CreateServiceOptions
@@ -246,7 +239,7 @@ const rollbackApplication = async (
cpuReservation,
command,
ports,
} = fullContext;
} = resolvedContext;
const resources = calculateResources({
memoryLimit,
@@ -257,6 +250,10 @@ const rollbackApplication = async (
const volumesMount = generateVolumeMounts(mounts);
const resolvedNetworks = await resolveServiceNetworks(
resolvedContext as Parameters<typeof resolveServiceNetworks>[0],
);
const {
HealthCheck,
RestartPolicy,
@@ -265,16 +262,16 @@ const rollbackApplication = async (
Mode,
RollbackConfig,
UpdateConfig,
Networks,
Ulimits,
} = generateConfigContainer(
fullContext as Parameters<typeof generateConfigContainer>[0],
resolvedContext as Parameters<typeof generateConfigContainer>[0],
);
const bindsMount = generateBindMounts(mounts);
const envVariables = prepareEnvironmentVariables(
env,
fullContext.environment.project.env,
resolvedContext.environment.project.env,
resolvedContext.environment.env,
);
let rollbackImage = image;
@@ -304,7 +301,7 @@ const rollbackApplication = async (
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
+48 -1
View File
@@ -2,7 +2,7 @@ import path from "node:path";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { paths } from "../constants";
import { IS_CLOUD, paths } from "../constants";
import { db } from "../db";
import type {
createScheduleSchema,
@@ -11,9 +11,51 @@ import type {
import { type Schedule, schedules } from "../db/schema/schedule";
import { encodeBase64 } from "../utils/docker/utils";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { findMemberByUserId } from "./permission";
import { findServerById } from "./server";
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
// Host-level schedules (server / dokploy-server) run their script as root on the
// host and must stay restricted to owners/admins, regardless of whether the
// request is also tied to a service. Attaching an accessible applicationId must
// not downgrade this to a service-access check.
export const assertHostScheduleAccess = async (
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
scheduleType: Schedule["scheduleType"] | null | undefined,
serverId: string | null | undefined,
) => {
if (scheduleType !== "server" && scheduleType !== "dokploy-server") return;
if (scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Host-level schedules are not available in the cloud version.",
});
}
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners and admins can manage server-level schedules.",
});
}
if (scheduleType === "server" && serverId) {
const targetServer = await findServerById(serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
};
export const createSchedule = async (
input: z.infer<typeof createScheduleSchema>,
) => {
@@ -39,6 +81,11 @@ export const findScheduleById = async (scheduleId: string) => {
where: eq(schedules.scheduleId, scheduleId),
with: {
application: {
columns: {
applicationId: true,
appName: true,
serverId: true,
},
with: {
environment: {
with: {
+30 -8
View File
@@ -53,6 +53,27 @@ export const findServerById = async (serverId: string) => {
return currentServer;
};
/**
* Removes the SSH private key material from a server record before it is sent
* to a client. `findServerById` eagerly loads the `sshKey` relation (needed for
* server-side SSH operations), but the private key must never leave the server:
* no client feature consumes it, and returning it exposed it to any member with
* only `server:read`. Server-side callers keep using `findServerById` directly.
*/
export const redactServerSshKey = <
T extends { sshKey?: { privateKey: string } | null },
>(
serverRecord: T,
): T => {
if (!serverRecord.sshKey) {
return serverRecord;
}
return {
...serverRecord,
sshKey: { ...serverRecord.sshKey, privateKey: "" },
};
};
export const findServersByUserId = async (userId: string) => {
const orgs = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),
@@ -79,15 +100,16 @@ export const deleteServer = async (serverId: string) => {
export const haveActiveServices = async (serverId: string) => {
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, serverId),
columns: { serverId: true },
with: {
applications: true,
compose: true,
libsql: true,
mariadb: true,
mongo: true,
mysql: true,
postgres: true,
redis: true,
applications: { columns: { applicationId: true } },
compose: { columns: { composeId: true } },
libsql: { columns: { libsqlId: true } },
mariadb: { columns: { mariadbId: true } },
mongo: { columns: { mongoId: true } },
mysql: { columns: { mysqlId: true } },
postgres: { columns: { postgresId: true } },
redis: { columns: { redisId: true } },
},
});
+18 -2
View File
@@ -4,11 +4,12 @@ import {
apikey,
invitation,
member,
passkey,
user,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "../lib/auth";
export type User = typeof user.$inferSelect;
@@ -396,6 +397,21 @@ export const findMemberById = async (
return result;
};
export const findPasskeysByUserId = async (userId: string) => {
return db.query.passkey.findMany({
where: eq(passkey.userId, userId),
columns: {
id: true,
name: true,
deviceType: true,
backedUp: true,
createdAt: true,
aaguid: true,
},
orderBy: [desc(passkey.createdAt)],
});
};
export const createOrganizationUserWithCredentials = async ({
organizationId,
email,
@@ -430,7 +446,7 @@ export const createOrganizationUserWithCredentials = async ({
.insert(user)
.values({
email: normalizedEmail,
emailVerified: false,
emailVerified: true,
updatedAt: now,
})
.returning({
@@ -0,0 +1,254 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateVaultProvider,
projects,
type VaultProviderAssignment,
type VaultProviderConfig,
vaultProvider,
} from "@dokploy/server/db/schema";
import { getVaultClient } from "@dokploy/server/utils/vault";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type VaultProvider = typeof vaultProvider.$inferSelect;
export const VAULT_SECRET_MASK = "********";
const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
{
hashicorp: ["token"],
infisical: ["clientSecret"],
aws: ["secretAccessKey"],
doppler: ["serviceToken"],
azure: ["clientSecret"],
};
export const maskVaultProviderConfig = (
config: VaultProviderConfig,
): VaultProviderConfig => {
const masked: Record<string, unknown> = { ...config };
for (const field of SENSITIVE_FIELDS[config.providerType]) {
if (masked[field]) {
masked[field] = VAULT_SECRET_MASK;
}
}
return masked as VaultProviderConfig;
};
export const mergeVaultProviderConfig = (
incoming: VaultProviderConfig,
existing: VaultProviderConfig,
): VaultProviderConfig => {
const merged: Record<string, unknown> = { ...incoming };
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
if (merged[field] === VAULT_SECRET_MASK) {
if (incoming.providerType !== existing.providerType) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Credentials must be re-entered when changing the provider type",
});
}
merged[field] = (existing as Record<string, unknown>)[field];
}
}
return merged as VaultProviderConfig;
};
const isUniqueNameViolation = (error: unknown) =>
error instanceof Error &&
error.message.includes("vault_provider_org_name_idx");
const validateAssignments = async (
assignments: VaultProviderAssignment[],
organizationId: string,
) => {
const orgProjects = await db.query.projects.findMany({
where: eq(projects.organizationId, organizationId),
with: { environments: true },
});
for (const assignment of assignments) {
const project = orgProjects.find(
(p) => p.projectId === assignment.projectId,
);
if (!project) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Assignment references a project outside this organization",
});
}
const environmentIds = new Set(
project.environments.map((e) => e.environmentId),
);
for (const environmentId of assignment.environmentIds) {
if (!environmentIds.has(environmentId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Assignment references an environment outside the selected project",
});
}
}
}
};
export const createVaultProvider = async (
input: z.infer<typeof apiCreateVaultProvider>,
organizationId: string,
) => {
await validateAssignments(input.assignments, organizationId);
try {
const newProvider = await db
.insert(vaultProvider)
.values({
name: input.name,
providerType: input.config.providerType,
config: input.config,
assignments: input.assignments,
organizationId,
})
.returning()
.then((value) => value[0]);
if (!newProvider) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the vault provider",
});
}
return newProvider;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A vault provider named "${input.name}" already exists in this organization`,
});
}
throw error;
}
};
export const findVaultProviderById = async (vaultProviderId: string) => {
const provider = await db.query.vaultProvider.findFirst({
where: eq(vaultProvider.vaultProviderId, vaultProviderId),
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Vault provider not found",
});
}
return provider;
};
export const findVaultProviderInOrganization = async (
vaultProviderId: string,
organizationId: string,
) => {
const provider = await findVaultProviderById(vaultProviderId);
if (provider.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this vault provider",
});
}
return provider;
};
export const findVaultProvidersByOrganizationId = async (
organizationId: string,
) => {
return await db.query.vaultProvider.findMany({
where: eq(vaultProvider.organizationId, organizationId),
orderBy: (providers, { asc }) => [asc(providers.name)],
});
};
export const updateVaultProvider = async (
vaultProviderId: string,
name: string,
config: VaultProviderConfig,
assignments: VaultProviderAssignment[],
) => {
const existing = await findVaultProviderById(vaultProviderId);
const mergedConfig = mergeVaultProviderConfig(config, existing.config);
await validateAssignments(assignments, existing.organizationId);
try {
const updated = await db
.update(vaultProvider)
.set({
name,
providerType: mergedConfig.providerType,
config: mergedConfig,
assignments,
})
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
.returning()
.then((res) => res[0]);
if (!updated) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating the vault provider",
});
}
return updated;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A vault provider named "${name}" already exists in this organization`,
});
}
throw error;
}
};
export const removeVaultProvider = async (vaultProviderId: string) => {
const removed = await db
.delete(vaultProvider)
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
.returning()
.then((res) => res[0]);
if (!removed) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Vault provider not found",
});
}
return removed;
};
export const testVaultProviderConnection = async (
config: VaultProviderConfig,
) => {
const client = getVaultClient(config.providerType);
try {
await client.testConnection(config);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error connecting to the vault provider",
});
}
};
export const listVaultProviderSecretNames = async (
config: VaultProviderConfig,
) => {
const client = getVaultClient(config.providerType);
if (!client.listSecretNames) {
return [];
}
try {
return await client.listSecretNames(config);
} catch {
return [];
}
};
@@ -13,6 +13,11 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
where: eq(volumeBackups.volumeBackupId, volumeBackupId),
with: {
application: {
columns: {
applicationId: true,
appName: true,
serverId: true,
},
with: {
environment: {
with: {
-66
View File
@@ -1,66 +0,0 @@
import type { CreateServiceOptions } from "dockerode";
import { docker } from "../constants";
import { pullImage } from "../utils/docker/utils";
export const initializeRedis = async () => {
const imageName = "redis:8";
const containerName = "dokploy-redis";
const settings: CreateServiceOptions = {
Name: containerName,
TaskTemplate: {
ContainerSpec: {
Image: imageName,
Mounts: [
{
Type: "volume",
Source: "dokploy-redis",
Target: "/data",
},
],
},
Networks: [{ Target: "dokploy-network" }],
Placement: {
Constraints: ["node.role==manager"],
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
...(process.env.NODE_ENV === "development" && {
EndpointSpec: {
Ports: [
{
TargetPort: 6379,
PublishedPort: 6379,
Protocol: "tcp",
PublishMode: "host",
},
],
},
}),
};
try {
await pullImage(imageName);
const service = docker.getService(containerName);
const inspect = await service.inspect();
await service.update({
version: Number.parseInt(inspect.Version.Index),
...settings,
});
console.log("Redis Started ✅");
} catch (_) {
try {
await docker.createService(settings);
} catch (error: any) {
if (error?.statusCode !== 409) {
throw error;
}
console.log("Redis service already exists, continuing...");
}
console.log("Redis Not Found: Starting ✅");
}
};
+1 -1
View File
@@ -482,7 +482,7 @@ const installUtilities = () => `
case "$OS_TYPE" in
arch)
$SUDO_CMD pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true
$SUDO_CMD pacman -Sy --noconfirm --needed unzip curl wget git git-lfs jq openssl >/dev/null || true
;;
alpine)
$SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
+43
View File
@@ -74,6 +74,49 @@ export async function fetchTemplatesList(
}));
}
const LOGO_MIME_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
svg: "image/svg+xml",
webp: "image/webp",
gif: "image/gif",
};
export async function fetchTemplateLogo(
templateId: string,
baseUrl = "https://templates.dokploy.com",
): Promise<string | null> {
try {
const templates = await fetchTemplatesList(baseUrl);
const template = templates.find((t) => t.id === templateId);
if (!template?.logo) return null;
const response = await fetch(
`${baseUrl}/blueprints/${templateId}/${template.logo}`,
{ signal: AbortSignal.timeout(10000) },
);
if (!response.ok) return null;
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) return null;
const contentType = response.headers.get("content-type")?.split(";")[0];
const extension = template.logo.split(".").pop()?.toLowerCase() ?? "";
const mimeType = contentType?.startsWith("image/")
? contentType
: LOGO_MIME_TYPES[extension];
if (!mimeType) return null;
const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
if (dataUrl.length > 2 * 1024 * 1024) return null;
return dataUrl;
} catch {
return null;
}
}
/**
* Fetches a specific template's files
*/
+3 -2
View File
@@ -35,7 +35,8 @@ export const generateRandomDomain = ({
projectName,
}: Schema): string => {
const hash = randomBytes(3).toString("hex");
const slugIp = serverIp.replaceAll(".", "-").replaceAll(":", "-");
const effectiveIp = serverIp || "127.0.0.1";
const slugIp = effectiveIp.replaceAll(".", "-").replaceAll(":", "-");
// Domain labels have a max length of 63 characters
// Reserve space for: hash (6) + separators (1-2) + ip section + dot + sslip.io (8)
@@ -46,7 +47,7 @@ export const generateRandomDomain = ({
? projectName.substring(0, maxProjectNameLength)
: projectName;
return `${truncatedProjectName}-${hash}${slugIp === "" ? "" : `-${slugIp}`}.sslip.io`;
return `${truncatedProjectName}-${hash}-${slugIp}.sslip.io`;
};
export const generateHash = (length = 8): string => {
@@ -120,7 +120,7 @@ export function parseRawConfig(
if (search) {
parsedLogs = parsedLogs.filter((log) =>
log.RequestHost.toLowerCase().includes(search.toLowerCase()),
(log.RequestHost ?? "").toLowerCase().includes(search.toLowerCase()),
);
}
+2 -3
View File
@@ -37,11 +37,10 @@ export const runComposeBackup = async (
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
rcloneFlags,
rcloneDestination,
deployment.logPath,
);
if (compose.serverId) {
+2 -4
View File
@@ -36,12 +36,10 @@ export const runLibsqlBackup = async (
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
rcloneFlags,
rcloneDestination,
deployment.logPath,
);
if (libsql.serverId) {
+2 -3
View File
@@ -35,11 +35,10 @@ export const runMariadbBackup = async (
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
rcloneFlags,
rcloneDestination,
deployment.logPath,
);
if (mariadb.serverId) {
+2 -3
View File
@@ -32,11 +32,10 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
rcloneFlags,
rcloneDestination,
deployment.logPath,
);
+2 -4
View File
@@ -33,12 +33,10 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
rcloneFlags,
rcloneDestination,
deployment.logPath,
);
@@ -36,12 +36,10 @@ export const runPostgresBackup = async (
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
rcloneFlags,
rcloneDestination,
deployment.logPath,
);
if (postgres.serverId) {
+27 -27
View File
@@ -2,6 +2,7 @@ import { logger } from "@dokploy/server/lib/logger";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Destination } from "@dokploy/server/services/destination";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { quote } from "shell-quote";
import { keepLatestNBackups } from ".";
import { runComposeBackup } from "./compose";
import { runLibsqlBackup } from "./libsql";
@@ -71,16 +72,16 @@ export const getS3Credentials = (destination: Destination) => {
const { accessKey, secretAccessKey, region, endpoint, provider } =
destination;
const rcloneFlags = [
`--s3-access-key-id="${accessKey}"`,
`--s3-secret-access-key="${secretAccessKey}"`,
`--s3-region="${region}"`,
`--s3-endpoint="${endpoint}"`,
`--s3-access-key-id=${quote([accessKey])}`,
`--s3-secret-access-key=${quote([secretAccessKey])}`,
`--s3-region=${quote([region])}`,
`--s3-endpoint=${quote([endpoint])}`,
"--s3-no-check-bucket",
"--s3-force-path-style",
];
if (provider) {
rcloneFlags.unshift(`--s3-provider="${provider}"`);
rcloneFlags.unshift(`--s3-provider=${quote([provider])}`);
}
if (destination.additionalFlags?.length) {
@@ -90,11 +91,16 @@ export const getS3Credentials = (destination: Destination) => {
return rcloneFlags;
};
// User-controlled values (database name, user, password) are passed to the
// container as environment variables via `docker exec -e VAR=<escaped>` and
// referenced as "$VAR" inside the inner shell, so they never appear in the
// inner command text. The -e value is escaped for the outer shell with
// shell-quote; the inner script is single-quoted and reads the env vars.
export const getPostgresBackupCommand = (
database: string,
databaseUser: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID bash -c 'set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER" --no-password "$DB_NAME" | gzip'`;
};
export const getMariadbBackupCommand = (
@@ -102,14 +108,14 @@ export const getMariadbBackupCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --single-transaction --quick --databases ${database} | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mariadb-dump --user="$DB_USER" --password="$DB_PASS" --single-transaction --quick --databases "$DB_NAME" | gzip'`;
};
export const getMysqlBackupCommand = (
database: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databasePassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mysqldump --default-character-set=utf8mb4 -u root --password="$DB_PASS" --single-transaction --no-tablespaces --quick "$DB_NAME" | gzip'`;
};
export const getMongoBackupCommand = (
@@ -117,11 +123,11 @@ export const getMongoBackupCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mongodump -d "$DB_NAME" -u "$DB_USER" -p "$DB_PASS" --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"`;
return `docker exec -e DB_NAME=${quote([database])} -i $CONTAINER_ID sh -c 'tar cf - -C /var/lib/sqld "$DB_NAME" | gzip'`;
};
export const getServiceContainerCommand = (appName: string) => {
@@ -253,11 +259,14 @@ export const generateBackupCommand = (backup: BackupSchedule) => {
export const getBackupCommand = (
backup: BackupSchedule,
rcloneCommand: string,
rcloneFlags: string[],
rcloneDestination: string,
logPath: string,
) => {
const containerSearch = getContainerSearchCommand(backup);
const backupCommand = generateBackupCommand(backup);
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const rcloneDeleteCommand = `rclone deletefile ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
logger.info(
{
@@ -273,33 +282,24 @@ export const getBackupCommand = (
set -eo pipefail;
echo "[$(date)] Starting backup process..." >> ${logPath};
echo "[$(date)] Executing backup command..." >> ${logPath};
CONTAINER_ID=$(${containerSearch})
CONTAINER_ID=$(${containerSearch});
if [ -z "$CONTAINER_ID" ]; then
echo "[$(date)] ❌ Error: Container not found" >> ${logPath};
exit 1;
fi
fi;
echo "[$(date)] Container Up: $CONTAINER_ID" >> ${logPath};
echo "[$(date)] Starting backup and upload to S3..." >> ${logPath};
# Run the backup command and capture the exit status
BACKUP_OUTPUT=$(${backupCommand} 2>&1 >/dev/null) || {
UPLOAD_OUTPUT=$({ ${backupCommand} | ${rcloneCommand}; } 2>&1 >/dev/null) || {
echo "[$(date)] ❌ Error: Backup failed" >> ${logPath};
echo "Error: $BACKUP_OUTPUT" >> ${logPath};
exit 1;
}
echo "[$(date)] ✅ backup completed successfully" >> ${logPath};
echo "[$(date)] Starting upload to S3..." >> ${logPath};
# Run the upload command and capture the exit status
UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || {
echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath};
echo "Error: $UPLOAD_OUTPUT" >> ${logPath};
${rcloneDeleteCommand} >/dev/null 2>&1 || true;
exit 1;
}
};
echo "[$(date)] ✅ Upload to S3 completed successfully" >> ${logPath};
echo "[$(date)] ✅ Backup uploaded to S3 successfully" >> ${logPath};
echo "Backup done ✅" >> ${logPath};
`;
};
+50 -9
View File
@@ -7,19 +7,23 @@ import { writeDomainsToCompose } from "../docker/domain";
import {
encodeBase64,
getEnvironmentVariablesObject,
prepareEnvironmentVariables,
prepareEnvironmentVariablesForFile,
} from "../docker/utils";
import { withResolvedVaultRefs } from "../vault";
export type ComposeNested = InferResultType<
"compose",
{ environment: { with: { project: true } }; mounts: true; domains: true }
>;
export const getBuildComposeCommand = async (compose: ComposeNested) => {
export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
const compose = await withResolvedVaultRefs(rawCompose);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const { sourceType, appName, mounts, composeType, domains } = compose;
const command = createCommand(compose);
const envCommand = getCreateEnvFileCommand(compose);
const envCommand = compose.createEnvFile
? getCreateEnvFileCommand(compose)
: "";
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const exportEnvCommand = getExportEnvCommand(compose);
@@ -67,11 +71,48 @@ Compose Type: ${composeType} ✅`;
return bashCommand;
};
// Shell control characters that must never appear in a user-provided compose
// command: they would let it break out of the `docker ${command}` invocation
// into arbitrary host commands. A normal docker compose CLI line never needs them.
// Removed '&' from the blocklist to allow '&&' chaining
const UNSAFE_COMPOSE_COMMAND = /[;|`$(){}<>\n\\]/;
const sanitizeCommand = (command: string) => {
const sanitizedCommand = command.trim();
const parts = sanitizedCommand.split(/\s+/);
if (UNSAFE_COMPOSE_COMMAND.test(sanitizedCommand)) {
throw new Error(
"Invalid characters in compose command: shell control characters are not allowed",
);
}
if (sanitizedCommand.includes("&")) {
// Block single '&' (e.g., backgrounding tasks) or malformed chains like '&&&'
if (
/(?<!&)&(?!&)/.test(sanitizedCommand) ||
sanitizedCommand.includes("&&&")
) {
throw new Error("Single '&' is not allowed. Use '&&' for chaining.");
}
// Split by '&&' and check that every chained command (skipping the first one) is safe
const chains = sanitizedCommand.split("&&").map((cmd) => cmd.trim());
const isSafeChain = chains
.slice(1)
.every(
(cmd) =>
cmd.startsWith("docker compose ") ||
cmd.startsWith("docker-compose "),
);
if (!isSafeChain) {
throw new Error(
"Chained commands must strictly start with 'docker compose '",
);
}
}
const parts = sanitizedCommand.split(/\s+/);
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
return restCommand.join(" ");
@@ -88,9 +129,9 @@ export const createCommand = (compose: ComposeNested) => {
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
return command;
@@ -116,7 +157,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
}
const envFileContent = prepareEnvironmentVariables(
const envFileContent = prepareEnvironmentVariablesForFile(
envContent,
compose.environment.project.env,
compose.environment.env,
@@ -124,8 +165,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const encodedContent = encodeBase64(envFileContent);
return `
touch ${envFilePath};
echo "${encodedContent}" | base64 -d > "${envFilePath}";
touch ${quote([envFilePath])};
echo "${encodedContent}" | base64 -d > ${quote([envFilePath])};
`;
};
@@ -85,9 +85,9 @@ export const getDockerCommand = (application: ApplicationNested) => {
}
command += `
echo "Building ${appName}" ;
cd ${dockerContextPath} || {
echo "❌ The path ${dockerContextPath} does not exist" ;
echo ${quote([`Building ${appName}`])} ;
cd ${quote([dockerContextPath])} || {
echo ${quote([`❌ The path ${dockerContextPath} does not exist`])} ;
exit 1;
}
+9 -4
View File
@@ -1,3 +1,4 @@
import { resolveServiceNetworks } from "@dokploy/server/services/network";
import { findRegistryByIdWithCredentials } from "@dokploy/server/services/registry";
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
@@ -11,6 +12,7 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
import { getDockerCommand } from "./docker-file";
import { getHerokuCommand } from "./heroku";
import { getNixpacksCommand } from "./nixpacks";
@@ -37,7 +39,8 @@ export type ApplicationNested = InferResultType<
}
>;
export const getBuildCommand = async (application: ApplicationNested) => {
export const getBuildCommand = async (rawApplication: ApplicationNested) => {
const application = await withResolvedVaultRefs(rawApplication);
let command = "";
if (application.sourceType !== "docker") {
@@ -76,8 +79,9 @@ export const getBuildCommand = async (application: ApplicationNested) => {
};
export const mechanizeDockerContainer = async (
application: ApplicationNested,
rawApplication: ApplicationNested,
) => {
const application = await withResolvedVaultRefs(rawApplication);
const {
appName,
env,
@@ -100,6 +104,8 @@ export const mechanizeDockerContainer = async (
const volumesMount = generateVolumeMounts(mounts);
const resolvedNetworks = await resolveServiceNetworks(application);
const {
HealthCheck,
RestartPolicy,
@@ -108,7 +114,6 @@ export const mechanizeDockerContainer = async (
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -147,7 +152,7 @@ export const mechanizeDockerContainer = async (
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -1,6 +1,7 @@
import path from "node:path";
import { getStaticCommand } from "@dokploy/server/utils/builders/static";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -52,10 +53,10 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
bashCommand += `
docker create --name ${buildContainerId} ${appName}
mkdir -p ${localPath}
docker cp ${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""} ${path.join(buildAppDirectory, publishDirectory)} || {
mkdir -p ${quote([localPath])}
docker cp ${quote([`${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""}`])} ${quote([localPath])} || {
docker rm ${buildContainerId}
echo "❌ Copying ${publishDirectory} to ${path.join(buildAppDirectory, publishDirectory)} failed" ;
echo ${quote([`❌ Copying ${publishDirectory} to ${localPath} failed`])} ;
exit 1;
}
docker rm ${buildContainerId}
@@ -53,14 +53,9 @@ export const getRailpackCommand = (application: ApplicationNested) => {
"build",
"--builder",
builderName,
...(cacheKey
? [
"--build-arg",
`secrets-hash=${secretsHash}`,
"--build-arg",
`cache-key=${cacheKey}`,
]
: []),
"--build-arg",
`secrets-hash=${secretsHash}`,
...(cacheKey ? ["--build-arg", `cache-key=${cacheKey}`] : []),
"--build-arg",
`BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v${application.railpackVersion}`,
"-f",
+4 -3
View File
@@ -5,6 +5,7 @@ import {
safeDockerLoginCommand,
} from "@dokploy/server/services/registry";
import { createRollback } from "@dokploy/server/services/rollbacks";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
export const uploadImageRemoteCommand = async (
@@ -124,18 +125,18 @@ const getRegistryCommands = (
registry.password,
);
return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo ${quote([`📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'`])} ;
${loginCmd} || {
echo "❌ DockerHub Failed" ;
exit 1;
}
echo "✅ Registry Login Success" ;
docker tag ${imageName} ${registryTag} || {
docker tag ${quote([imageName])} ${quote([registryTag])} || {
echo "❌ Error tagging image" ;
exit 1;
}
echo "✅ Image Tagged" ;
docker push ${registryTag} || {
docker push ${quote([registryTag])} || {
echo "❌ Error pushing image" ;
exit 1;
}
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions, PortConfig } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -9,6 +10,7 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type LibsqlNested = InferResultType<
"libsql",
@@ -17,7 +19,8 @@ export type LibsqlNested = InferResultType<
environment: { with: { project: true } };
}
>;
export const buildLibsql = async (libsql: LibsqlNested) => {
export const buildLibsql = async (rawLibsql: LibsqlNested) => {
const libsql = await withResolvedVaultRefs(rawLibsql);
const {
appName,
env,
@@ -46,6 +49,8 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
env ? `\n${env}` : ""
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
const resolvedNetworks = await resolveServiceNetworks(libsql);
const {
HealthCheck,
RestartPolicy,
@@ -54,7 +59,6 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
} = generateConfigContainer(libsql);
const resources = calculateResources({
memoryLimit,
@@ -96,7 +100,7 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
: {}),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -9,12 +10,14 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type MariadbNested = InferResultType<
"mariadb",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildMariadb = async (mariadb: MariadbNested) => {
export const buildMariadb = async (rawMariadb: MariadbNested) => {
const mariadb = await withResolvedVaultRefs(rawMariadb);
const {
appName,
env,
@@ -37,6 +40,8 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(mariadb);
const {
HealthCheck,
RestartPolicy,
@@ -45,7 +50,6 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -87,7 +91,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
+7 -3
View File
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -9,13 +10,15 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type MongoNested = InferResultType<
"mongo",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildMongo = async (mongo: MongoNested) => {
export const buildMongo = async (rawMongo: MongoNested) => {
const mongo = await withResolvedVaultRefs(rawMongo);
const {
appName,
env,
@@ -83,6 +86,8 @@ ${command ?? "wait $MONGOD_PID"}`;
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(mongo);
const {
HealthCheck,
RestartPolicy,
@@ -91,7 +96,6 @@ ${command ?? "wait $MONGOD_PID"}`;
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -143,7 +147,7 @@ ${command ?? "wait $MONGOD_PID"}`;
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
+7 -3
View File
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -9,13 +10,15 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type MysqlNested = InferResultType<
"mysql",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildMysql = async (mysql: MysqlNested) => {
export const buildMysql = async (rawMysql: MysqlNested) => {
const mysql = await withResolvedVaultRefs(rawMysql);
const {
appName,
env,
@@ -43,6 +46,8 @@ export const buildMysql = async (mysql: MysqlNested) => {
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(mysql);
const {
HealthCheck,
RestartPolicy,
@@ -51,7 +56,6 @@ export const buildMysql = async (mysql: MysqlNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -93,7 +97,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -9,12 +10,14 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type PostgresNested = InferResultType<
"postgres",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildPostgres = async (postgres: PostgresNested) => {
export const buildPostgres = async (rawPostgres: PostgresNested) => {
const postgres = await withResolvedVaultRefs(rawPostgres);
const {
appName,
env,
@@ -36,6 +39,8 @@ export const buildPostgres = async (postgres: PostgresNested) => {
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(postgres);
const {
HealthCheck,
RestartPolicy,
@@ -44,7 +49,6 @@ export const buildPostgres = async (postgres: PostgresNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -85,7 +89,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -14,6 +14,7 @@ import { deployMySql } from "@dokploy/server/services/mysql";
import { deployPostgres } from "@dokploy/server/services/postgres";
import { deployRedis } from "@dokploy/server/services/redis";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import { removeService } from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -40,7 +41,7 @@ export const rebuildDatabase = async (
for (const mount of database.mounts) {
if (mount.type === "volume") {
const command = `docker volume rm ${mount?.volumeName} --force`;
const command = `docker volume rm ${quote([mount?.volumeName ?? ""])} --force`;
if (database.serverId) {
await execAsyncRemote(database.serverId, command);
} else {
+7 -3
View File
@@ -1,5 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import {
calculateResources,
generateBindMounts,
@@ -9,12 +10,14 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type RedisNested = InferResultType<
"redis",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildRedis = async (redis: RedisNested) => {
export const buildRedis = async (rawRedis: RedisNested) => {
const redis = await withResolvedVaultRefs(rawRedis);
const {
appName,
env,
@@ -34,6 +37,8 @@ export const buildRedis = async (redis: RedisNested) => {
env ? `\n${env}` : ""
}`;
const resolvedNetworks = await resolveServiceNetworks(redis);
const {
HealthCheck,
RestartPolicy,
@@ -42,7 +47,6 @@ export const buildRedis = async (redis: RedisNested) => {
Mode,
RollbackConfig,
UpdateConfig,
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
@@ -91,7 +95,7 @@ export const buildRedis = async (redis: RedisNested) => {
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
Networks: resolvedNetworks,
RestartPolicy,
Placement,
Resources: {
@@ -26,11 +26,14 @@ export const addSuffixToVolumesInServices = (
if (_.has(newServiceConfig, "volumes")) {
newServiceConfig.volumes = _.map(newServiceConfig.volumes, (volume) => {
if (_.isString(volume)) {
const [volumeName, path] = volume.split(":");
// remainder is the container path plus optional access mode (:ro, :z, :Z)
const [volumeName, ...pathAndMode] = volume.split(":");
const remainder = pathAndMode.join(":");
// skip bind mounts and variables (e.g. $PWD)
if (
!volumeName ||
!remainder ||
volumeName.startsWith(".") ||
volumeName.startsWith("/") ||
volumeName.startsWith("$")
@@ -43,10 +46,10 @@ export const addSuffixToVolumesInServices = (
if (parts.length > 1) {
const baseName = parts[0];
const rest = parts.slice(1).join("/");
return `${baseName}-${suffix}/${rest}:${path}`;
return `${baseName}-${suffix}/${rest}:${remainder}`;
}
return `${volumeName}-${suffix}:${path}`;
return `${volumeName}-${suffix}:${remainder}`;
}
if (_.isObject(volume) && volume.type === "volume" && volume.source) {
return {
+147 -4
View File
@@ -1,8 +1,11 @@
import fs, { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { network, patch } from "@dokploy/server/db/schema";
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { eq, inArray } from "drizzle-orm";
import { parse, stringify } from "yaml";
import { execAsyncRemote } from "../process/execAsync";
import { cloneBitbucketRepository } from "../providers/bitbucket";
@@ -131,6 +134,38 @@ exit 1;
`;
}
};
export const applyComposeFilePatch = async (
compose: Compose,
): Promise<ComposeSpecification | null> => {
if (compose.sourceType === "raw") {
return null;
}
const composePatches = await db.query.patch.findMany({
where: eq(patch.composeId, compose.composeId),
});
const composeFilePatch = composePatches.find(
(p) =>
p.enabled &&
p.type !== "delete" &&
join(p.filePath) === join(compose.composePath),
);
if (!composeFilePatch?.content) {
return null;
}
try {
const parsed = parse(composeFilePatch.content, {
maxAliasCount: 10000,
}) as ComposeSpecification;
return parsed ?? null;
} catch {
return null;
}
};
export const addDomainToCompose = async (
compose: Compose,
domains: Domain[],
@@ -149,6 +184,8 @@ export const addDomainToCompose = async (
return null;
}
result = (await applyComposeFilePatch(compose)) ?? result;
if (compose.isolatedDeployment) {
const randomized = randomizeDeployableSpecificationFile(
result,
@@ -218,6 +255,17 @@ export const addDomainToCompose = async (
labels.unshift("traefik.swarm.network=dokploy-network");
}
}
} else {
const isolatedNetwork = compose.suffix || compose.appName;
if (compose.composeType === "docker-compose") {
if (!labels.includes(`traefik.docker.network=${isolatedNetwork}`)) {
labels.unshift(`traefik.docker.network=${isolatedNetwork}`);
}
} else {
if (!labels.includes(`traefik.swarm.network=${isolatedNetwork}`)) {
labels.unshift(`traefik.swarm.network=${isolatedNetwork}`);
}
}
}
}
@@ -229,14 +277,79 @@ export const addDomainToCompose = async (
}
}
// Add dokploy-network to the root of the compose file
const injectedNetworkNames = await applyServiceNetworks(result, compose);
if (!compose.isolatedDeployment) {
result.networks = addDokployNetworkToRoot(result.networks);
declareUsedNetworksInRoot(result, injectedNetworkNames);
}
return result;
};
export const applyServiceNetworks = async (
result: ComposeSpecification,
compose: Compose,
) => {
const injectedNetworkNames = new Set<string>();
const serviceNetworks = compose.serviceNetworks ?? [];
if (serviceNetworks.length === 0) return injectedNetworkNames;
const allNetworkIds = [
...new Set(serviceNetworks.flatMap((s) => s.networkIds)),
];
const networks =
allNetworkIds.length > 0
? await db.query.network.findMany({
where: inArray(network.networkId, allNetworkIds),
})
: [];
for (const config of serviceNetworks) {
const service = result.services?.[config.serviceName];
if (!service) continue;
for (const networkId of config.networkIds) {
const match = networks.find((n) => n.networkId === networkId);
if (!match) continue;
service.networks = addDokployNetworkToService(
service.networks,
match.name,
);
injectedNetworkNames.add(match.name);
}
if (config.detachDokployNetwork) {
removeNetworkFromService(service, "dokploy-network");
removeNetworkFromService(service, "default");
removeDokployNetworkLabel(service);
}
}
return injectedNetworkNames;
};
export const declareUsedNetworksInRoot = (
result: ComposeSpecification,
injectedNetworkNames: Set<string>,
) => {
const isUsed = (name: string) =>
Object.values(result.services ?? {}).some((service) => {
const nets = service?.networks;
if (Array.isArray(nets)) return nets.includes(name);
if (nets && typeof nets === "object") return name in nets;
return false;
});
if (isUsed("dokploy-network")) {
result.networks = addDokployNetworkToRoot(result.networks);
}
for (const name of injectedNetworkNames) {
if (isUsed(name)) {
result.networks = addDokployNetworkToRoot(result.networks, name);
}
}
};
export const writeComposeFile = async (
compose: Compose,
composeSpec: ComposeSpecification,
@@ -350,9 +463,10 @@ export const createDomainLabels = (
export const addDokployNetworkToService = (
networkService: DefinitionsService["networks"],
networkName = "dokploy-network",
) => {
let networks = networkService;
const network = "dokploy-network";
const network = networkName;
const defaultNetwork = "default";
if (!networks) {
networks = [];
@@ -377,11 +491,40 @@ export const addDokployNetworkToService = (
return networks;
};
export const removeNetworkFromService = (
service: DefinitionsService,
networkName: string,
) => {
const networks = service.networks;
if (Array.isArray(networks)) {
service.networks = networks.filter((n) => n !== networkName);
} else if (networks && typeof networks === "object") {
delete networks[networkName];
}
};
const removeDokployNetworkLabel = (service: DefinitionsService) => {
const stripped = (labels: DefinitionsService["labels"]) => {
if (Array.isArray(labels)) {
return labels.filter(
(l) =>
l !== "traefik.docker.network=dokploy-network" &&
l !== "traefik.swarm.network=dokploy-network",
);
}
return labels;
};
if (service.labels) service.labels = stripped(service.labels);
if (service.deploy?.labels)
service.deploy.labels = stripped(service.deploy.labels);
};
export const addDokployNetworkToRoot = (
networkRoot: PropertiesNetworks | undefined,
networkName = "dokploy-network",
) => {
let networks = networkRoot;
const network = "dokploy-network";
const network = networkName;
if (!networks) {
networks = {};
+31 -55
View File
@@ -401,6 +401,13 @@ export const prepareEnvironmentVariables = (
projectEnv?: string | null,
environmentEnv?: string | null,
) => {
for (const source of [serviceEnv, projectEnv, environmentEnv]) {
if (source?.includes("${{vault.")) {
throw new Error(
"Unresolved vault reference: call withResolvedVaultRefs() on the entity before preparing environment variables",
);
}
}
const projectVars = parse(projectEnv ?? "");
const environmentVars = parse(environmentEnv ?? "");
const serviceVars = parse(serviceEnv ?? "");
@@ -465,6 +472,27 @@ export const prepareEnvironmentVariablesForShell = (
return envVars.map((env) => quote([env]));
};
export const prepareEnvironmentVariablesForFile = (
serviceEnv: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
): string[] => {
const envVars = prepareEnvironmentVariables(
serviceEnv,
projectEnv,
environmentEnv,
);
return envVars.map((pair) => {
const [key, value] = parseEnvironmentKeyValuePair(pair);
const escapedValue = value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\$/g, "\\$");
return `${key}="${escapedValue}"`;
});
};
export const parseEnvironmentKeyValuePair = (
pair: string,
): [string, string] => {
@@ -548,7 +576,6 @@ export const generateConfigContainer = (
labelsSwarm,
replicas,
mounts,
networkSwarm,
stopGracePeriodSwarm,
endpointSpecSwarm,
ulimitsSwarm,
@@ -611,13 +638,6 @@ export const generateConfigContainer = (
stopGracePeriodSwarm !== undefined && {
StopGracePeriod: stopGracePeriodSwarm,
}),
...(networkSwarm
? {
Networks: networkSwarm,
}
: {
Networks: [{ Target: "dokploy-network" }],
}),
...(endpointSpecSwarm && {
EndpointSpec: {
...(endpointSpecSwarm.Mode && { Mode: endpointSpecSwarm.Mode }),
@@ -712,14 +732,14 @@ export const getCreateFileCommand = (
) => {
const fullPath = path.join(outputPath, filePath);
if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) {
return `mkdir -p ${fullPath};`;
return `mkdir -p ${quote([fullPath])};`;
}
const directory = path.dirname(fullPath);
const encodedContent = encodeBase64(content);
return `
mkdir -p ${directory};
echo "${encodedContent}" | base64 -d > "${fullPath}";
mkdir -p ${quote([directory])};
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
`;
};
@@ -900,50 +920,6 @@ export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
}
};
export const checkRedisHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-redis");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify Redis actually responds to PING
const containerId = await getSwarmServiceContainerId("dokploy-redis");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["redis-cli", "ping"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
if (!output.includes("PONG")) {
return {
status: "unhealthy",
message: `Redis did not respond with PONG: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Failed to check Redis",
};
}
};
export const checkTraefikHealth = async (): Promise<ServiceHealthStatus> => {
// Traefik can run as a standalone container or a swarm service
try {
+3 -2
View File
@@ -1,4 +1,5 @@
import * as fs from "node:fs/promises";
import { quote } from "shell-quote";
import { execAsync, execAsyncRemote, sleep } from "../utils/process/execAsync";
interface GPUInfo {
@@ -322,7 +323,7 @@ const setupLocalServer = async (daemonConfig: any) => {
};
const addGpuLabel = async (nodeId: string, serverId?: string) => {
const labelCommand = `docker node update --label-add gpu=true ${nodeId}`;
const labelCommand = `docker node update --label-add gpu=true ${quote([nodeId])}`;
if (serverId) {
await execAsyncRemote(serverId, labelCommand);
} else {
@@ -335,7 +336,7 @@ const verifySetup = async (nodeId: string, serverId?: string) => {
if (!finalStatus.swarmEnabled) {
const diagnosticCommands = [
`docker node inspect ${nodeId}`,
`docker node inspect ${quote([nodeId])}`,
'nvidia-smi -a | grep "GPU UUID"',
"cat /etc/docker/daemon.json",
"cat /etc/nvidia-container-runtime/config.toml",
@@ -1,8 +1,8 @@
// Valid hostname per RFC 1123: labels of letters, digits and hyphens
// (no leading/trailing hyphen), separated by dots. Underscores are rejected
// (no leading/trailing hyphen), optionally separated by dots. Underscores are rejected
// because Let's Encrypt refuses to issue certificates for them.
export const VALID_HOSTNAME_REGEX =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
export const INVALID_HOSTNAME_MESSAGE =
"Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";
@@ -10,6 +10,7 @@ import {
} from "@dokploy/server/services/bitbucket";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import type { z } from "zod";
export type ApplicationWithBitbucket = InferResultType<
@@ -124,8 +125,8 @@ export const cloneBitbucketRepository = async ({
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;`;
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(bitbucketBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
@@ -1,4 +1,5 @@
import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
export const buildRemoteDocker = async (application: ApplicationNested) => {
@@ -9,7 +10,7 @@ export const buildRemoteDocker = async (application: ApplicationNested) => {
throw new Error("Docker image not found");
}
let command = `
echo "Pulling ${dockerImage}";
echo ${quote([`Pulling ${dockerImage}`])};
`;
if (username && password) {
@@ -22,7 +23,7 @@ fi
}
command += `
docker pull ${dockerImage} 2>&1 || {
docker pull ${quote([dockerImage])} 2>&1 || {
echo "❌ Pulling image failed";
exit 1;
}
+5 -4
View File
@@ -4,6 +4,7 @@ import {
findSSHKeyById,
updateSSHKeyById,
} from "@dokploy/server/services/ssh-key";
import { quote } from "shell-quote";
import { execAsync, execAsyncRemote } from "../process/execAsync";
interface CloneGitRepository {
@@ -61,7 +62,7 @@ export const cloneGitRepository = async ({
}
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
command += `echo "Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅";`;
command += `echo ${quote([`Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅`])};`;
if (customGitSSHKeyId) {
await updateSSHKeyById({
@@ -78,8 +79,8 @@ export const cloneGitRepository = async ({
command += "chmod 600 /tmp/id_rsa;";
command += `export GIT_SSH_COMMAND="${gitSshCommand}";`;
}
command += `if ! git clone --branch ${customGitBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${customGitUrl} ${outputPath}; then
echo "❌ [ERROR] Fail to clone the repository ${customGitUrl}";
command += `if ! git clone --branch ${quote([String(customGitBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${quote([String(customGitUrl ?? "")])} ${quote([String(outputPath ?? "")])}; then
echo ${quote([`❌ [ERROR] Fail to clone the repository ${customGitUrl}`])};
exit 1;
fi
`;
@@ -114,7 +115,7 @@ const addHostToKnownHostsCommand = (repositoryURL: string) => {
// ssh-keyscan is best-effort: some Git hosts (e.g. Hugging Face) never answer
// it, and its exit code must not abort the clone under `set -e`. The clone's
// own host-key check (StrictHostKeyChecking=accept-new) is the real boundary.
return `ssh-keyscan -p ${port} ${domain} >> ${knownHostsPath} || true;`;
return `ssh-keyscan -p ${Number(port)} ${quote([String(domain ?? "")])} >> ${knownHostsPath} || true;`;
};
const sanitizeRepoPathSSH = (input: string) => {
const SSH_PATH_RE = new RegExp(
+3 -2
View File
@@ -7,6 +7,7 @@ import {
} from "@dokploy/server/services/gitea";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
export const getErrorCloneRequirements = (entity: {
giteaRepository?: string | null;
@@ -176,8 +177,8 @@ export const cloneGiteaRepository = async ({
giteaRepository!,
);
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
command += `git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(giteaBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
+71 -4
View File
@@ -6,8 +6,71 @@ import type { InferResultType } from "@dokploy/server/types/with";
import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server";
import { Octokit } from "octokit";
import { quote } from "shell-quote";
import type { z } from "zod";
export const DEFAULT_GITHUB_URL = "https://github.com";
export const DEFAULT_GITHUB_API_URL = "https://api.github.com";
export const parseGithubBaseUrl = (
githubUrl?: string | null,
): { url: string } | { error: string } => {
const raw = githubUrl?.trim();
if (!raw) {
return { url: DEFAULT_GITHUB_URL };
}
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
? raw
: `https://${raw}`;
let parsed: URL;
try {
parsed = new URL(withScheme);
} catch {
return { error: `"${raw}" is not a valid URL` };
}
if (parsed.protocol !== "https:") {
return { error: "Only https is supported for GitHub instances" };
}
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
// would slip past the checks below.
if (parsed.hostname.endsWith(".")) {
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
}
const { hostname } = parsed;
if (!hostname.includes(".")) {
return { error: `"${hostname}" is not a fully qualified hostname` };
}
return { url: `${parsed.protocol}//${parsed.host}` };
};
export const normalizeGithubUrl = (githubUrl?: string | null): string => {
const result = parseGithubBaseUrl(githubUrl);
return "url" in result ? result.url : DEFAULT_GITHUB_URL;
};
export const deriveGithubApiUrl = (githubUrl?: string | null): string => {
const normalized = normalizeGithubUrl(githubUrl);
const { protocol, host } = new URL(normalized);
if (host === "github.com" || host === "www.github.com") {
return DEFAULT_GITHUB_API_URL;
}
// Data residency tenants keep the api. prefix; GHES does not have one.
if (host === "ghe.com" || host.endsWith(".ghe.com")) {
return `${protocol}//api.${host}`;
}
return `${protocol}//${host}/api/v3`;
};
export const authGithub = (githubProvider: Github): Octokit => {
if (!haveGithubRequirements(githubProvider)) {
throw new TRPCError({
@@ -23,6 +86,7 @@ export const authGithub = (githubProvider: Github): Octokit => {
privateKey: githubProvider?.githubPrivateKey || "",
installationId: githubProvider?.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider?.githubUrl),
});
return octokit;
@@ -161,13 +225,14 @@ export const cloneGithubRepository = async ({
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;
const cloneBase = new URL(normalizeGithubUrl(githubProvider.githubUrl));
const repoclone = `${cloneBase.host}/${owner}/${repository}.git`;
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`;
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
command += `git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
@@ -186,6 +251,7 @@ export const getGithubRepositories = async (githubId?: string) => {
privateKey: githubProvider.githubPrivateKey,
installationId: githubProvider.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
});
const repositories = (await octokit.paginate(
@@ -212,6 +278,7 @@ export const getGithubBranches = async (
privateKey: githubProvider.githubPrivateKey,
installationId: githubProvider.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
});
const branches = (await octokit.paginate(octokit.rest.repos.listBranches, {
@@ -8,6 +8,7 @@ import {
} from "@dokploy/server/services/gitlab";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import type { z } from "zod";
export const refreshGitlabToken = async (gitlabProviderId: string) => {
@@ -151,8 +152,8 @@ export const cloneGitlabRepository = async ({
command += `mkdir -p ${outputPath};`;
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
command += `git clone --branch ${gitlabBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(gitlabBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
+3 -2
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Compose } from "@dokploy/server/services/compose";
import type { Destination } from "@dokploy/server/services/destination";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -26,10 +27,10 @@ export const restoreComposeBackup = async (
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
if (backupInput.metadata?.mongo) {
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
}
let credentials: DatabaseCredentials = {};
+2 -1
View File
@@ -1,6 +1,7 @@
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 { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials, getServiceContainerCommand } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -19,7 +20,7 @@ export const restoreLibsqlBackup = async (
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
const containerSearch = getServiceContainerCommand(appName);
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
+2 -1
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Mariadb } from "@dokploy/server/services/mariadb";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -19,7 +20,7 @@ export const restoreMariadbBackup = async (
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
const command = getRestoreCommand({
appName,
+2 -1
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Mongo } from "@dokploy/server/services/mongo";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -18,7 +19,7 @@ export const restoreMongoBackup = async (
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
const command = getRestoreCommand({
appName,
+2 -1
View File
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { MySql } from "@dokploy/server/services/mysql";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -19,7 +20,7 @@ export const restoreMySqlBackup = async (
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
const command = getRestoreCommand({
appName,
@@ -1,6 +1,7 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Postgres } from "@dokploy/server/services/postgres";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
@@ -20,7 +21,7 @@ export const restorePostgresBackup = async (
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
const command = getRestoreCommand({
appName,
+10 -6
View File
@@ -1,13 +1,17 @@
import { quote } from "shell-quote";
import {
getComposeContainerCommand,
getServiceContainerCommand,
} from "../backups/utils";
// User-controlled values are passed to the container via `docker exec -e` and
// read as "$VAR" inside a single-quoted inner script, so they never enter the
// inner command text. See the matching note in backups/utils.ts.
export const getPostgresRestoreCommand = (
database: string,
databaseUser: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U '${databaseUser}' -d ${database} -O --clean --if-exists"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID sh -c 'pg_restore -U "$DB_USER" -d "$DB_NAME" -O --clean --if-exists'`;
};
export const getMariadbRestoreCommand = (
@@ -15,14 +19,14 @@ export const getMariadbRestoreCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mariadb -u '${databaseUser}' -p'${databasePassword}' ${database}"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mariadb -u "$DB_USER" -p"$DB_PASS" "$DB_NAME"'`;
};
export const getMysqlRestoreCommand = (
database: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p'${databasePassword}' ${database}"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mysql -u root -p"$DB_PASS" "$DB_NAME"'`;
};
export const getMongoRestoreCommand = (
@@ -30,7 +34,7 @@ export const getMongoRestoreCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mongorestore --username "$DB_USER" --password "$DB_PASS" --authenticationDatabase admin --db "$DB_NAME" --archive --drop'`;
};
export const getComposeSearchCommand = (
@@ -88,8 +92,8 @@ rm -rf ${tempDir} && \
mkdir -p ${tempDir} && \
${rcloneCommand} ${tempDir} && \
cd ${tempDir} && \
gunzip -f "${fileName}" && \
${restoreCommand} < "${decompressedName}" && \
gunzip -f ${quote([fileName])} && \
${restoreCommand} < ${quote([decompressedName])} && \
rm -rf ${tempDir}
`;
};
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import type { Destination } from "@dokploy/server/services/destination";
import { quote } from "shell-quote";
import { getS3Credentials } from "../backups/utils";
import { execAsync } from "../process/execAsync";
@@ -35,7 +36,7 @@ export const restoreWebServerBackup = async (
// Download backup from S3
emit("Downloading backup from S3...");
await execAsync(
`rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${tempDir}/${backupFile}"`,
`rclone copyto ${rcloneFlags.join(" ")} ${quote([backupPath])} ${quote([`${tempDir}/${backupFile}`])}`,
);
// List files before extraction
@@ -45,7 +46,9 @@ export const restoreWebServerBackup = async (
// Extract backup
emit("Extracting backup...");
await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`);
await execAsync(
`cd ${quote([tempDir])} && unzip ${quote([backupFile])} > /dev/null 2>&1`,
);
// Restore filesystem first
emit("Restoring filesystem...");
+6 -2
View File
@@ -9,8 +9,12 @@ export const initSchedules = async () => {
where: eq(schedules.enabled, true),
with: {
server: true,
application: true,
compose: true,
application: {
columns: { applicationId: true, appName: true, serverId: true },
},
compose: {
columns: { composeId: true, appName: true, serverId: true },
},
organization: true,
},
});

Some files were not shown because too many files have changed in this diff Show More