{isError && (
@@ -262,7 +269,7 @@ const Register = ({ isCloud }: Props) => {
- {isCloud && (
+ {isAccountRegistration && (
Already have account?
@@ -310,12 +317,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
isCloud: true,
+ hasAdmin: true,
+ registrationEnabled: true,
},
};
}
const hasAdmin = await isAdminPresent();
- if (hasAdmin) {
+ if (hasAdmin && !DOKPLOY_ALLOW_REGISTRATION) {
return {
redirect: {
permanent: false,
@@ -326,6 +335,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
isCloud: false,
+ hasAdmin,
+ registrationEnabled: DOKPLOY_ALLOW_REGISTRATION,
},
};
}
diff --git a/apps/dokploy/scripts/migrate-auth-secret.ts b/apps/dokploy/scripts/migrate-auth-secret.ts
index 5a71678d9a..302612c6d8 100644
--- a/apps/dokploy/scripts/migrate-auth-secret.ts
+++ b/apps/dokploy/scripts/migrate-auth-secret.ts
@@ -46,7 +46,7 @@ async function main() {
if (records.length === 0) {
console.log("✅ No 2FA records found, nothing to migrate.");
- return;
+ process.exit(0);
}
console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`);
diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts
index 2548184781..d1fbb14d3a 100644
--- a/apps/dokploy/server/api/routers/compose.ts
+++ b/apps/dokploy/server/api/routers/compose.ts
@@ -862,6 +862,76 @@ export const composeRouter = createTRPCRouter({
}
}),
+ previewTemplate: protectedProcedure
+ .input(
+ z.object({
+ base64: z.string(),
+ appName: z.string(),
+ serverId: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ try {
+ if (input.serverId) {
+ const accessibleIds = await getAccessibleServerIds(ctx.session);
+ if (!accessibleIds.has(input.serverId)) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+
+ const decodedData = Buffer.from(input.base64, "base64").toString(
+ "utf-8",
+ );
+
+ let serverIp = "127.0.0.1";
+
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ serverIp = server.ipAddress;
+ } else if (process.env.NODE_ENV !== "development") {
+ const settings = await getWebServerSettings();
+ serverIp = settings?.serverIp || "127.0.0.1";
+ }
+
+ const templateData = JSON.parse(decodedData);
+ const config = parse(templateData.config) as CompleteTemplate;
+
+ if (!templateData.compose || !config) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Invalid template format. Must contain compose and config fields",
+ });
+ }
+
+ const configModified = {
+ ...config,
+ variables: {
+ APP_NAME: input.appName,
+ ...config.variables,
+ },
+ };
+
+ const processedTemplate = processTemplate(configModified, {
+ serverIp,
+ projectName: input.appName,
+ });
+
+ return {
+ compose: templateData.compose,
+ template: processedTemplate,
+ };
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Error processing template: ${error instanceof Error ? error.message : error}`,
+ });
+ }
+ }),
+
import: protectedProcedure
.input(
z.object({
diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts
index 6f3b1d1ae7..d17a04dfb6 100644
--- a/apps/dokploy/server/api/routers/deployment.ts
+++ b/apps/dokploy/server/api/routers/deployment.ts
@@ -151,6 +151,14 @@ export const deploymentRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["cancel"],
});
+ } else if (deployment.schedule?.serverId) {
+ const targetServer = await findServerById(deployment.schedule.serverId);
+ if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You don't have access to this deployment.",
+ });
+ }
}
if (!deployment.pid) {
@@ -188,6 +196,14 @@ export const deploymentRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["cancel"],
});
+ } else if (deployment.schedule?.serverId) {
+ const targetServer = await findServerById(deployment.schedule.serverId);
+ if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You don't have access to this deployment.",
+ });
+ }
}
const result = await removeDeployment(input.deploymentId);
await audit(ctx, {
@@ -197,4 +213,47 @@ export const deploymentRouter = createTRPCRouter({
});
return result;
}),
+
+ readLogs: protectedProcedure
+ .input(
+ z.object({
+ deploymentId: z.string().min(1),
+ tail: z.number().int().min(1).max(10000).default(100),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ const deployment = await findDeploymentById(input.deploymentId);
+ const serviceId = deployment.applicationId || deployment.composeId;
+ if (serviceId) {
+ await checkServicePermissionAndAccess(ctx, serviceId, {
+ deployment: ["read"],
+ });
+ } else if (deployment.schedule?.serverId) {
+ const targetServer = await findServerById(deployment.schedule.serverId);
+ if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You don't have access to this deployment.",
+ });
+ }
+ }
+
+ if (!deployment.logPath) {
+ return "";
+ }
+
+ const command = `tail -n ${input.tail} "${deployment.logPath}" 2>/dev/null || echo ""`;
+ const serverId = deployment.serverId || deployment.schedule?.serverId;
+ if (serverId) {
+ const { stdout } = await execAsyncRemote(serverId, command);
+ return stdout;
+ }
+
+ if (IS_CLOUD) {
+ return "";
+ }
+
+ const { stdout } = await execAsync(command);
+ return stdout;
+ }),
});
diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts
index 51c1fec5d9..6af018ed81 100644
--- a/apps/dokploy/server/api/routers/organization.ts
+++ b/apps/dokploy/server/api/routers/organization.ts
@@ -295,6 +295,14 @@ export const organizationRouter = createTRPCRouter({
});
}
+ // Owner role is non-delegable — no one can invite as owner
+ if (input.role === "owner") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Cannot invite a user with the owner role",
+ });
+ }
+
// If assigning a custom role, verify it exists
if (!["owner", "admin", "member"].includes(input.role)) {
const customRole = await db.query.organizationRole.findFirst({
diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts
index 93b7e6cf62..fc3b29d6e4 100644
--- a/apps/dokploy/server/api/routers/user.ts
+++ b/apps/dokploy/server/api/routers/user.ts
@@ -23,6 +23,7 @@ import {
apiUpdateUser,
invitation,
member,
+ session,
user,
} from "@dokploy/server/db/schema";
import {
@@ -32,7 +33,7 @@ import {
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
-import { and, asc, eq, gt } from "drizzle-orm";
+import { and, asc, eq, gt, ne } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
@@ -229,6 +230,15 @@ export const userRouter = createTRPCRouter({
password: bcrypt.hashSync(input.password, 10),
})
.where(eq(account.userId, ctx.user.id));
+
+ await db
+ .delete(session)
+ .where(
+ and(
+ eq(session.userId, ctx.user.id),
+ ne(session.id, ctx.session.id),
+ ),
+ );
}
try {
@@ -594,6 +604,13 @@ export const userRouter = createTRPCRouter({
});
}
+ if (input.role === "owner") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Cannot create a user with the owner role",
+ });
+ }
+
return await createOrganizationUserWithCredentials({
organizationId: ctx.session.activeOrganizationId,
email: input.email,
diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts
index 706a0dbecb..23c6650fdf 100644
--- a/packages/server/src/constants/index.ts
+++ b/packages/server/src/constants/index.ts
@@ -3,6 +3,27 @@ import path from "node:path";
import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true";
+export const isDokployRegistrationAllowed = (value?: string) =>
+ value === "true";
+export const DOKPLOY_ALLOW_REGISTRATION = isDokployRegistrationAllowed(
+ process.env.DOKPLOY_ALLOW_REGISTRATION,
+);
+export const shouldCreateDefaultOrganizationForSignUp = ({
+ isCloud,
+ hasOwner,
+ isSSORequest,
+ hasInvitation,
+ registrationAllowed,
+}: {
+ isCloud: boolean;
+ hasOwner: boolean;
+ isSSORequest: boolean;
+ hasInvitation: boolean;
+ registrationAllowed: boolean;
+}) =>
+ isCloud ||
+ !hasOwner ||
+ (!isSSORequest && !hasInvitation && registrationAllowed);
export const DOKPLOY_DOCKER_API_VERSION =
process.env.DOKPLOY_DOCKER_API_VERSION;
diff --git a/packages/server/src/db/schema/registry.ts b/packages/server/src/db/schema/registry.ts
index 68db88f802..02b2f4c558 100644
--- a/packages/server/src/db/schema/registry.ts
+++ b/packages/server/src/db/schema/registry.ts
@@ -44,6 +44,13 @@ export const registryRelations = relations(registry, ({ many }) => ({
}),
}));
+// Image references require a lowercase namespace (e.g. Docker Hub username).
+const registryUsernameSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .transform((s) => s.toLowerCase());
+
// Registry URLs must be hostname[:port] only — no shell metacharacters
// Empty string is allowed (means default/Docker Hub registry)
const registryUrlSchema = z
@@ -57,7 +64,7 @@ const registryUrlSchema = z
const createSchema = createInsertSchema(registry, {
registryName: z.string().min(1),
- username: z.string().min(1),
+ username: registryUsernameSchema,
password: z.string().min(1),
registryUrl: registryUrlSchema,
organizationId: z.string().min(1),
@@ -70,7 +77,7 @@ export const apiCreateRegistry = createSchema
.pick({})
.extend({
registryName: z.string().min(1),
- username: z.string().min(1),
+ username: registryUsernameSchema,
password: z.string().min(1),
registryUrl: registryUrlSchema,
registryType: z.enum(["cloud"]),
@@ -83,7 +90,7 @@ export const apiCreateRegistry = createSchema
export const apiTestRegistry = createSchema.pick({}).extend({
registryName: z.string().optional(),
- username: z.string().min(1),
+ username: registryUsernameSchema,
password: z.string().min(1),
registryUrl: registryUrlSchema,
registryType: z.enum(["cloud"]),
diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts
index c8dbf18073..dd5833439a 100644
--- a/packages/server/src/lib/auth.ts
+++ b/packages/server/src/lib/auth.ts
@@ -7,7 +7,11 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api";
import { admin, organization, twoFactor } from "better-auth/plugins";
import { and, desc, eq } from "drizzle-orm";
-import { IS_CLOUD } from "../constants";
+import {
+ DOKPLOY_ALLOW_REGISTRATION,
+ IS_CLOUD,
+ shouldCreateDefaultOrganizationForSignUp,
+} from "../constants";
import { db } from "../db";
import * as schema from "../db/schema";
import {
@@ -186,7 +190,7 @@ const { handler, api } = betterAuth({
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
- if (isAdminPresent) {
+ if (isAdminPresent && !DOKPLOY_ALLOW_REGISTRATION) {
throw new APIError("BAD_REQUEST", {
message: "Admin is already created",
});
@@ -195,12 +199,14 @@ const { handler, api } = betterAuth({
}
},
after: async (user, context) => {
- const isSSORequest = context?.path.includes("/sso");
+ const isSSORequest = Boolean(context?.path.includes("/sso"));
+ const hasInvitation = Boolean(context?.body?.invitationId);
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
+ const hasOwner = Boolean(isAdminPresent);
- if (!IS_CLOUD && !isAdminPresent) {
+ if (!IS_CLOUD && !hasOwner) {
await updateWebServerSettings({
serverIp: await getPublicIpWithFallback(),
});
@@ -231,7 +237,15 @@ const { handler, api } = betterAuth({
}
}
- if (IS_CLOUD || !isAdminPresent) {
+ if (
+ shouldCreateDefaultOrganizationForSignUp({
+ isCloud: IS_CLOUD,
+ hasOwner,
+ isSSORequest,
+ hasInvitation,
+ registrationAllowed: DOKPLOY_ALLOW_REGISTRATION,
+ })
+ ) {
await db.transaction(async (tx) => {
const organization = await tx
.insert(schema.organization)
diff --git a/packages/server/src/utils/cluster/upload.ts b/packages/server/src/utils/cluster/upload.ts
index aa014a05c8..6bf02547c9 100644
--- a/packages/server/src/utils/cluster/upload.ts
+++ b/packages/server/src/utils/cluster/upload.ts
@@ -101,8 +101,8 @@ export const getRegistryTag = (registry: Registry, imageName: string) => {
// Extract the repository name (last part after '/')
const repositoryName = extractRepositoryName(imageName);
- // Build the final tag using registry's username/prefix
- const targetPrefix = imagePrefix || username;
+ // Build the final tag using registry's username/prefix (must be lowercase for valid image refs)
+ const targetPrefix = (imagePrefix || username).toLowerCase();
const finalRegistry = registryUrl || "";
return finalRegistry