diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts
index 0dbeaaaad..06c90514f 100644
--- a/apps/dokploy/server/api/root.ts
+++ b/apps/dokploy/server/api/root.ts
@@ -30,6 +30,7 @@ import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
+import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
@@ -93,6 +94,7 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
+ forwardAuth: forwardAuthRouter,
whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,
auditLog: auditLogRouter,
diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts
index c7b1f8642..de847a301 100644
--- a/apps/dokploy/server/api/routers/application.ts
+++ b/apps/dokploy/server/api/routers/application.ts
@@ -4,7 +4,6 @@ import {
deleteAllMiddlewares,
findApplicationById,
findEnvironmentById,
- findGitProviderById,
findProjectById,
getAccessibleServerIds,
getApplicationStats,
@@ -31,6 +30,7 @@ import {
writeConfigRemote,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
+import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
addNewService,
checkServiceAccess,
@@ -174,13 +174,11 @@ export const applicationRouter = createTRPCRouter({
const gitProviderId = getGitProviderId();
if (gitProviderId) {
- try {
- const gitProvider = await findGitProviderById(gitProviderId);
- if (gitProvider.userId !== ctx.session.userId) {
- hasGitProviderAccess = false;
- unauthorizedProvider = application.sourceType;
- }
- } catch {
+ const canEdit = await canEditDeployGitSource(
+ gitProviderId,
+ ctx.session,
+ );
+ if (!canEdit) {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}
diff --git a/apps/dokploy/server/api/routers/cluster.ts b/apps/dokploy/server/api/routers/cluster.ts
index 3dc07935e..441410182 100644
--- a/apps/dokploy/server/api/routers/cluster.ts
+++ b/apps/dokploy/server/api/routers/cluster.ts
@@ -96,9 +96,11 @@ export const clusterRouter = createTRPCRouter({
const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect();
const docker_version = await docker.version();
+ const info = await docker.info();
- let ip = await getLocalServerIp();
- if (input.serverId) {
+ const swarmNodeAddr = info?.Swarm?.NodeAddr;
+ let ip = swarmNodeAddr || (await getLocalServerIp());
+ if (!swarmNodeAddr && input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
}
@@ -128,9 +130,11 @@ export const clusterRouter = createTRPCRouter({
const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect();
const docker_version = await docker.version();
+ const info = await docker.info();
- let ip = await getLocalServerIp();
- if (input.serverId) {
+ const swarmNodeAddr = info?.Swarm?.NodeAddr;
+ let ip = swarmNodeAddr || (await getLocalServerIp());
+ if (!swarmNodeAddr && input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
}
diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts
index 51e257ce6..126e80b1d 100644
--- a/apps/dokploy/server/api/routers/compose.ts
+++ b/apps/dokploy/server/api/routers/compose.ts
@@ -13,7 +13,6 @@ import {
findComposeById,
findDomainsByComposeId,
findEnvironmentById,
- findGitProviderById,
findProjectById,
findServerById,
getAccessibleServerIds,
@@ -34,6 +33,7 @@ import {
updateDeploymentStatus,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
+import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
addNewService,
checkServiceAccess,
@@ -173,13 +173,11 @@ export const composeRouter = createTRPCRouter({
const gitProviderId = getGitProviderId();
if (gitProviderId) {
- try {
- const gitProvider = await findGitProviderById(gitProviderId);
- if (gitProvider.userId !== ctx.session.userId) {
- hasGitProviderAccess = false;
- unauthorizedProvider = compose.sourceType;
- }
- } catch {
+ const canEdit = await canEditDeployGitSource(
+ gitProviderId,
+ ctx.session,
+ );
+ if (!canEdit) {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts
index d319e5a16..838ab83a0 100644
--- a/apps/dokploy/server/api/routers/docker.ts
+++ b/apps/dokploy/server/api/routers/docker.ts
@@ -38,7 +38,7 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId);
}),
- restartContainer: withPermission("service", "read")
+ restartContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -64,7 +64,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
- startContainer: withPermission("service", "read")
+ startContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -90,7 +90,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
- stopContainer: withPermission("service", "read")
+ stopContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -116,7 +116,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
- killContainer: withPermission("service", "read")
+ killContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
diff --git a/apps/dokploy/server/api/routers/git-provider.ts b/apps/dokploy/server/api/routers/git-provider.ts
index 5f48ff422..7e2aed9b3 100644
--- a/apps/dokploy/server/api/routers/git-provider.ts
+++ b/apps/dokploy/server/api/routers/git-provider.ts
@@ -42,6 +42,43 @@ export const gitProviderRouter = createTRPCRouter({
return results.map((r) => ({
...r,
isOwner: r.userId === ctx.session.userId,
+ github: r.github
+ ? {
+ githubId: r.github.githubId,
+ githubAppName: r.github.githubAppName,
+ githubAppId: r.github.githubAppId,
+ githubInstallationId: r.github.githubInstallationId,
+ isConfigured: !!(
+ r.github.githubPrivateKey &&
+ r.github.githubAppId &&
+ r.github.githubInstallationId
+ ),
+ }
+ : null,
+ gitlab: r.gitlab
+ ? {
+ gitlabId: r.gitlab.gitlabId,
+ applicationId: r.gitlab.applicationId,
+ gitlabUrl: r.gitlab.gitlabUrl,
+ isConfigured: !!(r.gitlab.accessToken && r.gitlab.refreshToken),
+ }
+ : null,
+ bitbucket: r.bitbucket
+ ? {
+ bitbucketId: r.bitbucket.bitbucketId,
+ bitbucketUsername: r.bitbucket.bitbucketUsername,
+ isConfigured: false,
+ isDeprecated: !!(r.bitbucket.appPassword && !r.bitbucket.apiToken),
+ }
+ : null,
+ gitea: r.gitea
+ ? {
+ giteaId: r.gitea.giteaId,
+ giteaUrl: r.gitea.giteaUrl,
+ clientId: r.gitea.clientId,
+ isConfigured: !!(r.gitea.accessToken && r.gitea.refreshToken),
+ }
+ : null,
}));
}),
diff --git a/apps/dokploy/server/api/routers/proprietary/forward-auth.ts b/apps/dokploy/server/api/routers/proprietary/forward-auth.ts
new file mode 100644
index 000000000..77caae150
--- /dev/null
+++ b/apps/dokploy/server/api/routers/proprietary/forward-auth.ts
@@ -0,0 +1,207 @@
+import {
+ assertApplicationDomainAccess,
+ deployForwardAuthOnServer,
+ disableForwardAuthOnDomain,
+ enableForwardAuthOnDomain,
+ findServerById,
+ forwardAuthCallbackUrl,
+ getDomainSsoStatus,
+ getForwardAuthServerStatus,
+ getForwardAuthSettings,
+ listSsoProvidersForOrg,
+ removeForwardAuthProxy,
+ removeForwardAuthSettings,
+ setForwardAuthSettings,
+} from "@dokploy/server";
+import {
+ apiDeployForwardAuthOnServer,
+ apiForwardAuthDomainTarget,
+ apiForwardAuthServerTarget,
+ apiSetForwardAuthSettings,
+} from "@dokploy/server/db/schema";
+import { TRPCError } from "@trpc/server";
+import {
+ createTRPCRouter,
+ enterpriseProcedure,
+ withPermission,
+} from "@/server/api/trpc";
+import { audit } from "@/server/api/utils/audit";
+
+export const forwardAuthRouter = createTRPCRouter({
+ getAuthDomain: enterpriseProcedure
+ .input(apiForwardAuthServerTarget)
+ .query(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const settings = await getForwardAuthSettings(input.serverId);
+ if (!settings) return null;
+ return {
+ host: settings.authDomain,
+ https: settings.https,
+ certificateType: settings.certificateType,
+ customCertResolver: settings.customCertResolver,
+ callbackUrl: forwardAuthCallbackUrl(
+ settings.authDomain,
+ settings.https,
+ ),
+ };
+ }),
+
+ setAuthDomain: enterpriseProcedure
+ .input(apiSetForwardAuthSettings)
+ .mutation(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const result = await setForwardAuthSettings({
+ organizationId: ctx.session.activeOrganizationId,
+ serverId: input.serverId,
+ authDomain: input.authDomain,
+ https: input.https,
+ certificateType: input.certificateType,
+ customCertResolver: input.customCertResolver,
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "server",
+ resourceId: input.serverId ?? "local",
+ resourceName: "forward-auth-domain",
+ });
+ return result;
+ }),
+
+ removeAuthDomain: enterpriseProcedure
+ .input(apiForwardAuthServerTarget)
+ .mutation(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const result = await removeForwardAuthSettings(input.serverId);
+ await audit(ctx, {
+ action: "delete",
+ resourceType: "server",
+ resourceId: input.serverId ?? "local",
+ resourceName: "forward-auth-domain",
+ });
+ return result;
+ }),
+
+ listProviders: enterpriseProcedure.query(({ ctx }) =>
+ listSsoProvidersForOrg(ctx.session.activeOrganizationId),
+ ),
+
+ serverStatus: enterpriseProcedure.query(({ ctx }) =>
+ getForwardAuthServerStatus(ctx.session.activeOrganizationId),
+ ),
+
+ deployOnServer: enterpriseProcedure
+ .input(apiDeployForwardAuthOnServer)
+ .mutation(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const result = await deployForwardAuthOnServer({
+ serverId: input.serverId ?? undefined,
+ providerId: input.providerId,
+ organizationId: ctx.session.activeOrganizationId,
+ });
+ await audit(ctx, {
+ action: "create",
+ resourceType: "server",
+ resourceId: input.serverId ?? "local",
+ resourceName: "forward-auth",
+ });
+ return result;
+ }),
+
+ removeOnServer: enterpriseProcedure
+ .input(apiForwardAuthServerTarget)
+ .mutation(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const result = await removeForwardAuthProxy(input.serverId);
+ await audit(ctx, {
+ action: "delete",
+ resourceType: "server",
+ resourceId: input.serverId ?? "local",
+ resourceName: "forward-auth",
+ });
+ return result;
+ }),
+
+ status: withPermission("domain", "read")
+ .input(apiForwardAuthDomainTarget)
+ .query(({ ctx, input }) => getDomainSsoStatus(ctx, input.domainId)),
+
+ enable: withPermission("domain", "create")
+ .input(apiForwardAuthDomainTarget)
+ .mutation(async ({ ctx, input }) => {
+ const domain = await assertApplicationDomainAccess(
+ ctx,
+ input.domainId,
+ "create",
+ );
+ const result = await enableForwardAuthOnDomain({
+ domainId: input.domainId,
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "domain",
+ resourceId: domain.domainId,
+ resourceName: domain.host,
+ });
+ return result;
+ }),
+
+ disable: withPermission("domain", "create")
+ .input(apiForwardAuthDomainTarget)
+ .mutation(async ({ ctx, input }) => {
+ const domain = await assertApplicationDomainAccess(
+ ctx,
+ input.domainId,
+ "create",
+ );
+ const result = await disableForwardAuthOnDomain({
+ domainId: input.domainId,
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "domain",
+ resourceId: domain.domainId,
+ resourceName: domain.host,
+ });
+ return result;
+ }),
+});
diff --git a/apps/dokploy/server/api/routers/proprietary/sso.ts b/apps/dokploy/server/api/routers/proprietary/sso.ts
index ca13cc470..84a79223d 100644
--- a/apps/dokploy/server/api/routers/proprietary/sso.ts
+++ b/apps/dokploy/server/api/routers/proprietary/sso.ts
@@ -53,10 +53,7 @@ export const ssoRouter = createTRPCRouter({
}),
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.ssoProvider.findMany({
- where: and(
- eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
- ),
+ where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: {
id: true,
providerId: true,
@@ -88,7 +85,6 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
@@ -116,12 +112,12 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
+ userId: true,
},
});
@@ -133,6 +129,13 @@ export const ssoRouter = createTRPCRouter({
});
}
+ if (existing.userId !== ctx.session.userId) {
+ await db
+ .update(ssoProvider)
+ .set({ userId: ctx.session.userId })
+ .where(eq(ssoProvider.id, existing.id));
+ }
+
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
@@ -218,7 +221,6 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
@@ -241,7 +243,6 @@ export const ssoRouter = createTRPCRouter({
and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
),
)
.returning({ id: ssoProvider.id });
diff --git a/apps/dokploy/server/api/routers/server.ts b/apps/dokploy/server/api/routers/server.ts
index 310363efd..56f851647 100644
--- a/apps/dokploy/server/api/routers/server.ts
+++ b/apps/dokploy/server/api/routers/server.ts
@@ -45,6 +45,7 @@ import {
redis,
server,
} from "@/server/db/schema";
+import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
export const serverRouter = createTRPCRouter({
create: withPermission("server", "create")
@@ -63,6 +64,11 @@ export const serverRouter = createTRPCRouter({
input,
ctx.session.activeOrganizationId,
);
+ await applyDockerCleanupSchedule(
+ project.serverId,
+ ctx.session.activeOrganizationId,
+ input.enableDockerCleanup,
+ );
await audit(ctx, {
action: "create",
resourceType: "server",
@@ -456,6 +462,12 @@ export const serverRouter = createTRPCRouter({
...input,
});
+ await applyDockerCleanupSchedule(
+ input.serverId,
+ ctx.session.activeOrganizationId,
+ input.enableDockerCleanup,
+ );
+
await audit(ctx, {
action: "update",
resourceType: "server",
diff --git a/apps/dokploy/server/utils/docker-cleanup.ts b/apps/dokploy/server/utils/docker-cleanup.ts
new file mode 100644
index 000000000..46a4f8569
--- /dev/null
+++ b/apps/dokploy/server/utils/docker-cleanup.ts
@@ -0,0 +1,39 @@
+import {
+ CLEANUP_CRON_JOB,
+ cleanupAll,
+ IS_CLOUD,
+ sendDockerCleanupNotifications,
+} from "@dokploy/server";
+import { scheduledJobs, scheduleJob } from "node-schedule";
+import { removeJob, schedule } from "./backup";
+
+export const applyDockerCleanupSchedule = async (
+ serverId: string,
+ organizationId: string,
+ enable: boolean,
+) => {
+ if (enable) {
+ if (IS_CLOUD) {
+ await schedule({
+ cronSchedule: CLEANUP_CRON_JOB,
+ serverId,
+ type: "server",
+ });
+ } else {
+ scheduleJob(serverId, CLEANUP_CRON_JOB, async () => {
+ await cleanupAll(serverId);
+ await sendDockerCleanupNotifications(organizationId);
+ });
+ }
+ } else {
+ if (IS_CLOUD) {
+ await removeJob({
+ cronSchedule: CLEANUP_CRON_JOB,
+ serverId,
+ type: "server",
+ });
+ } else {
+ scheduledJobs[serverId]?.cancel();
+ }
+ }
+};
diff --git a/packages/server/src/db/schema/domain.ts b/packages/server/src/db/schema/domain.ts
index 646dfdf9f..092275fde 100644
--- a/packages/server/src/db/schema/domain.ts
+++ b/packages/server/src/db/schema/domain.ts
@@ -55,6 +55,7 @@ export const domains = pgTable("domain", {
internalPath: text("internalPath").default("/"),
stripPath: boolean("stripPath").notNull().default(false),
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
+ forwardAuthEnabled: boolean("forwardAuthEnabled").notNull().default(false),
});
export const domainsRelations = relations(domains, ({ one }) => ({
@@ -94,6 +95,7 @@ export const apiCreateDomain = createSchema.pick({
internalPath: true,
stripPath: true,
middlewares: true,
+ forwardAuthEnabled: true,
});
export const apiFindDomain = z.object({
@@ -126,5 +128,6 @@ export const apiUpdateDomain = createSchema
internalPath: true,
stripPath: true,
middlewares: true,
+ forwardAuthEnabled: true,
})
.merge(createSchema.pick({ domainId: true }).required());
diff --git a/packages/server/src/db/schema/forward-auth.ts b/packages/server/src/db/schema/forward-auth.ts
new file mode 100644
index 000000000..47e7dec7e
--- /dev/null
+++ b/packages/server/src/db/schema/forward-auth.ts
@@ -0,0 +1,75 @@
+import { relations } from "drizzle-orm";
+import { boolean, pgTable, text } from "drizzle-orm/pg-core";
+import { nanoid } from "nanoid";
+import { z } from "zod";
+import { server } from "./server";
+import { certificateType } from "./shared";
+import { ssoProvider } from "./sso";
+
+export const forwardAuthSettings = pgTable("forward_auth_settings", {
+ forwardAuthSettingsId: text("forwardAuthSettingsId")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => nanoid()),
+ authDomain: text("authDomain").notNull(),
+ baseDomain: text("baseDomain").notNull(),
+ https: boolean("https").notNull().default(true),
+ certificateType: certificateType("certificateType")
+ .notNull()
+ .default("letsencrypt"),
+ customCertResolver: text("customCertResolver"),
+ providerId: text("providerId").references(() => ssoProvider.providerId, {
+ onDelete: "set null",
+ }),
+ serverId: text("serverId")
+ .unique()
+ .references(() => server.serverId, {
+ onDelete: "cascade",
+ }),
+ createdAt: text("createdAt")
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
+});
+
+export const forwardAuthSettingsRelations = relations(
+ forwardAuthSettings,
+ ({ one }) => ({
+ server: one(server, {
+ fields: [forwardAuthSettings.serverId],
+ references: [server.serverId],
+ }),
+ provider: one(ssoProvider, {
+ fields: [forwardAuthSettings.providerId],
+ references: [ssoProvider.providerId],
+ }),
+ }),
+);
+
+const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
+
+export const apiForwardAuthServerTarget = z.object({
+ serverId: z.string().nullable(),
+});
+
+export const apiForwardAuthDomainTarget = z.object({
+ domainId: z.string().min(1),
+});
+
+export const apiSetForwardAuthSettings = z.object({
+ serverId: z.string().nullable(),
+ authDomain: z
+ .string()
+ .trim()
+ .toLowerCase()
+ .refine((v) => domainRegex.test(v), { message: "Invalid auth domain" }),
+ https: z.boolean().default(true),
+ certificateType: z
+ .enum(["none", "letsencrypt", "custom"])
+ .default("letsencrypt"),
+ customCertResolver: z.string().optional(),
+});
+
+export const apiDeployForwardAuthOnServer = z.object({
+ serverId: z.string().nullable(),
+ providerId: z.string().min(1),
+});
diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts
index a4e613a02..b00bed200 100644
--- a/packages/server/src/db/schema/index.ts
+++ b/packages/server/src/db/schema/index.ts
@@ -10,6 +10,7 @@ export * from "./deployment";
export * from "./destination";
export * from "./domain";
export * from "./environment";
+export * from "./forward-auth";
export * from "./git-provider";
export * from "./gitea";
export * from "./github";
diff --git a/packages/server/src/db/schema/server.ts b/packages/server/src/db/schema/server.ts
index 4c8f1fc94..5a239f00d 100644
--- a/packages/server/src/db/schema/server.ts
+++ b/packages/server/src/db/schema/server.ts
@@ -147,8 +147,12 @@ export const apiCreateServer = createSchema
username: true,
sshKeyId: true,
serverType: true,
+ enableDockerCleanup: true,
})
- .required();
+ .required()
+ .extend({
+ enableDockerCleanup: z.boolean().default(true),
+ });
export const apiFindOneServer = z.object({
serverId: z.string().min(1),
@@ -170,10 +174,12 @@ export const apiUpdateServer = createSchema
username: true,
sshKeyId: true,
serverType: true,
+ enableDockerCleanup: true,
})
.required()
.extend({
command: z.string().optional(),
+ enableDockerCleanup: z.boolean().default(true),
});
export const apiUpdateServerMonitoring = createSchema
diff --git a/packages/server/src/db/schema/sso.ts b/packages/server/src/db/schema/sso.ts
index e95872fd4..502c9fcfa 100644
--- a/packages/server/src/db/schema/sso.ts
+++ b/packages/server/src/db/schema/sso.ts
@@ -10,7 +10,7 @@ export const ssoProvider = pgTable("sso_provider", {
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
providerId: text("provider_id").notNull().unique(),
- userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
+ userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "cascade",
}),
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index dd627deaf..7bda4615a 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -35,6 +35,7 @@ export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
+export * from "./services/proprietary/forward-auth";
export * from "./services/proprietary/license-key";
export * from "./services/proprietary/sso";
export * from "./services/redirect";
@@ -50,6 +51,7 @@ export * from "./services/user";
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";
@@ -127,6 +129,7 @@ export * from "./utils/tracking/hubspot";
export * from "./utils/traefik/application";
export * from "./utils/traefik/domain";
export * from "./utils/traefik/file-types";
+export * from "./utils/traefik/forward-auth";
export * from "./utils/traefik/middleware";
export * from "./utils/traefik/redirect";
export * from "./utils/traefik/security";
diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts
index ac1fbb449..c8ebb3be7 100644
--- a/packages/server/src/services/application.ts
+++ b/packages/server/src/services/application.ts
@@ -95,26 +95,22 @@ export const findApplicationById = async (applicationId: string) => {
const application = await db.query.applications.findFirst({
where: eq(applications.applicationId, applicationId),
with: {
- environment: {
- with: {
- project: true,
- },
- },
+ environment: { with: { project: true } },
domains: true,
deployments: true,
mounts: true,
redirects: true,
security: true,
ports: true,
- registry: true,
gitlab: true,
github: true,
bitbucket: true,
gitea: true,
server: true,
previewDeployments: true,
- buildRegistry: true,
- rollbackRegistry: true,
+ registry: { columns: { password: false } },
+ buildRegistry: { columns: { password: false } },
+ rollbackRegistry: { columns: { password: false } },
},
});
if (!application) {
diff --git a/packages/server/src/services/backup.ts b/packages/server/src/services/backup.ts
index e3951a89e..0e05d4080 100644
--- a/packages/server/src/services/backup.ts
+++ b/packages/server/src/services/backup.ts
@@ -34,7 +34,12 @@ export const findBackupById = async (backupId: string) => {
mariadb: true,
mongo: true,
libsql: true,
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
compose: true,
},
});
@@ -83,7 +88,12 @@ export const findBackupsByDbId = async (
mariadb: true,
mongo: true,
libsql: true,
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
},
});
return result || [];
diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts
index 0cec3418b..7a887cdc4 100644
--- a/packages/server/src/services/compose.ts
+++ b/packages/server/src/services/compose.ts
@@ -131,7 +131,12 @@ export const findComposeById = async (composeId: string) => {
server: true,
backups: {
with: {
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
deployments: true,
},
},
diff --git a/packages/server/src/services/git-provider.ts b/packages/server/src/services/git-provider.ts
index aafc7e947..942f94ed2 100644
--- a/packages/server/src/services/git-provider.ts
+++ b/packages/server/src/services/git-provider.ts
@@ -43,6 +43,38 @@ export const updateGitProvider = async (
.then((response) => response[0]);
};
+// Returns true if the user can edit the git source configuration of an existing
+// deploy that is connected to the given provider.
+// Owner/admin: always yes.
+// Member: only if they own the provider or it's shared with the org.
+// Being in accessedGitProviders only grants permission to connect NEW deploys,
+// not to modify the git config of an existing deploy owned by someone else.
+export const canEditDeployGitSource = async (
+ gitProviderId: string,
+ session: { userId: string; activeOrganizationId: string },
+): Promise => {
+ const { userId, activeOrganizationId } = session;
+
+ const memberRecord = await db.query.member.findFirst({
+ where: and(
+ eq(member.userId, userId),
+ eq(member.organizationId, activeOrganizationId),
+ ),
+ columns: { role: true },
+ });
+
+ if (memberRecord?.role === "owner") return true;
+
+ const provider = await db.query.gitProvider.findFirst({
+ where: eq(gitProvider.gitProviderId, gitProviderId),
+ columns: { userId: true, sharedWithOrganization: true },
+ });
+
+ if (!provider) return false;
+
+ return provider.userId === userId || provider.sharedWithOrganization;
+};
+
export const getAccessibleGitProviderIds = async (session: {
userId: string;
activeOrganizationId: string;
diff --git a/packages/server/src/services/libsql.ts b/packages/server/src/services/libsql.ts
index 8cd61b4af..dd2b82667 100644
--- a/packages/server/src/services/libsql.ts
+++ b/packages/server/src/services/libsql.ts
@@ -63,7 +63,12 @@ export const findLibsqlById = async (libsqlId: string) => {
server: true,
backups: {
with: {
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
deployments: true,
},
},
diff --git a/packages/server/src/services/mariadb.ts b/packages/server/src/services/mariadb.ts
index 40f9add9b..189ab39ad 100644
--- a/packages/server/src/services/mariadb.ts
+++ b/packages/server/src/services/mariadb.ts
@@ -68,7 +68,12 @@ export const findMariadbById = async (mariadbId: string) => {
server: true,
backups: {
with: {
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
deployments: true,
},
},
diff --git a/packages/server/src/services/mongo.ts b/packages/server/src/services/mongo.ts
index 2f4dd9af8..ad47cf04a 100644
--- a/packages/server/src/services/mongo.ts
+++ b/packages/server/src/services/mongo.ts
@@ -63,7 +63,12 @@ export const findMongoById = async (mongoId: string) => {
server: true,
backups: {
with: {
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
deployments: true,
},
},
diff --git a/packages/server/src/services/mysql.ts b/packages/server/src/services/mysql.ts
index 9590e1394..974f72f19 100644
--- a/packages/server/src/services/mysql.ts
+++ b/packages/server/src/services/mysql.ts
@@ -66,7 +66,12 @@ export const findMySqlById = async (mysqlId: string) => {
server: true,
backups: {
with: {
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
deployments: true,
},
},
diff --git a/packages/server/src/services/postgres.ts b/packages/server/src/services/postgres.ts
index 0900524d8..d4dddfdaf 100644
--- a/packages/server/src/services/postgres.ts
+++ b/packages/server/src/services/postgres.ts
@@ -76,7 +76,12 @@ export const findPostgresById = async (postgresId: string) => {
server: true,
backups: {
with: {
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
deployments: true,
},
},
diff --git a/packages/server/src/services/proprietary/forward-auth.ts b/packages/server/src/services/proprietary/forward-auth.ts
new file mode 100644
index 000000000..de1e1847d
--- /dev/null
+++ b/packages/server/src/services/proprietary/forward-auth.ts
@@ -0,0 +1,382 @@
+import { IS_CLOUD } from "@dokploy/server/constants";
+import { db } from "@dokploy/server/db";
+import {
+ forwardAuthSettings,
+ server,
+ ssoProvider,
+} from "@dokploy/server/db/schema";
+import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
+import {
+ deriveBaseDomain,
+ deriveCookieSecret,
+ type ForwardAuthOidcConfig,
+ forwardAuthCallbackUrl,
+ isForwardAuthRunning,
+ removeForwardAuth,
+ setupForwardAuth,
+} from "@dokploy/server/setup/forward-auth-setup";
+import { manageDomain } from "@dokploy/server/utils/traefik/domain";
+import {
+ manageForwardAuthDomain,
+ removeForwardAuthDomain,
+ removeForwardAuthMiddleware,
+} from "@dokploy/server/utils/traefik/forward-auth";
+import { TRPCError } from "@trpc/server";
+import { and, asc, desc, eq, isNotNull, isNull } from "drizzle-orm";
+import { findApplicationById } from "../application";
+import { findDomainById, updateDomainById } from "../domain";
+
+const resolveOidcConfig = (provider: {
+ issuer: string;
+ oidcConfig: string | null;
+}): ForwardAuthOidcConfig => {
+ if (!provider.oidcConfig) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Forward-auth requires an OIDC provider — SAML is not supported.",
+ });
+ }
+
+ let parsed: any;
+ try {
+ parsed = JSON.parse(provider.oidcConfig);
+ } catch {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to parse the SSO provider OIDC configuration",
+ });
+ }
+
+ if (!parsed?.clientId || !parsed?.clientSecret) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "SSO provider OIDC config is missing clientId/clientSecret",
+ });
+ }
+
+ return {
+ clientId: parsed.clientId,
+ clientSecret: parsed.clientSecret,
+ issuer: provider.issuer,
+ scopes: parsed.scopes,
+ skipDiscovery: parsed.skipDiscovery,
+ };
+};
+
+const findProviderForOrg = async (
+ providerId: string,
+ organizationId: string,
+) => {
+ const provider = await db.query.ssoProvider.findFirst({
+ where: and(
+ eq(ssoProvider.providerId, providerId),
+ eq(ssoProvider.organizationId, organizationId),
+ ),
+ columns: { providerId: true, issuer: true, oidcConfig: true },
+ });
+ if (!provider) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "SSO provider not found",
+ });
+ }
+ return provider;
+};
+
+export const listSsoProvidersForOrg = async (organizationId: string) => {
+ return db.query.ssoProvider.findMany({
+ where: and(
+ eq(ssoProvider.organizationId, organizationId),
+ isNotNull(ssoProvider.oidcConfig),
+ ),
+ columns: { providerId: true, issuer: true, domain: true },
+ orderBy: [asc(ssoProvider.createdAt)],
+ });
+};
+
+export const getDomainSsoStatus = async (
+ ctx: { session: { activeOrganizationId: string } },
+ domainId: string,
+) => {
+ const domain = await findDomainById(domainId);
+ if (domain.applicationId) {
+ await checkServicePermissionAndAccess(ctx as any, domain.applicationId, {
+ domain: ["read"],
+ });
+ }
+ return { enabled: !!domain.forwardAuthEnabled };
+};
+
+const settingsWhere = (serverId: string | null) =>
+ serverId
+ ? eq(forwardAuthSettings.serverId, serverId)
+ : isNull(forwardAuthSettings.serverId);
+
+export const getForwardAuthSettings = async (serverId: string | null) => {
+ return db.query.forwardAuthSettings.findFirst({
+ where: settingsWhere(serverId),
+ });
+};
+
+export const setForwardAuthSettings = async (input: {
+ organizationId: string;
+ serverId: string | null;
+ authDomain: string;
+ https: boolean;
+ certificateType: "none" | "letsencrypt" | "custom";
+ customCertResolver?: string | null;
+}) => {
+ const baseDomain = deriveBaseDomain(input.authDomain);
+ const existing = await getForwardAuthSettings(input.serverId);
+
+ const values = {
+ authDomain: input.authDomain,
+ baseDomain,
+ https: input.https,
+ certificateType: input.certificateType,
+ customCertResolver: input.customCertResolver ?? null,
+ };
+
+ if (existing) {
+ await db
+ .update(forwardAuthSettings)
+ .set(values)
+ .where(settingsWhere(input.serverId));
+ } else {
+ await db.insert(forwardAuthSettings).values({
+ ...values,
+ serverId: input.serverId,
+ });
+ }
+
+ await manageForwardAuthDomain(input.serverId, {
+ authDomain: input.authDomain,
+ https: input.https,
+ certificateType: input.certificateType,
+ customCertResolver: input.customCertResolver,
+ });
+
+ if (existing?.providerId) {
+ const proxyRunning = await isForwardAuthRunning(
+ input.serverId ?? undefined,
+ );
+ if (proxyRunning) {
+ await deployForwardAuthOnServer({
+ serverId: input.serverId ?? undefined,
+ providerId: existing.providerId,
+ organizationId: input.organizationId,
+ });
+ }
+ }
+
+ return { callbackUrl: forwardAuthCallbackUrl(input.authDomain, input.https) };
+};
+
+export const removeForwardAuthSettings = async (serverId: string | null) => {
+ const existing = await getForwardAuthSettings(serverId);
+ if (!existing) return { ok: true } as const;
+ await removeForwardAuthDomain(serverId);
+ await db.delete(forwardAuthSettings).where(settingsWhere(serverId));
+ return { ok: true } as const;
+};
+
+export const deployForwardAuthOnServer = async (input: {
+ serverId?: string;
+ providerId: string;
+ organizationId: string;
+}) => {
+ const settings = await getForwardAuthSettings(input.serverId ?? null);
+ if (!settings) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Set the authentication domain for this server before deploying the proxy.",
+ });
+ }
+
+ const provider = await findProviderForOrg(
+ input.providerId,
+ input.organizationId,
+ );
+ const oidc = resolveOidcConfig(provider);
+
+ await setupForwardAuth({
+ serverId: input.serverId,
+ oidc,
+ cookieSecret: deriveCookieSecret(
+ `${input.serverId ?? "host"}:${settings.baseDomain}`,
+ ),
+ authDomain: settings.authDomain,
+ baseDomain: settings.baseDomain,
+ authDomainHttps: settings.https,
+ });
+
+ if (settings.providerId !== input.providerId) {
+ await db
+ .update(forwardAuthSettings)
+ .set({ providerId: input.providerId })
+ .where(settingsWhere(input.serverId ?? null));
+ }
+
+ return { ok: true } as const;
+};
+
+const FORWARD_AUTH_CHECK_TIMEOUT_MS = 4000;
+
+const proxyStatus = async (
+ serverId: string | null,
+): Promise<"running" | "stopped" | "unknown"> => {
+ try {
+ const running = await Promise.race([
+ isForwardAuthRunning(serverId ?? undefined),
+ new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error("timeout")),
+ FORWARD_AUTH_CHECK_TIMEOUT_MS,
+ ),
+ ),
+ ]);
+ return running ? "running" : "stopped";
+ } catch {
+ return "unknown";
+ }
+};
+
+export const getForwardAuthServerStatus = async (organizationId: string) => {
+ const servers = await db.query.server.findMany({
+ where: and(
+ eq(server.organizationId, organizationId),
+ isNotNull(server.sshKeyId),
+ eq(server.serverType, "deploy"),
+ ),
+ columns: { serverId: true, name: true, ipAddress: true },
+ orderBy: [desc(server.createdAt)],
+ });
+
+ const targets: {
+ serverId: string | null;
+ name: string;
+ ipAddress: string | null;
+ }[] = [
+ ...(IS_CLOUD
+ ? []
+ : [
+ {
+ serverId: null,
+ name: "Dokploy Server (local)",
+ ipAddress: null,
+ },
+ ]),
+ ...servers.map((s) => ({
+ serverId: s.serverId,
+ name: s.name,
+ ipAddress: s.ipAddress,
+ })),
+ ];
+
+ return Promise.all(
+ targets.map(async (t) => {
+ const settings = await getForwardAuthSettings(t.serverId);
+ return {
+ ...t,
+ status: await proxyStatus(t.serverId),
+ authDomain: settings?.authDomain ?? null,
+ https: settings?.https ?? true,
+ certificateType: settings?.certificateType ?? "none",
+ customCertResolver: settings?.customCertResolver ?? null,
+ callbackUrl: settings
+ ? forwardAuthCallbackUrl(settings.authDomain, settings.https)
+ : null,
+ };
+ }),
+ );
+};
+
+export const removeForwardAuthProxy = async (serverId: string | null) => {
+ await removeForwardAuth(serverId ?? undefined);
+ await db
+ .update(forwardAuthSettings)
+ .set({ providerId: null })
+ .where(settingsWhere(serverId));
+ return { ok: true } as const;
+};
+
+const resolveApplicationDomain = async (domainId: string) => {
+ const domain = await findDomainById(domainId);
+ if (!domain.applicationId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "SSO forward-auth is currently only supported on application domains",
+ });
+ }
+ const application = await findApplicationById(domain.applicationId);
+ return { domain, application };
+};
+
+export const assertApplicationDomainAccess = async (
+ ctx: { session: { activeOrganizationId: string } },
+ domainId: string,
+ action: "create" | "delete",
+) => {
+ const domain = await findDomainById(domainId);
+ if (!domain.applicationId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "SSO forward-auth is currently only supported on application domains",
+ });
+ }
+ await checkServicePermissionAndAccess(ctx as any, domain.applicationId, {
+ domain: [action],
+ });
+ return domain;
+};
+
+export const enableForwardAuthOnDomain = async (input: {
+ domainId: string;
+}) => {
+ const { application } = await resolveApplicationDomain(input.domainId);
+ const serverId = application.serverId ?? undefined;
+
+ const settings = await getForwardAuthSettings(serverId ?? null);
+ if (!settings?.providerId) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Deploy the authentication proxy for this server in SSO settings first.",
+ });
+ }
+
+ const proxyRunning = await isForwardAuthRunning(serverId);
+ if (!proxyRunning) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "The authentication proxy is not deployed on this server. Deploy it in SSO settings first.",
+ });
+ }
+
+ await updateDomainById(input.domainId, { forwardAuthEnabled: true });
+ const domain = await findDomainById(input.domainId);
+ await manageDomain(application, domain);
+
+ return { ok: true } as const;
+};
+
+export const disableForwardAuthOnDomain = async (input: {
+ domainId: string;
+}) => {
+ const { application, domain } = await resolveApplicationDomain(
+ input.domainId,
+ );
+ const uniqueConfigKey = domain.uniqueConfigKey;
+
+ await updateDomainById(input.domainId, { forwardAuthEnabled: false });
+ const updated = await findDomainById(input.domainId);
+ await manageDomain(application, updated);
+ await removeForwardAuthMiddleware(application, uniqueConfigKey);
+
+ return { ok: true } as const;
+};
diff --git a/packages/server/src/services/registry.ts b/packages/server/src/services/registry.ts
index 65ba80921..e395dd4c9 100644
--- a/packages/server/src/services/registry.ts
+++ b/packages/server/src/services/registry.ts
@@ -27,6 +27,16 @@ export function safeDockerLoginCommand(
return `printf %s ${escapedPassword} | docker login ${escapedRegistry} -u ${escapedUser} --password-stdin`;
}
+function sanitizeRegistryError(
+ error: unknown,
+ password: string | null | undefined,
+): string {
+ const message =
+ error instanceof Error ? error.message : "Error with registry login";
+ if (!password) return message;
+ return message.split(password).join("***");
+}
+
export const createRegistry = async (
input: z.infer,
organizationId: string,
@@ -59,10 +69,15 @@ export const createRegistry = async (
input.username,
input.password,
);
- if (input.serverId && input.serverId !== "none") {
- await execAsyncRemote(input.serverId, loginCommand);
- } else if (newRegistry.registryType === "cloud") {
- await execAsync(loginCommand);
+ try {
+ if (input.serverId && input.serverId !== "none") {
+ await execAsyncRemote(input.serverId, loginCommand);
+ } else if (newRegistry.registryType === "cloud") {
+ await execAsync(loginCommand);
+ }
+ } catch (error) {
+ const sanitized = sanitizeRegistryError(error, input.password);
+ throw new TRPCError({ code: "BAD_REQUEST", message: sanitized });
}
return newRegistry;
@@ -129,16 +144,24 @@ export const updateRegistry = async (
});
}
- if (registryData?.serverId && registryData?.serverId !== "none") {
- await execAsyncRemote(registryData.serverId, loginCommand);
- } else if (response?.registryType === "cloud") {
- await execAsync(loginCommand);
+ try {
+ if (registryData?.serverId && registryData?.serverId !== "none") {
+ await execAsyncRemote(registryData.serverId, loginCommand);
+ } else if (response?.registryType === "cloud") {
+ await execAsync(loginCommand);
+ }
+ } catch (execError) {
+ throw new Error(sanitizeRegistryError(execError, response?.password));
}
return response;
} catch (error) {
const message =
- error instanceof Error ? error.message : "Error updating this registry";
+ error instanceof TRPCError
+ ? error.message
+ : error instanceof Error
+ ? error.message
+ : "Error updating this registry";
throw new TRPCError({
code: "BAD_REQUEST",
message,
@@ -162,6 +185,19 @@ export const findRegistryById = async (registryId: string) => {
return registryResponse;
};
+export const findRegistryByIdWithCredentials = async (registryId: string) => {
+ const registryResponse = await db.query.registry.findFirst({
+ where: eq(registry.registryId, registryId),
+ });
+ if (!registryResponse) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Registry not found",
+ });
+ }
+ return registryResponse;
+};
+
export const findAllRegistryByOrganizationId = async (
organizationId: string,
) => {
diff --git a/packages/server/src/services/rollbacks.ts b/packages/server/src/services/rollbacks.ts
index 51d978572..97fa4b1c5 100644
--- a/packages/server/src/services/rollbacks.ts
+++ b/packages/server/src/services/rollbacks.ts
@@ -7,7 +7,6 @@ import {
deployments as deploymentsSchema,
rollbacks,
} from "../db/schema";
-import type { ApplicationNested } from "../utils/builders";
import { getRegistryTag } from "../utils/cluster/upload";
import {
calculateResources,
@@ -23,7 +22,11 @@ import { findDeploymentById } from "./deployment";
import type { Mount } from "./mount";
import type { Port } from "./port";
import type { Project } from "./project";
-import { type Registry, safeDockerLoginCommand } from "./registry";
+import {
+ findRegistryByIdWithCredentials,
+ type Registry,
+ safeDockerLoginCommand,
+} from "./registry";
export const createRollback = async (
input: z.infer,
@@ -56,11 +59,29 @@ export const createRollback = async (
...rest
} = await findApplicationById(deployment.applicationId);
+ const registry = rest.registryId
+ ? await findRegistryByIdWithCredentials(rest.registryId)
+ : rest.registry;
+ const buildRegistry = rest.buildRegistryId
+ ? await findRegistryByIdWithCredentials(rest.buildRegistryId)
+ : rest.buildRegistry;
+ const rollbackRegistry = rest.rollbackRegistryId
+ ? await findRegistryByIdWithCredentials(rest.rollbackRegistryId)
+ : rest.rollbackRegistry;
+
+ const fullContextWithCredentials = {
+ ...rest,
+ registry,
+ buildRegistry,
+ rollbackRegistry,
+ };
+
await tx
.update(rollbacks)
.set({
image: tagImage,
- fullContext: rest,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fullContext: fullContextWithCredentials as any,
})
.where(eq(rollbacks.rollbackId, rollback.rollbackId));
@@ -162,7 +183,6 @@ export const rollback = async (rollbackId: string) => {
if (!result.fullContext) {
throw new Error("Rollback context not found");
}
- // Use the full context for rollback
await rollbackApplication(
application.appName,
result.image || "",
@@ -198,24 +218,25 @@ const rollbackApplication = async (
};
mounts: Mount[];
ports: Port[];
- rollbackRegistry?: Registry;
+ rollbackRegistry?: Registry | null;
},
) => {
if (!fullContext) {
throw new Error("Full context is required for rollback");
}
+ const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
+
// Ensure Docker daemon is authenticated with the rollback registry
// before updating the swarm service. The authconfig in CreateServiceOptions
// alone is not sufficient — Docker Swarm also relies on the daemon's
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
- if (fullContext.rollbackRegistry) {
- await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
+ if (rollbackRegistry) {
+ await dockerLoginForRegistry(rollbackRegistry, serverId);
}
const docker = await getRemoteDocker(serverId);
- // Use the same configuration as mechanizeDockerContainer
const {
env,
mounts,
@@ -246,7 +267,9 @@ const rollbackApplication = async (
UpdateConfig,
Networks,
Ulimits,
- } = generateConfigContainer(fullContext as ApplicationNested);
+ } = generateConfigContainer(
+ fullContext as Parameters[0],
+ );
const bindsMount = generateBindMounts(mounts);
const envVariables = prepareEnvironmentVariables(
@@ -254,18 +277,16 @@ const rollbackApplication = async (
fullContext.environment.project.env,
);
- // Build the full registry image path if rollbackRegistry is available
- // e.g., "appName:v5" -> "siumauricio/appName:v5" or "registry.com/prefix/appName:v5"
let rollbackImage = image;
- if (fullContext.rollbackRegistry) {
- rollbackImage = getRegistryTag(fullContext.rollbackRegistry, image);
+ if (rollbackRegistry) {
+ rollbackImage = getRegistryTag(rollbackRegistry, image);
}
const settings: CreateServiceOptions = {
authconfig: {
- password: fullContext.rollbackRegistry?.password || "",
- username: fullContext.rollbackRegistry?.username || "",
- serveraddress: fullContext.rollbackRegistry?.registryUrl || "",
+ password: rollbackRegistry?.password || "",
+ username: rollbackRegistry?.username || "",
+ serveraddress: rollbackRegistry?.registryUrl || "",
},
Name: appName,
TaskTemplate: {
diff --git a/packages/server/src/services/volume-backups.ts b/packages/server/src/services/volume-backups.ts
index b91aedc7c..abd29df1f 100644
--- a/packages/server/src/services/volume-backups.ts
+++ b/packages/server/src/services/volume-backups.ts
@@ -84,7 +84,12 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
},
},
},
- destination: true,
+ destination: {
+ columns: {
+ accessKey: false,
+ secretAccessKey: false,
+ },
+ },
},
});
diff --git a/packages/server/src/setup/forward-auth-setup.ts b/packages/server/src/setup/forward-auth-setup.ts
new file mode 100644
index 000000000..cec525064
--- /dev/null
+++ b/packages/server/src/setup/forward-auth-setup.ts
@@ -0,0 +1,158 @@
+import { createHmac } from "node:crypto";
+import type { CreateServiceOptions } from "dockerode";
+import { betterAuthSecret } from "../lib/auth-secret";
+import { getRemoteDocker } from "../utils/servers/remote-docker";
+
+export const FORWARD_AUTH_SERVICE_NAME = "dokploy-forward-auth";
+const FORWARD_AUTH_IMAGE = "quay.io/oauth2-proxy/oauth2-proxy:v7.6.0";
+
+export const FORWARD_AUTH_PORT = 4180;
+
+export interface ForwardAuthOidcConfig {
+ clientId: string;
+ clientSecret: string;
+ issuer: string;
+ scopes?: string[];
+ skipDiscovery?: boolean;
+}
+
+export interface SetupForwardAuthOptions {
+ serverId?: string;
+ oidc: ForwardAuthOidcConfig;
+ cookieSecret: string;
+ authDomain: string;
+ baseDomain: string;
+ authDomainHttps?: boolean;
+ emailDomains?: string[];
+}
+
+export const deriveBaseDomain = (authDomain: string): string => {
+ const labels = authDomain.trim().toLowerCase().split(".").filter(Boolean);
+ const base = labels.length > 2 ? labels.slice(1) : labels;
+ return `.${base.join(".")}`;
+};
+
+export const forwardAuthCallbackUrl = (
+ authDomain: string,
+ https: boolean,
+): string => `${https ? "https" : "http"}://${authDomain}/oauth2/callback`;
+
+export const deriveCookieSecret = (salt: string): string => {
+ // oauth2-proxy requires cookie_secret to be exactly 16, 24, or 32 bytes.
+ // Take the first 32 hex chars (= 16 bytes) to satisfy that constraint.
+ return createHmac("sha256", betterAuthSecret)
+ .update(`forward-auth:${salt}`)
+ .digest("hex")
+ .slice(0, 32);
+};
+
+export const buildForwardAuthEnv = (
+ options: SetupForwardAuthOptions,
+): string[] => {
+ const { oidc, cookieSecret, authDomain, baseDomain, authDomainHttps } =
+ options;
+ const scheme = authDomainHttps ? "https" : "http";
+ const emailDomains =
+ options.emailDomains && options.emailDomains.length > 0
+ ? options.emailDomains
+ : ["*"];
+
+ const env: string[] = [
+ "OAUTH2_PROXY_PROVIDER=oidc",
+ `OAUTH2_PROXY_OIDC_ISSUER_URL=${oidc.issuer}`,
+ `OAUTH2_PROXY_CLIENT_ID=${oidc.clientId}`,
+ `OAUTH2_PROXY_CLIENT_SECRET=${oidc.clientSecret}`,
+ `OAUTH2_PROXY_COOKIE_SECRET=${cookieSecret}`,
+ `OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:${FORWARD_AUTH_PORT}`,
+ "OAUTH2_PROXY_REVERSE_PROXY=true",
+ "OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true",
+ "OAUTH2_PROXY_SET_XAUTHREQUEST=true",
+ "OAUTH2_PROXY_UPSTREAMS=static://202",
+ `OAUTH2_PROXY_REDIRECT_URL=${scheme}://${authDomain}/oauth2/callback`,
+ `OAUTH2_PROXY_COOKIE_DOMAINS=${baseDomain}`,
+ `OAUTH2_PROXY_WHITELIST_DOMAINS=${baseDomain}`,
+ `OAUTH2_PROXY_COOKIE_SECURE=${authDomainHttps ? "true" : "false"}`,
+ "OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
+ `OAUTH2_PROXY_EMAIL_DOMAINS=${emailDomains.join(",")}`,
+ ];
+
+ const scopes = oidc.scopes?.length
+ ? oidc.scopes
+ : ["openid", "email", "profile"];
+ env.push(`OAUTH2_PROXY_SCOPE=${scopes.join(" ")}`);
+
+ if (oidc.skipDiscovery) {
+ env.push("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
+ }
+
+ return env;
+};
+
+export const setupForwardAuth = async (options: SetupForwardAuthOptions) => {
+ const { serverId } = options;
+ const docker = await getRemoteDocker(serverId);
+
+ const settings: CreateServiceOptions = {
+ Name: FORWARD_AUTH_SERVICE_NAME,
+ TaskTemplate: {
+ ContainerSpec: {
+ Image: FORWARD_AUTH_IMAGE,
+ Env: buildForwardAuthEnv(options),
+ },
+ Networks: [{ Target: "dokploy-network" }],
+ Placement: {
+ Constraints: ["node.role==manager"],
+ },
+ },
+ Mode: {
+ Replicated: {
+ Replicas: 1,
+ },
+ },
+ };
+
+ try {
+ const service = docker.getService(FORWARD_AUTH_SERVICE_NAME);
+ const inspect = await service.inspect();
+ await service.update({
+ version: Number.parseInt(inspect.Version.Index),
+ ...settings,
+ TaskTemplate: {
+ ...settings.TaskTemplate,
+ ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
+ },
+ });
+ console.log("Forward Auth Updated ✅");
+ } catch (_) {
+ try {
+ await docker.createService(settings);
+ console.log("Forward Auth Started ✅");
+ } catch (error: any) {
+ if (error?.statusCode !== 409) {
+ throw error;
+ }
+ console.log("Forward Auth service already exists, continuing...");
+ }
+ }
+};
+
+export const removeForwardAuth = async (serverId?: string) => {
+ const docker = await getRemoteDocker(serverId);
+ try {
+ const service = docker.getService(FORWARD_AUTH_SERVICE_NAME);
+ await service.remove();
+ console.log("Forward Auth Removed ✅");
+ } catch {}
+};
+
+export const isForwardAuthRunning = async (
+ serverId?: string,
+): Promise => {
+ const docker = await getRemoteDocker(serverId);
+ try {
+ await docker.getService(FORWARD_AUTH_SERVICE_NAME).inspect();
+ return true;
+ } catch {
+ return false;
+ }
+};
diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts
index 6640590b0..296498ff3 100644
--- a/packages/server/src/utils/backups/compose.ts
+++ b/packages/server/src/utils/backups/compose.ts
@@ -4,6 +4,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
+import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
@@ -23,7 +24,7 @@ export const runComposeBackup = async (
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix, databaseType, serviceName } = backup;
- const destination = backup.destination;
+ const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts
index 876579cb1..8f0e0b6dd 100644
--- a/packages/server/src/utils/backups/index.ts
+++ b/packages/server/src/utils/backups/index.ts
@@ -1,6 +1,7 @@
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
import { member } from "@dokploy/server/db/schema";
import type { BackupSchedule } from "@dokploy/server/services/backup";
+import { findDestinationById } from "@dokploy/server/services/destination";
import { getAllServers } from "@dokploy/server/services/server";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { eq } from "drizzle-orm";
@@ -131,9 +132,10 @@ export const keepLatestNBackups = async (
if (!backup.keepLatestCount) return;
try {
- const rcloneFlags = getS3Credentials(backup.destination);
+ const destination = await findDestinationById(backup.destinationId);
+ const rcloneFlags = getS3Credentials(destination);
const appName = getServiceAppName(backup);
- const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
+ const backupFilesPath = `:s3:${destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
// --include "*.bson.gz" or "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" ${backupFilesPath}`;
diff --git a/packages/server/src/utils/backups/libsql.ts b/packages/server/src/utils/backups/libsql.ts
index a994db8bd..7f6c85f48 100644
--- a/packages/server/src/utils/backups/libsql.ts
+++ b/packages/server/src/utils/backups/libsql.ts
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
+import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Libsql } from "@dokploy/server/services/libsql";
import { findProjectById } from "@dokploy/server/services/project";
@@ -29,7 +30,7 @@ export const runLibsqlBackup = async (
description: "Initializing Backup",
});
const { prefix } = backup;
- const destination = backup.destination;
+ const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts
index dea22ff18..121910ee8 100644
--- a/packages/server/src/utils/backups/mariadb.ts
+++ b/packages/server/src/utils/backups/mariadb.ts
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
+import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Mariadb } from "@dokploy/server/services/mariadb";
import { findProjectById } from "@dokploy/server/services/project";
@@ -23,7 +24,7 @@ export const runMariadbBackup = async (
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
- const destination = backup.destination;
+ const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts
index cebece14f..4b212d70c 100644
--- a/packages/server/src/utils/backups/mongo.ts
+++ b/packages/server/src/utils/backups/mongo.ts
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
+import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Mongo } from "@dokploy/server/services/mongo";
import { findProjectById } from "@dokploy/server/services/project";
@@ -20,7 +21,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
- const destination = backup.destination;
+ const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts
index a72f59880..8d0e6188a 100644
--- a/packages/server/src/utils/backups/mysql.ts
+++ b/packages/server/src/utils/backups/mysql.ts
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
+import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { MySql } from "@dokploy/server/services/mysql";
import { findProjectById } from "@dokploy/server/services/project";
@@ -20,7 +21,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
- const destination = backup.destination;
+ const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts
index 30a88db2b..e57682f39 100644
--- a/packages/server/src/utils/backups/postgres.ts
+++ b/packages/server/src/utils/backups/postgres.ts
@@ -3,6 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
+import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project";
@@ -29,7 +30,7 @@ export const runPostgresBackup = async (
description: "Initializing Backup",
});
const { prefix } = backup;
- const destination = backup.destination;
+ const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts
index 69bde1b99..82bbf4eff 100644
--- a/packages/server/src/utils/builders/index.ts
+++ b/packages/server/src/utils/builders/index.ts
@@ -1,3 +1,4 @@
+import { findRegistryByIdWithCredentials } from "@dokploy/server/services/registry";
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { getRegistryTag, uploadImageRemoteCommand } from "../cluster/upload";
@@ -28,9 +29,9 @@ export type ApplicationNested = InferResultType<
security: true;
redirects: true;
ports: true;
- registry: true;
- buildRegistry: true;
- rollbackRegistry: true;
+ registry: { columns: { password: false } };
+ buildRegistry: { columns: { password: false } };
+ rollbackRegistry: { columns: { password: false } };
deployments: true;
environment: { with: { project: true } };
}
@@ -121,8 +122,8 @@ export const mechanizeDockerContainer = async (
application.environment.env,
);
- const image = getImageName(application);
- const authConfig = getAuthConfig(application);
+ const image = await getImageName(application);
+ const authConfig = await getAuthConfig(application);
const docker = await getRemoteDocker(application.serverId);
const settings: CreateServiceOptions = {
@@ -190,7 +191,7 @@ export const mechanizeDockerContainer = async (
}
};
-const getImageName = (application: ApplicationNested) => {
+const getImageName = async (application: ApplicationNested) => {
const { appName, sourceType, dockerImage, registry, buildRegistry } =
application;
const imageName = `${appName}:latest`;
@@ -199,18 +200,18 @@ const getImageName = (application: ApplicationNested) => {
}
if (registry) {
- const registryTag = getRegistryTag(registry, imageName);
- return registryTag;
+ const r = await findRegistryByIdWithCredentials(registry.registryId);
+ return getRegistryTag(r, imageName);
}
if (buildRegistry) {
- const registryTag = getRegistryTag(buildRegistry, imageName);
- return registryTag;
+ const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
+ return getRegistryTag(r, imageName);
}
return imageName;
};
-export const getAuthConfig = (application: ApplicationNested) => {
+export const getAuthConfig = async (application: ApplicationNested) => {
const {
registry,
buildRegistry,
@@ -222,23 +223,21 @@ export const getAuthConfig = (application: ApplicationNested) => {
if (sourceType === "docker") {
if (username && password) {
- return {
- password,
- username,
- serveraddress: registryUrl || "",
- };
+ return { password, username, serveraddress: registryUrl || "" };
}
} else if (registry) {
+ const r = await findRegistryByIdWithCredentials(registry.registryId);
return {
- password: registry.password,
- username: registry.username,
- serveraddress: registry.registryUrl,
+ password: r.password,
+ username: r.username,
+ serveraddress: r.registryUrl,
};
} else if (buildRegistry) {
+ const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
return {
- password: buildRegistry.password,
- username: buildRegistry.username,
- serveraddress: buildRegistry.registryUrl,
+ password: r.password,
+ username: r.username,
+ serveraddress: r.registryUrl,
};
}
diff --git a/packages/server/src/utils/cluster/upload.ts b/packages/server/src/utils/cluster/upload.ts
index 6bf02547c..b4235c767 100644
--- a/packages/server/src/utils/cluster/upload.ts
+++ b/packages/server/src/utils/cluster/upload.ts
@@ -1,5 +1,9 @@
import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment";
-import type { Registry } from "@dokploy/server/services/registry";
+import {
+ findRegistryByIdWithCredentials,
+ safeDockerLoginCommand,
+ type Registry,
+} from "@dokploy/server/services/registry";
import { createRollback } from "@dokploy/server/services/rollbacks";
import type { ApplicationNested } from "../builders";
@@ -22,19 +26,19 @@ export const uploadImageRemoteCommand = async (
const commands: string[] = [];
if (registry) {
- const registryTag = getRegistryTag(registry, imageName);
+ const r = await findRegistryByIdWithCredentials(registry.registryId);
+ const registryTag = getRegistryTag(r, imageName);
if (registryTag) {
commands.push(`echo "📦 [Enabled Registry Swarm]"`);
- commands.push(getRegistryCommands(registry, imageName, registryTag));
+ commands.push(getRegistryCommands(r, imageName, registryTag));
}
}
if (buildRegistry) {
- const buildRegistryTag = getRegistryTag(buildRegistry, imageName);
+ const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
+ const buildRegistryTag = getRegistryTag(r, imageName);
if (buildRegistryTag) {
commands.push(`echo "🔑 [Enabled Build Registry]"`);
- commands.push(
- getRegistryCommands(buildRegistry, imageName, buildRegistryTag),
- );
+ commands.push(getRegistryCommands(r, imageName, buildRegistryTag));
commands.push(
`echo "⚠️ INFO: After the build is finished, you need to wait a few seconds for the server to download the image and run the container."`,
);
@@ -57,15 +61,13 @@ export const uploadImageRemoteCommand = async (
deploymentId: deploymentId,
});
- const rollbackRegistryTag = getRegistryTag(
- rollbackRegistry,
- rollback?.image || "",
+ const r = await findRegistryByIdWithCredentials(
+ rollbackRegistry.registryId,
);
+ const rollbackRegistryTag = getRegistryTag(r, rollback?.image || "");
if (rollbackRegistryTag) {
commands.push(`echo "🔄 [Enabled Rollback Registry]"`);
- commands.push(
- getRegistryCommands(rollbackRegistry, imageName, rollbackRegistryTag),
- );
+ commands.push(getRegistryCommands(r, imageName, rollbackRegistryTag));
}
}
try {
@@ -74,6 +76,7 @@ export const uploadImageRemoteCommand = async (
throw error;
}
};
+
/**
* Extract the repository name from imageName by taking the last part after '/'
* Examples:
@@ -115,19 +118,24 @@ const getRegistryCommands = (
imageName: string,
registryTag: string,
): string => {
+ const loginCmd = safeDockerLoginCommand(
+ registry.registryUrl,
+ registry.username,
+ registry.password,
+ );
return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
-echo "${registry.password}" | docker login ${registry.registryUrl} -u '${registry.username}' --password-stdin || {
+${loginCmd} || {
echo "❌ DockerHub Failed" ;
exit 1;
}
echo "✅ Registry Login Success" ;
-docker tag ${imageName} ${registryTag} || {
+docker tag ${imageName} ${registryTag} || {
echo "❌ Error tagging image" ;
exit 1;
}
echo "✅ Image Tagged" ;
-docker push ${registryTag} || {
+docker push ${registryTag} || {
echo "❌ Error pushing image" ;
exit 1;
}
diff --git a/packages/server/src/utils/databases/libsql.ts b/packages/server/src/utils/databases/libsql.ts
index e3074ceca..70937c64e 100644
--- a/packages/server/src/utils/databases/libsql.ts
+++ b/packages/server/src/utils/databases/libsql.ts
@@ -140,7 +140,11 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
: []),
],
},
- UpdateConfig,
+ UpdateConfig: libsql.updateConfigSwarm ?? {
+ Parallelism: 1,
+ Order: "stop-first" as const,
+ FailureAction: "rollback" as const,
+ },
};
try {
const service = docker.getService(appName);
diff --git a/packages/server/src/utils/databases/mariadb.ts b/packages/server/src/utils/databases/mariadb.ts
index bd3ba31f6..3e5a9ef49 100644
--- a/packages/server/src/utils/databases/mariadb.ts
+++ b/packages/server/src/utils/databases/mariadb.ts
@@ -111,7 +111,11 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
]
: [],
},
- UpdateConfig,
+ UpdateConfig: mariadb.updateConfigSwarm ?? {
+ Parallelism: 1,
+ Order: "stop-first" as const,
+ FailureAction: "rollback" as const,
+ },
};
try {
const service = docker.getService(appName);
diff --git a/packages/server/src/utils/databases/mongo.ts b/packages/server/src/utils/databases/mongo.ts
index 6644ea5c4..c1617fac0 100644
--- a/packages/server/src/utils/databases/mongo.ts
+++ b/packages/server/src/utils/databases/mongo.ts
@@ -167,7 +167,11 @@ ${command ?? "wait $MONGOD_PID"}`;
]
: [],
},
- UpdateConfig,
+ UpdateConfig: mongo.updateConfigSwarm ?? {
+ Parallelism: 1,
+ Order: "stop-first" as const,
+ FailureAction: "rollback" as const,
+ },
};
try {
diff --git a/packages/server/src/utils/databases/mysql.ts b/packages/server/src/utils/databases/mysql.ts
index f68cb42de..34dd6c21e 100644
--- a/packages/server/src/utils/databases/mysql.ts
+++ b/packages/server/src/utils/databases/mysql.ts
@@ -117,7 +117,11 @@ export const buildMysql = async (mysql: MysqlNested) => {
]
: [],
},
- UpdateConfig,
+ UpdateConfig: mysql.updateConfigSwarm ?? {
+ Parallelism: 1,
+ Order: "stop-first" as const,
+ FailureAction: "rollback" as const,
+ },
};
try {
const service = docker.getService(appName);
diff --git a/packages/server/src/utils/databases/postgres.ts b/packages/server/src/utils/databases/postgres.ts
index 6be8f19c3..6eddb4581 100644
--- a/packages/server/src/utils/databases/postgres.ts
+++ b/packages/server/src/utils/databases/postgres.ts
@@ -109,7 +109,11 @@ export const buildPostgres = async (postgres: PostgresNested) => {
]
: [],
},
- UpdateConfig,
+ UpdateConfig: postgres.updateConfigSwarm ?? {
+ Parallelism: 1,
+ Order: "stop-first" as const,
+ FailureAction: "rollback" as const,
+ },
};
try {
const service = docker.getService(appName);
diff --git a/packages/server/src/utils/databases/redis.ts b/packages/server/src/utils/databases/redis.ts
index fd358ee51..21388c848 100644
--- a/packages/server/src/utils/databases/redis.ts
+++ b/packages/server/src/utils/databases/redis.ts
@@ -115,7 +115,11 @@ export const buildRedis = async (redis: RedisNested) => {
]
: [],
},
- UpdateConfig,
+ UpdateConfig: redis.updateConfigSwarm ?? {
+ Parallelism: 1,
+ Order: "stop-first" as const,
+ FailureAction: "rollback" as const,
+ },
};
try {
diff --git a/packages/server/src/utils/providers/docker.ts b/packages/server/src/utils/providers/docker.ts
index 06f962dc7..f3a4c39f3 100644
--- a/packages/server/src/utils/providers/docker.ts
+++ b/packages/server/src/utils/providers/docker.ts
@@ -1,3 +1,4 @@
+import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
import type { ApplicationNested } from "../builders";
export const buildRemoteDocker = async (application: ApplicationNested) => {
@@ -13,7 +14,7 @@ echo "Pulling ${dockerImage}";
if (username && password) {
command += `
-if ! echo "${password}" | docker login --username "${username}" --password-stdin "${registryUrl || ""}" 2>&1; then
+if ! ${safeDockerLoginCommand(registryUrl || "", username, password)} 2>&1; then
echo "❌ Login failed";
exit 1;
fi
diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts
index 20cac27fb..946c94a38 100644
--- a/packages/server/src/utils/restore/compose.ts
+++ b/packages/server/src/utils/restore/compose.ts
@@ -77,9 +77,9 @@ export const restoreComposeBackup = async (
});
emit("Starting restore...");
- emit(`Backup path: ${backupPath}`);
-
- emit(`Executing command: ${restoreCommand}`);
+ emit(
+ `Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
+ );
if (serverId) {
await execAsyncRemote(serverId, restoreCommand);
diff --git a/packages/server/src/utils/restore/libsql.ts b/packages/server/src/utils/restore/libsql.ts
index e984826f8..f50f0c6da 100644
--- a/packages/server/src/utils/restore/libsql.ts
+++ b/packages/server/src/utils/restore/libsql.ts
@@ -21,15 +21,13 @@ export const restoreLibsqlBackup = async (
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
- emit("Starting restore...");
- emit(`Backup path: ${backupPath}`);
-
const containerSearch = getServiceContainerCommand(appName);
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
const command = `CONTAINER_ID=$(${containerSearch}) && ${rcloneCommand} | ${restoreCommand}`;
- emit(`Executing command: ${command}`);
+ emit("Starting restore...");
+ emit(`Restoring libsql from ${backupInput.backupFile}`);
if (serverId) {
await execAsyncRemote(serverId, command);
diff --git a/packages/server/src/utils/restore/mariadb.ts b/packages/server/src/utils/restore/mariadb.ts
index ffbceba76..afa167280 100644
--- a/packages/server/src/utils/restore/mariadb.ts
+++ b/packages/server/src/utils/restore/mariadb.ts
@@ -34,8 +34,9 @@ export const restoreMariadbBackup = async (
});
emit("Starting restore...");
-
- emit(`Executing command: ${command}`);
+ emit(
+ `Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
+ );
if (serverId) {
await execAsyncRemote(serverId, command);
diff --git a/packages/server/src/utils/restore/mongo.ts b/packages/server/src/utils/restore/mongo.ts
index 4329a4985..91eb9af5a 100644
--- a/packages/server/src/utils/restore/mongo.ts
+++ b/packages/server/src/utils/restore/mongo.ts
@@ -34,8 +34,9 @@ export const restoreMongoBackup = async (
});
emit("Starting restore...");
-
- emit(`Executing command: ${command}`);
+ emit(
+ `Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
+ );
if (serverId) {
await execAsyncRemote(serverId, command);
diff --git a/packages/server/src/utils/restore/mysql.ts b/packages/server/src/utils/restore/mysql.ts
index f5187242c..c955ef960 100644
--- a/packages/server/src/utils/restore/mysql.ts
+++ b/packages/server/src/utils/restore/mysql.ts
@@ -33,8 +33,9 @@ export const restoreMySqlBackup = async (
});
emit("Starting restore...");
-
- emit(`Executing command: ${command}`);
+ emit(
+ `Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
+ );
if (serverId) {
await execAsyncRemote(serverId, command);
diff --git a/packages/server/src/utils/restore/postgres.ts b/packages/server/src/utils/restore/postgres.ts
index 19f32989f..8cbbe2175 100644
--- a/packages/server/src/utils/restore/postgres.ts
+++ b/packages/server/src/utils/restore/postgres.ts
@@ -22,9 +22,6 @@ export const restorePostgresBackup = async (
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
- emit("Starting restore...");
- emit(`Backup path: ${backupPath}`);
-
const command = getRestoreCommand({
appName,
credentials: {
@@ -36,7 +33,10 @@ export const restorePostgresBackup = async (
restoreType: "database",
});
- emit(`Executing command: ${command}`);
+ emit("Starting restore...");
+ emit(
+ `Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
+ );
if (serverId) {
await execAsyncRemote(serverId, command);
diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts
index 596758b33..d35473a54 100644
--- a/packages/server/src/utils/traefik/domain.ts
+++ b/packages/server/src/utils/traefik/domain.ts
@@ -10,6 +10,11 @@ import {
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig, HttpRouter } from "./file-types";
+import {
+ createForwardAuthMiddleware,
+ forwardAuthMiddlewareName,
+ removeForwardAuthMiddleware,
+} from "./forward-auth";
import { createPathMiddlewares, removePathMiddlewares } from "./middleware";
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
@@ -48,6 +53,10 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
config.http.services[serviceName] = createServiceConfig(appName, domain);
await createPathMiddlewares(app, domain);
+ // SSO forward-auth: writes the per-app forwardAuth + errors middlewares (the
+ // /oauth2/* router lives on the central auth domain, not here). No-op unless
+ // the domain links a provider and the org has an auth domain configured.
+ await createForwardAuthMiddleware(app, domain);
if (app.serverId) {
await writeTraefikConfigRemote(config, appName, app.serverId);
@@ -84,6 +93,7 @@ export const removeDomain = async (
}
await removePathMiddlewares(application, uniqueKey);
+ await removeForwardAuthMiddleware(application, uniqueKey);
// verify if is the last router if so we delete the router
if (
@@ -184,6 +194,16 @@ export const createRouterConfig = async (
routerConfig.middlewares?.push(middlewareName);
}
+ // Enterprise SSO forward-auth gate. Placed before custom middlewares so
+ // authentication runs first. No-op unless the domain links a provider.
+ // The -errors middleware must come first so a 401 from the auth check is
+ // rewritten to a 302 redirect to the login page.
+ if (domain.forwardAuthEnabled) {
+ const name = forwardAuthMiddlewareName(appName, uniqueConfigKey);
+ routerConfig.middlewares?.push(`${name}-errors`);
+ routerConfig.middlewares?.push(name);
+ }
+
// custom middlewares from domain
if (domain.middlewares && domain.middlewares.length > 0) {
routerConfig.middlewares?.push(...domain.middlewares);
diff --git a/packages/server/src/utils/traefik/file-types.ts b/packages/server/src/utils/traefik/file-types.ts
index e761cb512..f9149c2cc 100644
--- a/packages/server/src/utils/traefik/file-types.ts
+++ b/packages/server/src/utils/traefik/file-types.ts
@@ -652,6 +652,13 @@ export interface ErrorsMiddleware {
* The URL for the error page (hosted by service). You can use {status} in the query, that will be replaced by the received status code.
*/
query?: string;
+ /**
+ * Rewrites the returning status code, mapping the original status to a new one
+ * (e.g. { "401": 302 } so the browser follows the redirect to the login page).
+ */
+ statusRewrites?: {
+ [k: string]: number;
+ };
}
/**
* The ForwardAuth middleware delegate the authentication to an external service. If the service response code is 2XX, access is granted and the original request is performed. Otherwise, the response from the authentication server is returned.
diff --git a/packages/server/src/utils/traefik/forward-auth.ts b/packages/server/src/utils/traefik/forward-auth.ts
new file mode 100644
index 000000000..3d6c207e3
--- /dev/null
+++ b/packages/server/src/utils/traefik/forward-auth.ts
@@ -0,0 +1,204 @@
+import { db } from "@dokploy/server/db";
+import { forwardAuthSettings } from "@dokploy/server/db/schema";
+import type { Domain } from "@dokploy/server/services/domain";
+import {
+ FORWARD_AUTH_PORT,
+ FORWARD_AUTH_SERVICE_NAME,
+} from "@dokploy/server/setup/forward-auth-setup";
+import { eq, isNull } from "drizzle-orm";
+import type { ApplicationNested } from "../builders";
+import {
+ removeTraefikConfig,
+ removeTraefikConfigRemote,
+ writeTraefikConfig,
+ writeTraefikConfigRemote,
+} from "./application";
+import type { FileConfig } from "./file-types";
+import {
+ loadMiddlewares,
+ loadRemoteMiddlewares,
+ writeMiddleware,
+} from "./middleware";
+
+export interface AuthDomainConfig {
+ authDomain: string;
+ https: boolean;
+ certificateType: "none" | "letsencrypt" | "custom";
+ customCertResolver?: string | null;
+}
+
+const TRAEFIK_SERVICE = "forward-auth-proxy";
+
+export const forwardAuthMiddlewareName = (
+ appName: string,
+ uniqueConfigKey: number,
+): string => `forward-auth-${appName}-${uniqueConfigKey}`;
+
+const proxyUrl = () =>
+ `http://${FORWARD_AUTH_SERVICE_NAME}:${FORWARD_AUTH_PORT}`;
+
+const loadOrEmptyMiddlewares = async (
+ serverId: string | null,
+): Promise => {
+ try {
+ return serverId
+ ? await loadRemoteMiddlewares(serverId)
+ : loadMiddlewares();
+ } catch {
+ return { http: { middlewares: {} } };
+ }
+};
+
+const persistMiddlewares = async (
+ config: FileConfig,
+ serverId: string | null,
+) => {
+ if (serverId) {
+ await writeTraefikConfigRemote(config, "middlewares", serverId);
+ } else {
+ writeMiddleware(config);
+ }
+};
+
+const loadAuthGateDomain = async (serverId: string | null) => {
+ return db.query.forwardAuthSettings.findFirst({
+ where: serverId
+ ? eq(forwardAuthSettings.serverId, serverId)
+ : isNull(forwardAuthSettings.serverId),
+ columns: { authDomain: true, https: true },
+ });
+};
+
+export const createForwardAuthMiddleware = async (
+ app: ApplicationNested,
+ domain: Domain,
+) => {
+ if (!domain.forwardAuthEnabled) {
+ return;
+ }
+
+ const authGate = await loadAuthGateDomain(app.serverId ?? null);
+ if (!authGate) {
+ return;
+ }
+ const authDomain = authGate.authDomain;
+ const authDomainHttps = authGate.https;
+
+ const { appName, serverId } = app;
+ const config = await loadOrEmptyMiddlewares(serverId);
+
+ config.http = config.http || {};
+ config.http.middlewares = config.http.middlewares || {};
+
+ const name = forwardAuthMiddlewareName(appName, domain.uniqueConfigKey);
+ const scheme = authDomainHttps ? "https" : "http";
+
+ config.http.middlewares[name] = {
+ forwardAuth: {
+ address: `${scheme}://${authDomain}/oauth2/auth`,
+ trustForwardHeader: true,
+ authResponseHeaders: [
+ "X-Auth-Request-User",
+ "X-Auth-Request-Email",
+ "X-Auth-Request-Preferred-Username",
+ "Authorization",
+ ],
+ },
+ };
+
+ config.http.middlewares[`${name}-errors`] = {
+ errors: {
+ status: ["401-403"],
+ service: TRAEFIK_SERVICE,
+ query: "/oauth2/sign_in?rd={url}",
+ statusRewrites: { "401": 302 },
+ },
+ };
+
+ await persistMiddlewares(config, serverId);
+};
+
+export const removeForwardAuthMiddleware = async (
+ app: ApplicationNested,
+ uniqueConfigKey: number,
+) => {
+ const { appName, serverId } = app;
+ let config: FileConfig;
+ try {
+ config = serverId
+ ? await loadRemoteMiddlewares(serverId)
+ : loadMiddlewares();
+ } catch {
+ return;
+ }
+
+ const name = forwardAuthMiddlewareName(appName, uniqueConfigKey);
+ let changed = false;
+ for (const key of [name, `${name}-errors`]) {
+ if (config.http?.middlewares?.[key]) {
+ delete config.http.middlewares[key];
+ changed = true;
+ }
+ }
+ if (changed) {
+ await persistMiddlewares(config, serverId);
+ }
+};
+
+export const buildAuthDomainRouter = (cfg: AuthDomainConfig): FileConfig => {
+ const entry = cfg.https ? "websecure" : "web";
+ const oauthRouter: NonNullable<
+ NonNullable["routers"]
+ >[string] = {
+ rule: `Host(\`${cfg.authDomain}\`) && PathPrefix(\`/oauth2/\`)`,
+ service: TRAEFIK_SERVICE,
+ entryPoints: [entry],
+ priority: 1000,
+ };
+
+ if (cfg.https) {
+ if (cfg.certificateType === "letsencrypt") {
+ oauthRouter.tls = { certResolver: "letsencrypt" };
+ } else if (cfg.certificateType === "custom" && cfg.customCertResolver) {
+ oauthRouter.tls = { certResolver: cfg.customCertResolver };
+ } else {
+ oauthRouter.tls = {};
+ }
+ }
+
+ return {
+ http: {
+ routers: { "forward-auth-oauth": oauthRouter },
+ services: {
+ [TRAEFIK_SERVICE]: {
+ loadBalancer: {
+ servers: [{ url: proxyUrl() }],
+ passHostHeader: true,
+ },
+ },
+ },
+ },
+ };
+};
+
+export const authDomainConfigName = "forward-auth-domain";
+
+export const manageForwardAuthDomain = async (
+ serverId: string | null,
+ cfg: AuthDomainConfig,
+) => {
+ const config = buildAuthDomainRouter(cfg);
+ if (serverId) {
+ await writeTraefikConfigRemote(config, authDomainConfigName, serverId);
+ } else {
+ writeTraefikConfig(config, authDomainConfigName);
+ }
+};
+
+export const removeForwardAuthDomain = async (serverId: string | null) => {
+ if (serverId) {
+ await removeTraefikConfigRemote(authDomainConfigName, serverId);
+ } else {
+ await removeTraefikConfig(authDomainConfigName);
+ }
+};
diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts
index 1d795a14a..a9240f2dd 100644
--- a/packages/server/src/utils/volume-backups/backup.ts
+++ b/packages/server/src/utils/volume-backups/backup.ts
@@ -1,6 +1,7 @@
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { findComposeById } from "@dokploy/server/services/compose";
+import { findDestinationById } from "@dokploy/server/services/destination";
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import {
getBackupTimestamp,
@@ -31,14 +32,14 @@ export const backupVolume = async (
volumeBackup: Awaited>,
) => {
const { serviceType, volumeName, turnOff, prefix } = volumeBackup;
+ const destination = await findDestinationById(volumeBackup.destinationId);
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
- const destination = volumeBackup.destination;
const s3AppName = getVolumeServiceAppName(volumeBackup);
const backupFileName = `${volumeName}-${getBackupTimestamp()}.tar`;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
- const rcloneFlags = getS3Credentials(volumeBackup.destination);
+ const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);
diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts
index a1eb0a8f1..8edca6cf6 100644
--- a/packages/server/src/utils/volume-backups/utils.ts
+++ b/packages/server/src/utils/volume-backups/utils.ts
@@ -4,6 +4,7 @@ import {
createDeploymentVolumeBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
+import { findDestinationById } from "@dokploy/server/services/destination";
import { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import {
execAsync,
@@ -77,7 +78,8 @@ const cleanupOldVolumeBackups = async (
volumeBackup: Awaited>,
serverId?: string | null,
) => {
- const { keepLatestCount, destination, prefix, volumeName } = volumeBackup;
+ const { keepLatestCount, prefix, volumeName } = volumeBackup;
+ const destination = await findDestinationById(volumeBackup.destinationId);
if (!keepLatestCount) return;