diff --git a/apps/dokploy/components/shared/drawer-logs.tsx b/apps/dokploy/components/shared/drawer-logs.tsx
index bc383cb19..38f8b5db4 100644
--- a/apps/dokploy/components/shared/drawer-logs.tsx
+++ b/apps/dokploy/components/shared/drawer-logs.tsx
@@ -60,7 +60,11 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => {
{" "}
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
-
+
))
) : (
diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json
index 9165d09b6..39a5de912 100644
--- a/apps/dokploy/package.json
+++ b/apps/dokploy/package.json
@@ -1,6 +1,6 @@
{
"name": "dokploy",
- "version": "v0.28.0",
+ "version": "v0.28.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -55,7 +55,7 @@
"@codemirror/legacy-modes": "6.4.0",
"@codemirror/view": "6.29.0",
"@dokploy/server": "workspace:*",
- "@dokploy/trpc-openapi": "0.0.16",
+ "@dokploy/trpc-openapi": "0.0.17",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx
index d934f263d..37ba09e67 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx
@@ -100,7 +100,6 @@ import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
export type Services = {
- appName: string;
serverId?: string | null;
serverName?: string | null;
name: string;
@@ -146,7 +145,6 @@ export const extractServicesFromEnvironment = (
}
}
return {
- appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
@@ -161,7 +159,6 @@ export const extractServicesFromEnvironment = (
const mariadb: Services[] =
environment.mariadb?.map((item) => ({
- appName: item.appName,
name: item.name,
type: "mariadb",
id: item.mariadbId,
@@ -174,7 +171,6 @@ export const extractServicesFromEnvironment = (
const postgres: Services[] =
environment.postgres?.map((item) => ({
- appName: item.appName,
name: item.name,
type: "postgres",
id: item.postgresId,
@@ -187,7 +183,6 @@ export const extractServicesFromEnvironment = (
const mongo: Services[] =
environment.mongo?.map((item) => ({
- appName: item.appName,
name: item.name,
type: "mongo",
id: item.mongoId,
@@ -200,7 +195,6 @@ export const extractServicesFromEnvironment = (
const redis: Services[] =
environment.redis?.map((item) => ({
- appName: item.appName,
name: item.name,
type: "redis",
id: item.redisId,
@@ -213,7 +207,6 @@ export const extractServicesFromEnvironment = (
const mysql: Services[] =
environment.mysql?.map((item) => ({
- appName: item.appName,
name: item.name,
type: "mysql",
id: item.mysqlId,
@@ -242,7 +235,6 @@ export const extractServicesFromEnvironment = (
}
}
return {
- appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
diff --git a/apps/dokploy/proprietary/README.md b/apps/dokploy/proprietary/README.md
deleted file mode 100644
index b1af288e6..000000000
--- a/apps/dokploy/proprietary/README.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# Proprietary Features
-
-This directory contains all proprietary functionality of Dokploy.
-
-## Purpose
-
-This folder will house all **paid features** and premium functionality that are not part of the open source code.
-
-## License
-
-The code in this directory is under Dokploy's proprietary license. See [LICENSE_PROPRIETARY.md](../../../LICENSE_PROPRIETARY.md) for more details.
-
-## Contact
-
-If you want to learn more about our paid features or have any questions, please contact us at:
-
-- Email: [sales@dokploy.com](mailto:sales@dokploy.com)
-- Contact Form: [https://dokploy.com/contact](https://dokploy.com/contact)
diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts
index 97dba570e..df3e81c82 100644
--- a/apps/dokploy/server/api/routers/application.ts
+++ b/apps/dokploy/server/api/routers/application.ts
@@ -7,6 +7,7 @@ import {
findApplicationById,
findEnvironmentById,
findGitProviderById,
+ findMemberById,
findProjectById,
getApplicationStats,
IS_CLOUD,
@@ -32,7 +33,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
@@ -53,6 +54,8 @@ import {
apiSaveGitProvider,
apiUpdateApplication,
applications,
+ environments,
+ projects,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
@@ -1002,4 +1005,138 @@ export const applicationRouter = createTRPCRouter({
message: "Deployment cancellation only available in cloud version",
});
}),
+
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ appName: z.string().optional(),
+ description: z.string().optional(),
+ repository: z.string().optional(),
+ owner: z.string().optional(),
+ dockerImage: z.string().optional(),
+ projectId: z.string().optional(),
+ environmentId: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+
+ if (input.projectId) {
+ baseConditions.push(eq(environments.projectId, input.projectId));
+ }
+ if (input.environmentId) {
+ baseConditions.push(
+ eq(applications.environmentId, input.environmentId),
+ );
+ }
+
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(applications.name, term),
+ ilike(applications.appName, term),
+ ilike(applications.description ?? "", term),
+ ilike(applications.repository ?? "", term),
+ ilike(applications.owner ?? "", term),
+ ilike(applications.dockerImage ?? "", term),
+ )!,
+ );
+ }
+
+ if (input.name?.trim()) {
+ baseConditions.push(ilike(applications.name, `%${input.name.trim()}%`));
+ }
+ if (input.appName?.trim()) {
+ baseConditions.push(
+ ilike(applications.appName, `%${input.appName.trim()}%`),
+ );
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(
+ applications.description ?? "",
+ `%${input.description.trim()}%`,
+ ),
+ );
+ }
+ if (input.repository?.trim()) {
+ baseConditions.push(
+ ilike(applications.repository ?? "", `%${input.repository.trim()}%`),
+ );
+ }
+ if (input.owner?.trim()) {
+ baseConditions.push(
+ ilike(applications.owner ?? "", `%${input.owner.trim()}%`),
+ );
+ }
+ if (input.dockerImage?.trim()) {
+ baseConditions.push(
+ ilike(
+ applications.dockerImage ?? "",
+ `%${input.dockerImage.trim()}%`,
+ ),
+ );
+ }
+
+ if (ctx.user.role === "member") {
+ const { accessedServices } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedServices.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${applications.applicationId} IN (${sql.join(
+ accessedServices.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+
+ const where = and(...baseConditions);
+
+ const [items, countResult] = await Promise.all([
+ db
+ .select({
+ applicationId: applications.applicationId,
+ name: applications.name,
+ appName: applications.appName,
+ description: applications.description,
+ environmentId: applications.environmentId,
+ applicationStatus: applications.applicationStatus,
+ sourceType: applications.sourceType,
+ createdAt: applications.createdAt,
+ })
+ .from(applications)
+ .innerJoin(
+ environments,
+ eq(applications.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where)
+ .orderBy(desc(applications.createdAt))
+ .limit(input.limit)
+ .offset(input.offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(applications)
+ .innerJoin(
+ environments,
+ eq(applications.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where),
+ ]);
+
+ return {
+ items,
+ total: countResult[0]?.count ?? 0,
+ };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts
index f868e2ae1..e3c803cd4 100644
--- a/apps/dokploy/server/api/routers/compose.ts
+++ b/apps/dokploy/server/api/routers/compose.ts
@@ -16,6 +16,7 @@ import {
findDomainsByComposeId,
findEnvironmentById,
findGitProviderById,
+ findMemberById,
findProjectById,
findServerById,
getComposeContainer,
@@ -41,7 +42,7 @@ import {
} from "@dokploy/server/templates/github";
import { processTemplate } from "@dokploy/server/templates/processors";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import _ from "lodash";
import { nanoid } from "nanoid";
import { parse } from "toml";
@@ -58,6 +59,8 @@ import {
apiRedeployCompose,
apiUpdateCompose,
compose as composeTable,
+ environments,
+ projects,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
@@ -1054,4 +1057,114 @@ export const composeRouter = createTRPCRouter({
message: "Deployment cancellation only available in cloud version",
});
}),
+
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ appName: z.string().optional(),
+ description: z.string().optional(),
+ projectId: z.string().optional(),
+ environmentId: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+
+ if (input.projectId) {
+ baseConditions.push(eq(environments.projectId, input.projectId));
+ }
+ if (input.environmentId) {
+ baseConditions.push(
+ eq(composeTable.environmentId, input.environmentId),
+ );
+ }
+
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(composeTable.name, term),
+ ilike(composeTable.appName, term),
+ ilike(composeTable.description ?? "", term),
+ )!,
+ );
+ }
+
+ if (input.name?.trim()) {
+ baseConditions.push(ilike(composeTable.name, `%${input.name.trim()}%`));
+ }
+ if (input.appName?.trim()) {
+ baseConditions.push(
+ ilike(composeTable.appName, `%${input.appName.trim()}%`),
+ );
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(
+ composeTable.description ?? "",
+ `%${input.description.trim()}%`,
+ ),
+ );
+ }
+
+ if (ctx.user.role === "member") {
+ const { accessedServices } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedServices.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${composeTable.composeId} IN (${sql.join(
+ accessedServices.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+
+ const where = and(...baseConditions);
+
+ const [items, countResult] = await Promise.all([
+ db
+ .select({
+ composeId: composeTable.composeId,
+ name: composeTable.name,
+ appName: composeTable.appName,
+ description: composeTable.description,
+ environmentId: composeTable.environmentId,
+ composeStatus: composeTable.composeStatus,
+ sourceType: composeTable.sourceType,
+ createdAt: composeTable.createdAt,
+ })
+ .from(composeTable)
+ .innerJoin(
+ environments,
+ eq(composeTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where)
+ .orderBy(desc(composeTable.createdAt))
+ .limit(input.limit)
+ .offset(input.offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(composeTable)
+ .innerJoin(
+ environments,
+ eq(composeTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where),
+ ]);
+
+ return {
+ items,
+ total: countResult[0]?.count ?? 0,
+ };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts
index 9f5eb45c2..16376e9e0 100644
--- a/apps/dokploy/server/api/routers/environment.ts
+++ b/apps/dokploy/server/api/routers/environment.ts
@@ -11,7 +11,9 @@ import {
findMemberById,
updateEnvironmentById,
} from "@dokploy/server";
+import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -21,6 +23,7 @@ import {
apiRemoveEnvironment,
apiUpdateEnvironment,
} from "@/server/db/schema";
+import { environments, projects } from "@/server/db/schema";
// Helper function to filter services within an environment based on user permissions
const filterEnvironmentServices = (
@@ -358,4 +361,92 @@ export const environmentRouter = createTRPCRouter({
});
}
}),
+
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ description: z.string().optional(),
+ projectId: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+
+ if (input.projectId) {
+ baseConditions.push(eq(environments.projectId, input.projectId));
+ }
+
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(environments.name, term),
+ ilike(environments.description ?? "", term),
+ )!,
+ );
+ }
+
+ if (input.name?.trim()) {
+ baseConditions.push(ilike(environments.name, `%${input.name.trim()}%`));
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(
+ environments.description ?? "",
+ `%${input.description.trim()}%`,
+ ),
+ );
+ }
+
+ if (ctx.user.role === "member") {
+ const { accessedEnvironments } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedEnvironments.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${environments.environmentId} IN (${sql.join(
+ accessedEnvironments.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+
+ const where = and(...baseConditions);
+
+ const [items, countResult] = await Promise.all([
+ db
+ .select({
+ environmentId: environments.environmentId,
+ name: environments.name,
+ description: environments.description,
+ createdAt: environments.createdAt,
+ env: environments.env,
+ projectId: environments.projectId,
+ isDefault: environments.isDefault,
+ })
+ .from(environments)
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where)
+ .orderBy(desc(environments.createdAt))
+ .limit(input.limit)
+ .offset(input.offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(environments)
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where),
+ ]);
+
+ return {
+ items,
+ total: countResult[0]?.count ?? 0,
+ };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts
index bddc71b09..567cd4ad8 100644
--- a/apps/dokploy/server/api/routers/mariadb.ts
+++ b/apps/dokploy/server/api/routers/mariadb.ts
@@ -8,6 +8,7 @@ import {
findBackupsByDbId,
findEnvironmentById,
findMariadbById,
+ findMemberById,
findProjectById,
IS_CLOUD,
rebuildDatabase,
@@ -22,7 +23,7 @@ import {
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
-import { eq } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -37,6 +38,7 @@ import {
apiUpdateMariaDB,
mariadb as mariadbTable,
} from "@/server/db/schema";
+import { environments, projects } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const mariadbRouter = createTRPCRouter({
create: protectedProcedure
@@ -446,4 +448,102 @@ export const mariadbRouter = createTRPCRouter({
await rebuildDatabase(mariadb.mariadbId, "mariadb");
return true;
}),
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ appName: z.string().optional(),
+ description: z.string().optional(),
+ projectId: z.string().optional(),
+ environmentId: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+ if (input.projectId) {
+ baseConditions.push(eq(environments.projectId, input.projectId));
+ }
+ if (input.environmentId) {
+ baseConditions.push(
+ eq(mariadbTable.environmentId, input.environmentId),
+ );
+ }
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(mariadbTable.name, term),
+ ilike(mariadbTable.appName, term),
+ ilike(mariadbTable.description ?? "", term),
+ )!,
+ );
+ }
+ if (input.name?.trim()) {
+ baseConditions.push(ilike(mariadbTable.name, `%${input.name.trim()}%`));
+ }
+ if (input.appName?.trim()) {
+ baseConditions.push(
+ ilike(mariadbTable.appName, `%${input.appName.trim()}%`),
+ );
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(
+ mariadbTable.description ?? "",
+ `%${input.description.trim()}%`,
+ ),
+ );
+ }
+ if (ctx.user.role === "member") {
+ const { accessedServices } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedServices.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${mariadbTable.mariadbId} IN (${sql.join(
+ accessedServices.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+ const where = and(...baseConditions);
+ const [items, countResult] = await Promise.all([
+ db
+ .select({
+ mariadbId: mariadbTable.mariadbId,
+ name: mariadbTable.name,
+ appName: mariadbTable.appName,
+ description: mariadbTable.description,
+ environmentId: mariadbTable.environmentId,
+ applicationStatus: mariadbTable.applicationStatus,
+ createdAt: mariadbTable.createdAt,
+ })
+ .from(mariadbTable)
+ .innerJoin(
+ environments,
+ eq(mariadbTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where)
+ .orderBy(desc(mariadbTable.createdAt))
+ .limit(input.limit)
+ .offset(input.offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(mariadbTable)
+ .innerJoin(
+ environments,
+ eq(mariadbTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where),
+ ]);
+ return { items, total: countResult[0]?.count ?? 0 };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts
index e8454c8a4..ec0a4041c 100644
--- a/apps/dokploy/server/api/routers/mongo.ts
+++ b/apps/dokploy/server/api/routers/mongo.ts
@@ -7,6 +7,7 @@ import {
deployMongo,
findBackupsByDbId,
findEnvironmentById,
+ findMemberById,
findMongoById,
findProjectById,
IS_CLOUD,
@@ -21,7 +22,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -36,6 +37,7 @@ import {
apiUpdateMongo,
mongo as mongoTable,
} from "@/server/db/schema";
+import { environments, projects } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const mongoRouter = createTRPCRouter({
create: protectedProcedure
@@ -476,4 +478,97 @@ export const mongoRouter = createTRPCRouter({
return true;
}),
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ appName: z.string().optional(),
+ description: z.string().optional(),
+ projectId: z.string().optional(),
+ environmentId: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+ if (input.projectId) {
+ baseConditions.push(eq(environments.projectId, input.projectId));
+ }
+ if (input.environmentId) {
+ baseConditions.push(eq(mongoTable.environmentId, input.environmentId));
+ }
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(mongoTable.name, term),
+ ilike(mongoTable.appName, term),
+ ilike(mongoTable.description ?? "", term),
+ )!,
+ );
+ }
+ if (input.name?.trim()) {
+ baseConditions.push(ilike(mongoTable.name, `%${input.name.trim()}%`));
+ }
+ if (input.appName?.trim()) {
+ baseConditions.push(
+ ilike(mongoTable.appName, `%${input.appName.trim()}%`),
+ );
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(mongoTable.description ?? "", `%${input.description.trim()}%`),
+ );
+ }
+ if (ctx.user.role === "member") {
+ const { accessedServices } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedServices.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${mongoTable.mongoId} IN (${sql.join(
+ accessedServices.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+ const where = and(...baseConditions);
+ const [items, countResult] = await Promise.all([
+ db
+ .select({
+ mongoId: mongoTable.mongoId,
+ name: mongoTable.name,
+ appName: mongoTable.appName,
+ description: mongoTable.description,
+ environmentId: mongoTable.environmentId,
+ applicationStatus: mongoTable.applicationStatus,
+ createdAt: mongoTable.createdAt,
+ })
+ .from(mongoTable)
+ .innerJoin(
+ environments,
+ eq(mongoTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where)
+ .orderBy(desc(mongoTable.createdAt))
+ .limit(input.limit)
+ .offset(input.offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(mongoTable)
+ .innerJoin(
+ environments,
+ eq(mongoTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where),
+ ]);
+ return { items, total: countResult[0]?.count ?? 0 };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts
index b1bc10f32..5a00ef0d0 100644
--- a/apps/dokploy/server/api/routers/mysql.ts
+++ b/apps/dokploy/server/api/routers/mysql.ts
@@ -7,6 +7,7 @@ import {
deployMySql,
findBackupsByDbId,
findEnvironmentById,
+ findMemberById,
findMySqlById,
findProjectById,
IS_CLOUD,
@@ -21,7 +22,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -34,7 +35,9 @@ import {
apiSaveEnvironmentVariablesMySql,
apiSaveExternalPortMySql,
apiUpdateMySql,
+ environments,
mysql as mysqlTable,
+ projects,
} from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
@@ -471,4 +474,97 @@ export const mysqlRouter = createTRPCRouter({
return true;
}),
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ appName: z.string().optional(),
+ description: z.string().optional(),
+ projectId: z.string().optional(),
+ environmentId: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+ if (input.projectId) {
+ baseConditions.push(eq(environments.projectId, input.projectId));
+ }
+ if (input.environmentId) {
+ baseConditions.push(eq(mysqlTable.environmentId, input.environmentId));
+ }
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(mysqlTable.name, term),
+ ilike(mysqlTable.appName, term),
+ ilike(mysqlTable.description ?? "", term),
+ )!,
+ );
+ }
+ if (input.name?.trim()) {
+ baseConditions.push(ilike(mysqlTable.name, `%${input.name.trim()}%`));
+ }
+ if (input.appName?.trim()) {
+ baseConditions.push(
+ ilike(mysqlTable.appName, `%${input.appName.trim()}%`),
+ );
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(mysqlTable.description ?? "", `%${input.description.trim()}%`),
+ );
+ }
+ if (ctx.user.role === "member") {
+ const { accessedServices } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedServices.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${mysqlTable.mysqlId} IN (${sql.join(
+ accessedServices.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+ const where = and(...baseConditions);
+ const [items, countResult] = await Promise.all([
+ db
+ .select({
+ mysqlId: mysqlTable.mysqlId,
+ name: mysqlTable.name,
+ appName: mysqlTable.appName,
+ description: mysqlTable.description,
+ environmentId: mysqlTable.environmentId,
+ applicationStatus: mysqlTable.applicationStatus,
+ createdAt: mysqlTable.createdAt,
+ })
+ .from(mysqlTable)
+ .innerJoin(
+ environments,
+ eq(mysqlTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where)
+ .orderBy(desc(mysqlTable.createdAt))
+ .limit(input.limit)
+ .offset(input.offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(mysqlTable)
+ .innerJoin(
+ environments,
+ eq(mysqlTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where),
+ ]);
+ return { items, total: countResult[0]?.count ?? 0 };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts
index d9f69330c..48de9d5a2 100644
--- a/apps/dokploy/server/api/routers/postgres.ts
+++ b/apps/dokploy/server/api/routers/postgres.ts
@@ -7,6 +7,7 @@ import {
deployPostgres,
findBackupsByDbId,
findEnvironmentById,
+ findMemberById,
findPostgresById,
findProjectById,
getMountPath,
@@ -22,7 +23,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -37,6 +38,7 @@ import {
apiUpdatePostgres,
postgres as postgresTable,
} from "@/server/db/schema";
+import { environments, projects } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const postgresRouter = createTRPCRouter({
@@ -483,4 +485,104 @@ export const postgresRouter = createTRPCRouter({
return true;
}),
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ appName: z.string().optional(),
+ description: z.string().optional(),
+ projectId: z.string().optional(),
+ environmentId: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+ if (input.projectId) {
+ baseConditions.push(eq(environments.projectId, input.projectId));
+ }
+ if (input.environmentId) {
+ baseConditions.push(
+ eq(postgresTable.environmentId, input.environmentId),
+ );
+ }
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(postgresTable.name, term),
+ ilike(postgresTable.appName, term),
+ ilike(postgresTable.description ?? "", term),
+ )!,
+ );
+ }
+ if (input.name?.trim()) {
+ baseConditions.push(
+ ilike(postgresTable.name, `%${input.name.trim()}%`),
+ );
+ }
+ if (input.appName?.trim()) {
+ baseConditions.push(
+ ilike(postgresTable.appName, `%${input.appName.trim()}%`),
+ );
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(
+ postgresTable.description ?? "",
+ `%${input.description.trim()}%`,
+ ),
+ );
+ }
+ if (ctx.user.role === "member") {
+ const { accessedServices } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedServices.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${postgresTable.postgresId} IN (${sql.join(
+ accessedServices.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+ const where = and(...baseConditions);
+ const [items, countResult] = await Promise.all([
+ db
+ .select({
+ postgresId: postgresTable.postgresId,
+ name: postgresTable.name,
+ appName: postgresTable.appName,
+ description: postgresTable.description,
+ environmentId: postgresTable.environmentId,
+ applicationStatus: postgresTable.applicationStatus,
+ createdAt: postgresTable.createdAt,
+ })
+ .from(postgresTable)
+ .innerJoin(
+ environments,
+ eq(postgresTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where)
+ .orderBy(desc(postgresTable.createdAt))
+ .limit(input.limit)
+ .offset(input.offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(postgresTable)
+ .innerJoin(
+ environments,
+ eq(postgresTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where),
+ ]);
+ return { items, total: countResult[0]?.count ?? 0 };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts
index b1df68951..e270ee4b4 100644
--- a/apps/dokploy/server/api/routers/project.ts
+++ b/apps/dokploy/server/api/routers/project.ts
@@ -34,10 +34,14 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
-import { and, desc, eq, sql } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import { z } from "zod";
-import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
+import {
+ adminProcedure,
+ createTRPCRouter,
+ protectedProcedure,
+} from "@/server/api/trpc";
import {
apiCreateProject,
apiFindOneProject,
@@ -219,31 +223,69 @@ export const projectRouter = createTRPCRouter({
applications.applicationId,
accessedServices,
),
- with: { domains: true },
+ columns: {
+ applicationId: true,
+ name: true,
+ applicationStatus: true,
+ },
},
mariadb: {
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
+ columns: {
+ mariadbId: true,
+ name: true,
+ applicationStatus: true,
+ },
},
mongo: {
where: buildServiceFilter(mongo.mongoId, accessedServices),
+ columns: {
+ mongoId: true,
+ name: true,
+ applicationStatus: true,
+ },
},
mysql: {
where: buildServiceFilter(mysql.mysqlId, accessedServices),
+ columns: {
+ mysqlId: true,
+ name: true,
+ applicationStatus: true,
+ },
},
postgres: {
where: buildServiceFilter(
postgres.postgresId,
accessedServices,
),
+ columns: {
+ postgresId: true,
+ name: true,
+ applicationStatus: true,
+ },
},
redis: {
where: buildServiceFilter(redis.redisId, accessedServices),
+ columns: {
+ redisId: true,
+ name: true,
+ applicationStatus: true,
+ },
},
compose: {
where: buildServiceFilter(compose.composeId, accessedServices),
- with: { domains: true },
+ columns: {
+ composeId: true,
+ name: true,
+ composeStatus: true,
+ },
},
},
+ columns: {
+ environmentId: true,
+ isDefault: true,
+ name: true,
+ },
},
},
orderBy: desc(projects.createdAt),
@@ -255,21 +297,50 @@ export const projectRouter = createTRPCRouter({
environments: {
with: {
applications: {
- with: {
- domains: true,
+ columns: {
+ applicationId: true,
+ name: true,
+ applicationStatus: true,
+ },
+ },
+ mariadb: {
+ columns: {
+ mariadbId: true,
+ },
+ },
+ mongo: {
+ columns: {
+ mongoId: true,
+ },
+ },
+ mysql: {
+ columns: {
+ mysqlId: true,
+ },
+ },
+ postgres: {
+ columns: {
+ postgresId: true,
+ },
+ },
+ redis: {
+ columns: {
+ redisId: true,
},
},
- mariadb: true,
- mongo: true,
- mysql: true,
- postgres: true,
- redis: true,
compose: {
- with: {
- domains: true,
+ columns: {
+ composeId: true,
+ name: true,
+ composeStatus: true,
},
},
},
+ columns: {
+ name: true,
+ environmentId: true,
+ isDefault: true,
+ },
},
},
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
@@ -277,6 +348,183 @@ export const projectRouter = createTRPCRouter({
});
}),
+ /** All projects with full environments and services for the admin permissions UI. Admin only. */
+ allForPermissions: adminProcedure.query(async ({ ctx }) => {
+ return await db.query.projects.findMany({
+ where: eq(projects.organizationId, ctx.session.activeOrganizationId),
+ orderBy: desc(projects.createdAt),
+ columns: {
+ projectId: true,
+ name: true,
+ },
+ with: {
+ environments: {
+ columns: {
+ environmentId: true,
+ name: true,
+ isDefault: true,
+ },
+ with: {
+ applications: {
+ columns: {
+ applicationId: true,
+ appName: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
+ },
+ },
+ mariadb: {
+ columns: {
+ mariadbId: true,
+ appName: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
+ },
+ },
+ postgres: {
+ columns: {
+ postgresId: true,
+ appName: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
+ },
+ },
+ mysql: {
+ columns: {
+ mysqlId: true,
+ appName: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
+ },
+ },
+ mongo: {
+ columns: {
+ mongoId: true,
+ appName: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
+ },
+ },
+ redis: {
+ columns: {
+ redisId: true,
+ appName: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
+ },
+ },
+ compose: {
+ columns: {
+ composeId: true,
+ appName: true,
+ name: true,
+ createdAt: true,
+ composeStatus: true,
+ description: true,
+ serverId: true,
+ },
+ },
+ },
+ },
+ },
+ });
+ }),
+
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ description: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(projects.name, term),
+ ilike(projects.description ?? "", term),
+ )!,
+ );
+ }
+
+ if (input.name?.trim()) {
+ baseConditions.push(ilike(projects.name, `%${input.name.trim()}%`));
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(projects.description ?? "", `%${input.description.trim()}%`),
+ );
+ }
+
+ if (ctx.user.role === "member") {
+ const { accessedProjects } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedProjects.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${projects.projectId} IN (${sql.join(
+ accessedProjects.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+
+ const where = and(...baseConditions);
+
+ const [items, countResult] = await Promise.all([
+ db.query.projects.findMany({
+ where,
+ limit: input.limit,
+ offset: input.offset,
+ orderBy: desc(projects.createdAt),
+ columns: {
+ projectId: true,
+ name: true,
+ description: true,
+ createdAt: true,
+ organizationId: true,
+ env: true,
+ },
+ }),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(projects)
+ .where(where),
+ ]);
+
+ return {
+ items,
+ total: countResult[0]?.count ?? 0,
+ };
+ }),
+
remove: protectedProcedure
.input(apiRemoveProject)
.mutation(async ({ input, ctx }) => {
diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts
index dfeff82bb..94939bd20 100644
--- a/apps/dokploy/server/api/routers/redis.ts
+++ b/apps/dokploy/server/api/routers/redis.ts
@@ -6,6 +6,7 @@ import {
createRedis,
deployRedis,
findEnvironmentById,
+ findMemberById,
findProjectById,
findRedisById,
IS_CLOUD,
@@ -20,7 +21,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -35,6 +36,7 @@ import {
apiUpdateRedis,
redis as redisTable,
} from "@/server/db/schema";
+import { environments, projects } from "@/server/db/schema";
export const redisRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateRedis)
@@ -450,4 +452,97 @@ export const redisRouter = createTRPCRouter({
await rebuildDatabase(redis.redisId, "redis");
return true;
}),
+ search: protectedProcedure
+ .input(
+ z.object({
+ q: z.string().optional(),
+ name: z.string().optional(),
+ appName: z.string().optional(),
+ description: z.string().optional(),
+ projectId: z.string().optional(),
+ environmentId: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+ offset: z.number().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const baseConditions = [
+ eq(projects.organizationId, ctx.session.activeOrganizationId),
+ ];
+ if (input.projectId) {
+ baseConditions.push(eq(environments.projectId, input.projectId));
+ }
+ if (input.environmentId) {
+ baseConditions.push(eq(redisTable.environmentId, input.environmentId));
+ }
+ if (input.q?.trim()) {
+ const term = `%${input.q.trim()}%`;
+ baseConditions.push(
+ or(
+ ilike(redisTable.name, term),
+ ilike(redisTable.appName, term),
+ ilike(redisTable.description ?? "", term),
+ )!,
+ );
+ }
+ if (input.name?.trim()) {
+ baseConditions.push(ilike(redisTable.name, `%${input.name.trim()}%`));
+ }
+ if (input.appName?.trim()) {
+ baseConditions.push(
+ ilike(redisTable.appName, `%${input.appName.trim()}%`),
+ );
+ }
+ if (input.description?.trim()) {
+ baseConditions.push(
+ ilike(redisTable.description ?? "", `%${input.description.trim()}%`),
+ );
+ }
+ if (ctx.user.role === "member") {
+ const { accessedServices } = await findMemberById(
+ ctx.user.id,
+ ctx.session.activeOrganizationId,
+ );
+ if (accessedServices.length === 0) return { items: [], total: 0 };
+ baseConditions.push(
+ sql`${redisTable.redisId} IN (${sql.join(
+ accessedServices.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ );
+ }
+ const where = and(...baseConditions);
+ const [items, countResult] = await Promise.all([
+ db
+ .select({
+ redisId: redisTable.redisId,
+ name: redisTable.name,
+ appName: redisTable.appName,
+ description: redisTable.description,
+ environmentId: redisTable.environmentId,
+ applicationStatus: redisTable.applicationStatus,
+ createdAt: redisTable.createdAt,
+ })
+ .from(redisTable)
+ .innerJoin(
+ environments,
+ eq(redisTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where)
+ .orderBy(desc(redisTable.createdAt))
+ .limit(input.limit)
+ .offset(input.offset),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(redisTable)
+ .innerJoin(
+ environments,
+ eq(redisTable.environmentId, environments.environmentId),
+ )
+ .innerJoin(projects, eq(environments.projectId, projects.projectId))
+ .where(where),
+ ]);
+ return { items, total: countResult[0]?.count ?? 0 };
+ }),
});
diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts
index a3cc97e6b..fee7f2f5d 100644
--- a/apps/dokploy/server/api/routers/settings.ts
+++ b/apps/dokploy/server/api/routers/settings.ts
@@ -530,12 +530,12 @@ export const settingsRouter = createTRPCRouter({
getOpenApiDocument: protectedProcedure.query(
async ({ ctx }): Promise => {
const protocol = ctx.req.headers["x-forwarded-proto"];
- const url = `${protocol}://${ctx.req.headers.host}/api/trpc`;
+ const url = `${protocol}://${ctx.req.headers.host}/api`;
const openApiDocument = generateOpenApiDocument(appRouter, {
title: "tRPC OpenAPI",
version: packageInfo.version,
baseUrl: url,
- docsUrl: `${url}/trpc/settings.getOpenApiDocument`,
+ docsUrl: `${url}/settings.getOpenApiDocument`,
tags: [
"admin",
"docker",
diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts
index c910b1d58..109f2aac3 100644
--- a/apps/dokploy/server/api/routers/stripe.ts
+++ b/apps/dokploy/server/api/routers/stripe.ts
@@ -21,9 +21,51 @@ import {
STARTUP_PRODUCT_ID,
WEBSITE_URL,
} from "@/server/utils/stripe";
-import { adminProcedure, createTRPCRouter } from "../trpc";
+import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
export const stripeRouter = createTRPCRouter({
+ /** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
+ getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
+ if (!IS_CLOUD) return null;
+ const owner = await findUserById(ctx.user.ownerId);
+ if (!owner?.stripeCustomerId) return null;
+
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: "2024-09-30.acacia",
+ });
+ const subscriptions = await stripe.subscriptions.list({
+ customer: owner.stripeCustomerId,
+ status: "active",
+ expand: ["data.items.data.price"],
+ });
+ const activeSub = subscriptions.data[0];
+ if (!activeSub) return null;
+
+ const priceIds = activeSub.items.data.map(
+ (item) => (item.price as Stripe.Price).id,
+ );
+ if (
+ priceIds.some(
+ (id) =>
+ id === STARTUP_BASE_PRICE_MONTHLY_ID ||
+ id === STARTUP_BASE_PRICE_ANNUAL_ID,
+ )
+ ) {
+ return "startup" as const;
+ }
+ if (
+ priceIds.some(
+ (id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
+ )
+ ) {
+ return "hobby" as const;
+ }
+ if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
+ return "legacy" as const;
+ }
+ return null;
+ }),
+
getProducts: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;
diff --git a/apps/dokploy/server/db/index.ts b/apps/dokploy/server/db/index.ts
new file mode 100644
index 000000000..afc10bedf
--- /dev/null
+++ b/apps/dokploy/server/db/index.ts
@@ -0,0 +1,38 @@
+import { dbUrl } from "@dokploy/server/db/constants";
+import * as schema from "@dokploy/server/db/schema";
+import { and, eq } from "drizzle-orm";
+import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
+
+export { and, eq };
+
+type Database = PostgresJsDatabase;
+/**
+ * Evita problemas de redeclaración global en monorepos.
+ * No usamos `declare global`.
+ */
+const globalForDb = globalThis as unknown as {
+ db?: Database;
+};
+
+let dbConnection: Database;
+
+if (process.env.NODE_ENV === "production") {
+ // En producción no usamos global cache
+ dbConnection = drizzle(postgres(dbUrl), {
+ schema,
+ });
+} else {
+ // En desarrollo reutilizamos conexión para evitar múltiples conexiones
+ if (!globalForDb.db) {
+ globalForDb.db = drizzle(postgres(dbUrl), {
+ schema,
+ });
+ }
+
+ dbConnection = globalForDb.db;
+}
+
+export const db: Database = dbConnection;
+
+export { dbUrl };
diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts
index 3dea8f486..21e1ff19f 100644
--- a/packages/server/src/lib/auth.ts
+++ b/packages/server/src/lib/auth.ts
@@ -73,23 +73,25 @@ const { handler, api } = betterAuth({
disabled: process.env.NODE_ENV === "production",
},
async trustedOrigins() {
- const trustedOrigins = await getTrustedOrigins();
if (IS_CLOUD) {
- return trustedOrigins;
+ return getTrustedOrigins();
}
- const settings = await getWebServerSettings();
- if (!settings) {
- return [];
- }
- return [
- ...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
- ...(settings?.host ? [`https://${settings?.host}`] : []),
- ...(process.env.NODE_ENV === "development"
+ const [trustedOrigins, settings] = await Promise.all([
+ getTrustedOrigins(),
+ getWebServerSettings(),
+ ]);
+ if (!settings) return [];
+ const devOrigins =
+ process.env.NODE_ENV === "development"
? [
"http://localhost:3000",
"https://absolutely-handy-falcon.ngrok-free.app",
]
- : []),
+ : [];
+ return [
+ ...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
+ ...(settings?.host ? [`https://${settings?.host}`] : []),
+ ...devOrigins,
...trustedOrigins,
];
},
diff --git a/packages/server/src/services/admin.ts b/packages/server/src/services/admin.ts
index 9bc18334f..2721777df 100644
--- a/packages/server/src/services/admin.ts
+++ b/packages/server/src/services/admin.ts
@@ -117,23 +117,33 @@ export const getDokployUrl = async () => {
return `http://${settings?.serverIp}:${process.env.PORT}`;
};
-export const getTrustedOrigins = async () => {
- const members = await db.query.member.findMany({
- where: eq(member.role, "owner"),
- with: {
- user: true,
- },
- });
+const TRUSTED_ORIGINS_CACHE_TTL_MS = 30 * 60_000;
+let trustedOriginsCache: { data: string[]; expiresAt: number } | null = null;
- if (members.length === 0) {
- return [];
+export const getTrustedOrigins = async () => {
+ const runQuery = async () => {
+ const rows = await db
+ .select({ trustedOrigins: user.trustedOrigins })
+ .from(member)
+ .innerJoin(user, eq(member.userId, user.id))
+ .where(eq(member.role, "owner"));
+ return Array.from(new Set(rows.flatMap((r) => r.trustedOrigins ?? [])));
+ };
+
+ if (IS_CLOUD) {
+ const now = Date.now();
+ if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
+ return trustedOriginsCache.data;
+ }
+ const trustedOrigins = await runQuery();
+ trustedOriginsCache = {
+ data: trustedOrigins,
+ expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
+ };
+ return trustedOrigins;
}
- const trustedOrigins = members.flatMap(
- (member) => member.user.trustedOrigins || [],
- );
-
- return Array.from(new Set(trustedOrigins));
+ return runQuery();
};
export const getTrustedProviders = async () => {
diff --git a/packages/server/src/services/environment.ts b/packages/server/src/services/environment.ts
index d37e7b789..9be18a287 100644
--- a/packages/server/src/services/environment.ts
+++ b/packages/server/src/services/environment.ts
@@ -34,42 +34,139 @@ export const createEnvironment = async (
export const findEnvironmentById = async (environmentId: string) => {
const environment = await db.query.environments.findFirst({
where: eq(environments.environmentId, environmentId),
+ columns: {
+ name: true,
+ description: true,
+ environmentId: true,
+ isDefault: true,
+ projectId: true,
+ env: true,
+ },
with: {
applications: {
with: {
- deployments: true,
- server: true,
+ server: {
+ columns: {
+ name: true,
+ serverId: true,
+ },
+ },
+ },
+ columns: {
+ name: true,
+ applicationId: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
},
},
mariadb: {
with: {
- server: true,
+ server: {
+ columns: {
+ name: true,
+ serverId: true,
+ },
+ },
+ },
+ columns: {
+ mariadbId: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
},
},
mongo: {
with: {
- server: true,
+ server: {
+ columns: {
+ name: true,
+ serverId: true,
+ },
+ },
+ },
+ columns: {
+ mongoId: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
},
},
mysql: {
with: {
- server: true,
+ server: {
+ columns: {
+ name: true,
+ serverId: true,
+ },
+ },
+ },
+ columns: {
+ mysqlId: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
},
},
postgres: {
with: {
- server: true,
+ server: {
+ columns: {
+ name: true,
+ serverId: true,
+ },
+ },
+ },
+ columns: {
+ postgresId: true,
+ name: true,
+ description: true,
+ createdAt: true,
+ applicationStatus: true,
+ serverId: true,
},
},
redis: {
with: {
- server: true,
+ server: {
+ columns: {
+ name: true,
+ serverId: true,
+ },
+ },
+ },
+ columns: {
+ redisId: true,
+ name: true,
+ createdAt: true,
+ applicationStatus: true,
+ description: true,
+ serverId: true,
},
},
compose: {
with: {
- deployments: true,
- server: true,
+ server: {
+ columns: {
+ name: true,
+ serverId: true,
+ },
+ },
+ },
+ columns: {
+ composeId: true,
+ name: true,
+ createdAt: true,
+ composeStatus: true,
+ description: true,
+ serverId: true,
},
},
project: true,
@@ -98,6 +195,12 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
compose: true,
project: true,
},
+ columns: {
+ name: true,
+ description: true,
+ environmentId: true,
+ isDefault: true,
+ },
});
return projectEnvironments;
};
@@ -169,6 +272,7 @@ export const duplicateEnvironment = async (
name: input.name,
description: input.description || originalEnvironment.description,
projectId: originalEnvironment.projectId,
+ env: originalEnvironment.env,
})
.returning()
.then((value) => value[0]);
diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts
index 32e5e4a7e..c15856dc3 100644
--- a/packages/server/src/setup/server-setup.ts
+++ b/packages/server/src/setup/server-setup.ts
@@ -281,17 +281,43 @@ const installRequirements = async (
.on("error", (err) => {
client.end();
if (err.level === "client-authentication") {
- onData?.(
- `Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
- );
+ const technicalDetail = `Error: ${err.message} ${err.level}`;
+ const friendlyMessage = [
+ "",
+ "❌ Couldn't connect to your server — the SSH key was not accepted.",
+ "",
+ "This usually means the key doesn't match what's on the server, or the key format is invalid.",
+ "",
+ `Technical details: ${technicalDetail}`,
+ "",
+ "💡 Hints:",
+ " • Check that the SSH key you added in Dokploy is the same one installed on the server (e.g. in ~/.ssh/authorized_keys).",
+ " • Try generating a new SSH key in Dokploy and add only the public key to the server, then try again.",
+ " • Make sure to follow the instructions on the Setup Server Button on the SSH Keys tab",
+ ].join("\n");
+ onData?.(friendlyMessage);
reject(
new Error(
- `Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
+ `Authentication failed: Invalid SSH private key. ${technicalDetail}`,
),
);
} else {
- onData?.(`SSH connection error: ${err.message} ${err.level}`);
- reject(new Error(`SSH connection error: ${err.message}`));
+ const technicalDetail = `${err.message} ${err.level ?? ""}`.trim();
+ const friendlyMessage = [
+ "",
+ "❌ Couldn't connect to your server.",
+ "",
+ "The connection failed before setup could run. Common causes: wrong IP or port, firewall blocking access, or the server is offline.",
+ "",
+ `Technical details: ${technicalDetail}`,
+ "",
+ "💡 Hints:",
+ " • Check that the server IP address and SSH port are correct and the server is powered on.",
+ " • If the server is in a private network, ensure Dokploy can reach it (VPN, firewall rules, or correct security groups).",
+ " • Make sure the SSH port (usually 22) is open and the SSH service is running on the server.",
+ ].join("\n");
+ onData?.(friendlyMessage);
+ reject(new Error(`SSH connection error: ${technicalDetail}`));
}
})
.connect({
diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts
index 86ed1e86c..230453e56 100644
--- a/packages/server/src/utils/docker/domain.ts
+++ b/packages/server/src/utils/docker/domain.ts
@@ -106,10 +106,6 @@ export const writeDomainsToCompose = async (
compose: Compose,
domains: Domain[],
) => {
- if (!domains.length) {
- return "";
- }
-
try {
const composeConverted = await addDomainToCompose(compose, domains);
const path = getComposePath(compose);
@@ -145,7 +141,7 @@ export const addDomainToCompose = async (
result = await loadDockerCompose(compose);
}
- if (!result || domains.length === 0) {
+ if (!result) {
return null;
}
diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts
index cd0249000..d44e3ccaf 100644
--- a/packages/server/src/utils/process/execAsync.ts
+++ b/packages/server/src/utils/process/execAsync.ts
@@ -201,14 +201,31 @@ export const execAsyncRemote = async (
.on("error", (err) => {
conn.end();
if (err.level === "client-authentication") {
+ const technicalDetail = `Error: ${err.message} ${err.level}`;
+ const friendlyMessage = [
+ "",
+ "❌ Couldn't connect to your server — the SSH key was not accepted.",
+ "",
+ "This usually means the key doesn't match what's on the server, or the key format is invalid.",
+ "",
+ `Technical details: ${technicalDetail}`,
+ "",
+ "💡 Hints:",
+ " • Check that the SSH key you added in Dokploy is the same one installed on the server (e.g. in ~/.ssh/authorized_keys).",
+ " • Try generating a new SSH key in Dokploy and add only the public key to the server, then try again.",
+ " • Make sure to follow the instructions on the Setup Server Button on the SSH Keys tab and then click on deployments tab and check the logs for more details.",
+ ].join("\n");
const errorMsg = `Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`;
- onData?.(errorMsg);
+ onData?.(friendlyMessage);
reject(
- new ExecError(errorMsg, {
- command,
- serverId,
- originalError: err,
- }),
+ new ExecError(
+ `Authentication failed: Invalid SSH private key. ${friendlyMessage}`,
+ {
+ command,
+ serverId,
+ originalError: err,
+ },
+ ),
);
} else {
const errorMsg = `SSH connection error: ${err.message}`;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7e8369280..efd3780e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -138,8 +138,8 @@ importers:
specifier: workspace:*
version: link:../../packages/server
'@dokploy/trpc-openapi':
- specifier: 0.0.16
- version: 0.0.16(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
+ specifier: 0.0.17
+ version: 0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1
@@ -1291,8 +1291,8 @@ packages:
'@codemirror/view@6.39.15':
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
- '@dokploy/trpc-openapi@0.0.16':
- resolution: {integrity: sha512-95pukFwMkSKLfnUB21OnYVwmR832NVD6ewI65ZgpxYxTrmdVGCUzvphqs85fiq4UKV3qcuUSq3nn47d3Sh09zg==}
+ '@dokploy/trpc-openapi@0.0.17':
+ resolution: {integrity: sha512-pXWbqx2W0MoWav/wehEqcXzORLgn7PhnmLsZza1v6+lOSo0Vwuu47PrITbRYKQ2zZcR1nTL18TrgPuMzXK23Iw==}
peerDependencies:
'@trpc/server': ^11.1.0
zod: ^4.3.6
@@ -8867,7 +8867,7 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
- '@dokploy/trpc-openapi@0.0.16(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
+ '@dokploy/trpc-openapi@0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
dependencies:
'@trpc/server': 11.10.0(typescript@5.9.3)
co-body: 6.2.0