+
diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts
index 5767a38a9..50419c056 100644
--- a/apps/dokploy/server/api/routers/domain.ts
+++ b/apps/dokploy/server/api/routers/domain.ts
@@ -8,7 +8,10 @@ import {
findOrganizationById,
findPreviewDeploymentById,
findServerById,
+ generateApplicationDomain,
+ generatePreviewDeploymentDomain,
generateTraefikMeDomain,
+ getProjectWildcardDomain,
getWebServerSettings,
manageDomain,
removeDomain,
@@ -98,11 +101,31 @@ export const domainRouter = createTRPCRouter({
return await findDomainsByComposeId(input.composeId);
}),
generateDomain: protectedProcedure
- .input(z.object({ appName: z.string(), serverId: z.string().optional() }))
+ .input(
+ z.object({
+ appName: z.string(),
+ serverId: z.string().optional(),
+ projectId: z.string().optional(),
+ domainType: z.enum(["application", "preview"]).default("application"),
+ previewWildcard: z.string().optional().nullable(),
+ }),
+ )
.mutation(async ({ input, ctx }) => {
- return generateTraefikMeDomain(
+ if (input.domainType === "preview") {
+ return generatePreviewDeploymentDomain(
+ input.appName,
+ ctx.user.ownerId,
+ input.projectId,
+ input.serverId,
+ input.previewWildcard ?? undefined,
+ );
+ }
+
+ // Use the new generateApplicationDomain which supports custom wildcard domains
+ return generateApplicationDomain(
input.appName,
ctx.user.ownerId,
+ input.projectId,
input.serverId,
);
}),
@@ -116,6 +139,12 @@ export const domainRouter = createTRPCRouter({
const settings = await getWebServerSettings();
return settings?.serverIp || "";
}),
+ // Get the effective wildcard domain for a project (project-level or inherited from organization)
+ getEffectiveWildcardDomain: protectedProcedure
+ .input(z.object({ projectId: z.string() }))
+ .query(async ({ input }) => {
+ return getProjectWildcardDomain(input.projectId);
+ }),
update: protectedProcedure
.input(apiUpdateDomain)
diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts
index 834c8a399..025e27044 100644
--- a/apps/dokploy/server/api/routers/organization.ts
+++ b/apps/dokploy/server/api/routers/organization.ts
@@ -158,6 +158,47 @@ export const organizationRouter = createTRPCRouter({
.returning();
return result[0];
}),
+ // Update the wildcard domain for the organization
+ // This domain pattern will be used as default for all projects in the organization
+ updateWildcardDomain: adminProcedure
+ .input(
+ z.object({
+ wildcardDomain: z
+ .string()
+ .nullable()
+ .refine(
+ (val) => {
+ if (val === null || val === "") return true;
+ // Validate wildcard domain format: should start with * and be a valid domain pattern
+ // Examples: *.example.com, *-apps.example.com, *.apps.mydomain.org
+ const wildcardPattern =
+ /^\*[\.\-]?[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/;
+ return wildcardPattern.test(val);
+ },
+ {
+ message:
+ 'Invalid wildcard domain format. Use patterns like "*.example.com" or "*-apps.example.com"',
+ },
+ ),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const result = await db
+ .update(organization)
+ .set({
+ wildcardDomain: input.wildcardDomain || null,
+ })
+ .where(eq(organization.id, ctx.session.activeOrganizationId))
+ .returning();
+ return result[0];
+ }),
+ // Get the current wildcard domain configuration for the active organization
+ getWildcardDomain: protectedProcedure.query(async ({ ctx }) => {
+ const org = await db.query.organization.findFirst({
+ where: eq(organization.id, ctx.session.activeOrganizationId),
+ });
+ return org?.wildcardDomain ?? null;
+ }),
delete: protectedProcedure
.input(
z.object({
diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts
index 9f46d7de3..8df5cd205 100644
--- a/apps/dokploy/server/api/routers/project.ts
+++ b/apps/dokploy/server/api/routers/project.ts
@@ -29,6 +29,7 @@ import {
findProjectById,
findRedisById,
findUserById,
+ getProjectWildcardDomain,
IS_CLOUD,
updateProjectById,
} from "@dokploy/server";
@@ -326,6 +327,70 @@ export const projectRouter = createTRPCRouter({
throw error;
}
}),
+ // Update wildcard domain settings for a project
+ updateWildcardDomain: protectedProcedure
+ .input(
+ z.object({
+ projectId: z.string().min(1),
+ wildcardDomain: z
+ .string()
+ .nullable()
+ .refine(
+ (val) => {
+ if (val === null || val === "") return true;
+ // Validate wildcard domain format: should start with * and be a valid domain pattern
+ // Examples: *.example.com, *-apps.example.com, *.apps.mydomain.org
+ const wildcardPattern =
+ /^\*[\.\-]?[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/;
+ return wildcardPattern.test(val);
+ },
+ {
+ message:
+ 'Invalid wildcard domain format. Use patterns like "*.example.com" or "*-apps.example.com"',
+ },
+ ),
+ useOrganizationWildcard: z.boolean(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const currentProject = await findProjectById(input.projectId);
+ if (currentProject.organizationId !== ctx.session.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to update this project",
+ });
+ }
+
+ const project = await updateProjectById(input.projectId, {
+ wildcardDomain: input.wildcardDomain || null,
+ useOrganizationWildcard: input.useOrganizationWildcard,
+ });
+
+ return project;
+ }),
+ // Get wildcard domain configuration for a project (includes inheritance from organization)
+ getWildcardDomainConfig: protectedProcedure
+ .input(apiFindOneProject)
+ .query(async ({ input, ctx }) => {
+ const currentProject = await findProjectById(input.projectId);
+ if (currentProject.organizationId !== ctx.session.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this project",
+ });
+ }
+
+ // Get the effective wildcard domain (with inheritance)
+ const effectiveWildcard = await getProjectWildcardDomain(input.projectId);
+
+ return {
+ projectWildcardDomain: currentProject.wildcardDomain,
+ useOrganizationWildcard: currentProject.useOrganizationWildcard,
+ organizationWildcardDomain:
+ currentProject.organization?.wildcardDomain ?? null,
+ effectiveWildcardDomain: effectiveWildcard,
+ };
+ }),
duplicate: protectedProcedure
.input(
z.object({
diff --git a/packages/server/src/db/schema/account.ts b/packages/server/src/db/schema/account.ts
index 40789a4a3..0a1e54580 100644
--- a/packages/server/src/db/schema/account.ts
+++ b/packages/server/src/db/schema/account.ts
@@ -66,6 +66,9 @@ export const organization = pgTable("organization", {
ownerId: text("owner_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
+ // Custom wildcard domain for generated domains (e.g., "*-apps.example.com")
+ // When set, new applications will use this pattern instead of traefik.me
+ wildcardDomain: text("wildcardDomain"),
});
export const organizationRelations = relations(
diff --git a/packages/server/src/db/schema/project.ts b/packages/server/src/db/schema/project.ts
index abba26a7d..a7b5c9e56 100644
--- a/packages/server/src/db/schema/project.ts
+++ b/packages/server/src/db/schema/project.ts
@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
-import { pgTable, text } from "drizzle-orm/pg-core";
+import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -21,6 +21,13 @@ export const projects = pgTable("project", {
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
env: text("env").notNull().default(""),
+ // Custom wildcard domain settings for this project
+ // If wildcardDomain is set, it overrides the organization's wildcardDomain
+ // If useOrganizationWildcard is true (default), inherit from organization when wildcardDomain is null
+ wildcardDomain: text("wildcardDomain"),
+ useOrganizationWildcard: boolean("useOrganizationWildcard")
+ .notNull()
+ .default(true),
});
export const projectRelations = relations(projects, ({ many, one }) => ({
diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts
index d952e1f6a..77b44affa 100644
--- a/packages/server/src/lib/auth.ts
+++ b/packages/server/src/lib/auth.ts
@@ -189,7 +189,9 @@ const { handler, api } = betterAuth({
desc(schema.member.createdAt),
],
with: {
- organization: true,
+ organization: {
+ columns: { id: true },
+ },
},
});
@@ -326,7 +328,9 @@ export const validateRequest = async (request: IncomingMessage) => {
eq(schema.member.organizationId, organizationId),
),
with: {
- organization: true,
+ organization: {
+ columns: { ownerId: true },
+ },
},
});
@@ -389,7 +393,9 @@ export const validateRequest = async (request: IncomingMessage) => {
),
),
with: {
- organization: true,
+ organization: {
+ columns: { ownerId: true },
+ },
},
});
diff --git a/packages/server/src/services/domain.ts b/packages/server/src/services/domain.ts
index b2e15ed91..1106cd7ef 100644
--- a/packages/server/src/services/domain.ts
+++ b/packages/server/src/services/domain.ts
@@ -1,14 +1,18 @@
import dns from "node:dns";
import { promisify } from "node:util";
import { db } from "@dokploy/server/db";
+import {
+ generateCustomWildcardDomain,
+ generateRandomDomain,
+} from "@dokploy/server/templates";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
-import { generateRandomDomain } from "@dokploy/server/templates";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { type apiCreateDomain, domains } from "../db/schema";
import { findApplicationById } from "./application";
import { detectCDNProvider } from "./cdn";
+import { getProjectWildcardDomain } from "./project";
import { findServerById } from "./server";
export type Domain = typeof domains.$inferSelect;
@@ -68,11 +72,70 @@ export const generateTraefikMeDomain = async (
});
};
-export const generateWildcardDomain = (
+/**
+ * Generates a domain for an application.
+ * If the project has a custom wildcard domain configured (at project or organization level),
+ * it will use that pattern. Otherwise, it falls back to the default traefik.me domain.
+ *
+ * @param appName - The name of the application
+ * @param userId - The user ID (used for traefik.me fallback)
+ * @param projectId - Optional project ID to check for custom wildcard domain
+ * @param serverId - Optional server ID for remote servers
+ * @returns The generated domain string
+ */
+export const generateApplicationDomain = async (
appName: string,
- serverDomain: string,
-) => {
- return `${appName}-${serverDomain}`;
+ userId: string,
+ projectId?: string,
+ serverId?: string,
+): Promise => {
+ // Check if the project has a custom wildcard domain configured
+ if (projectId) {
+ const wildcardDomain = await getProjectWildcardDomain(projectId);
+
+ if (wildcardDomain) {
+ return generateCustomWildcardDomain({
+ appName,
+ wildcardDomain,
+ });
+ }
+ }
+
+ // Fall back to traefik.me domain
+ return generateTraefikMeDomain(appName, userId, serverId);
+};
+
+/**
+ * Generates a domain for preview deployments.
+ *
+ * Precedence:
+ * 1. Application-level preview wildcard (when provided)
+ * 2. Project/organization wildcard (via getProjectWildcardDomain)
+ * 3. traefik.me fallback
+ */
+export const generatePreviewDeploymentDomain = async (
+ appName: string,
+ userId: string,
+ projectId?: string,
+ serverId?: string,
+ previewWildcard?: string | null,
+ options?: {
+ fallbackGenerator?: typeof generateTraefikMeDomain;
+ },
+): Promise => {
+ const { fallbackGenerator = generateTraefikMeDomain } = options ?? {};
+ const effectiveWildcard =
+ previewWildcard ||
+ (projectId ? await getProjectWildcardDomain(projectId) : null);
+
+ if (effectiveWildcard) {
+ return generateCustomWildcardDomain({
+ appName,
+ wildcardDomain: effectiveWildcard,
+ });
+ }
+
+ return fallbackGenerator(appName, userId, serverId);
};
export const findDomainById = async (domainId: string) => {
diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts
index 1ece3bc53..3247054f4 100644
--- a/packages/server/src/services/preview-deployment.ts
+++ b/packages/server/src/services/preview-deployment.ts
@@ -15,7 +15,7 @@ import { removeTraefikConfig } from "../utils/traefik/application";
import { manageDomain } from "../utils/traefik/domain";
import { findApplicationById } from "./application";
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
-import { createDomain } from "./domain";
+import { createDomain, generatePreviewDeploymentDomain } from "./domain";
import { type Github, getIssueComment } from "./github";
import { getWebServerSettings } from "./web-server-settings";
@@ -138,11 +138,12 @@ export const createPreviewDeployment = async (
const org = await db.query.organization.findFirst({
where: eq(organization.id, application.environment.project.organizationId),
});
- const generateDomain = await generateWildcardDomain(
- application.previewWildcard || "*.traefik.me",
+ const generateDomain = await generatePreviewDeploymentDomain(
appName,
- application.server?.ipAddress || "",
org?.ownerId || "",
+ application.environment.projectId,
+ application.serverId || undefined,
+ application.previewWildcard,
);
const octokit = authGithub(application?.github as Github);
diff --git a/packages/server/src/services/project.ts b/packages/server/src/services/project.ts
index cf58b18fa..af34cd249 100644
--- a/packages/server/src/services/project.ts
+++ b/packages/server/src/services/project.ts
@@ -49,6 +49,7 @@ export const findProjectById = async (projectId: string) => {
const project = await db.query.projects.findFirst({
where: eq(projects.projectId, projectId),
with: {
+ organization: true,
environments: {
with: {
applications: true,
@@ -71,6 +72,39 @@ export const findProjectById = async (projectId: string) => {
return project;
};
+/**
+ * Get the effective wildcard domain for a project.
+ * Returns the project's wildcard domain if set, otherwise falls back to
+ * the organization's wildcard domain if useOrganizationWildcard is true.
+ * Returns null if no custom wildcard domain is configured.
+ */
+export const getProjectWildcardDomain = async (
+ projectId: string,
+): Promise => {
+ const project = await db.query.projects.findFirst({
+ where: eq(projects.projectId, projectId),
+ with: {
+ organization: true,
+ },
+ });
+
+ if (!project) {
+ return null;
+ }
+
+ // If the project has its own wildcard domain, use it
+ if (project.wildcardDomain) {
+ return project.wildcardDomain;
+ }
+
+ // If the project should inherit from organization, return organization's wildcard
+ if (project.useOrganizationWildcard && project.organization?.wildcardDomain) {
+ return project.organization.wildcardDomain;
+ }
+
+ return null;
+};
+
export const deleteProject = async (projectId: string) => {
const project = await db
.delete(projects)
diff --git a/packages/server/src/templates/index.ts b/packages/server/src/templates/index.ts
index db67cb36f..f4803f610 100644
--- a/packages/server/src/templates/index.ts
+++ b/packages/server/src/templates/index.ts
@@ -49,6 +49,50 @@ export const generateRandomDomain = ({
return `${truncatedProjectName}-${hash}${slugIp === "" ? "" : `-${slugIp}`}.traefik.me`;
};
+export interface CustomWildcardSchema {
+ appName: string;
+ wildcardDomain: string;
+}
+
+/**
+ * Generates a domain using a custom wildcard pattern.
+ * The wildcardDomain should be in the format "*-apps.example.com"
+ * where the "*" will be replaced with "{appName}-{randomHash}".
+ *
+ * @example
+ * generateCustomWildcardDomain({
+ * appName: "nitropage",
+ * wildcardDomain: "*-apps.example.com"
+ * })
+ * // Returns: "nitropage-a1b2c3-apps.example.com"
+ */
+export const generateCustomWildcardDomain = ({
+ appName,
+ wildcardDomain,
+}: CustomWildcardSchema): string => {
+ const hash = randomBytes(3).toString("hex");
+
+ // Domain labels have a max length of 63 characters
+ // Reserve space for: hash (6) + separator (1) + remaining domain parts
+ const maxAppNameLength = 40;
+ const truncatedAppName =
+ appName.length > maxAppNameLength
+ ? appName.substring(0, maxAppNameLength)
+ : appName;
+
+ // Replace the wildcard "*" with the app name and hash
+ // The wildcardDomain format should be like "*-apps.example.com" or "*.apps.example.com"
+ const replacement = `${truncatedAppName}-${hash}`;
+
+ // Handle both "*-apps.example.com" and "*.apps.example.com" patterns
+ if (wildcardDomain.startsWith("*")) {
+ return wildcardDomain.replace("*", replacement);
+ }
+
+ // If no wildcard found at the start, prepend the replacement
+ return `${replacement}.${wildcardDomain}`;
+};
+
export const generateHash = (length = 8): string => {
return randomBytes(Math.ceil(length / 2))
.toString("hex")