- {/*
*/}
Mount Type
@@ -169,6 +216,13 @@ export const ShowVolumes = ({ id, type }: Props) => {
)}
+ {/* Show defined volumes from docker-compose.yml for compose services */}
+ {(() => {
+ const composeVolumes = getComposeVolumes(data, type);
+ return (
+ composeVolumes &&
+ );
+ })()}
);
diff --git a/apps/dokploy/server/api/models/compose.models.ts b/apps/dokploy/server/api/models/compose.models.ts
new file mode 100644
index 000000000..3f5ef1017
--- /dev/null
+++ b/apps/dokploy/server/api/models/compose.models.ts
@@ -0,0 +1,123 @@
+/**
+ * Compose model
+ */
+export interface Compose {
+ composeId: string;
+ name: string;
+ appName: string;
+ description: string | null;
+ env: string | null;
+ composeFile: string;
+ refreshToken: string | null;
+ sourceType: "git" | "github" | "gitlab" | "bitbucket" | "gitea" | "raw";
+ composeType: "docker-compose" | "stack";
+ repository: string | null;
+ owner: string | null;
+ branch: string | null;
+ autoDeploy: boolean | null;
+ gitlabProjectId: number | null;
+ gitlabRepository: string | null;
+ gitlabOwner: string | null;
+ gitlabBranch: string | null;
+ gitlabPathNamespace: string | null;
+ bitbucketRepository: string | null;
+ bitbucketOwner: string | null;
+ bitbucketBranch: string | null;
+ giteaRepository: string | null;
+ giteaOwner: string | null;
+ giteaBranch: string | null;
+ customGitUrl: string | null;
+ customGitBranch: string | null;
+ customGitSSHKeyId: string | null;
+ command: string;
+ enableSubmodules: boolean;
+ composePath: string;
+ suffix: string;
+ randomize: boolean;
+ isolatedDeployment: boolean;
+ isolatedDeploymentsVolume: boolean;
+ triggerType: string | null;
+ composeStatus: string;
+ environmentId: string;
+ createdAt: string;
+ watchPaths: string[] | null;
+ githubId: string | null;
+ gitlabId: string | null;
+ bitbucketId: string | null;
+ giteaId: string | null;
+ serverId: string | null;
+ environment: {
+ environmentId: string;
+ name: string;
+ projectId: string;
+ project: {
+ projectId: string;
+ name: string;
+ description: string | null;
+ organizationId: string;
+ createdAt: string;
+ };
+ };
+ deployments: Array<{
+ deploymentId: string;
+ status: string | null;
+ composeId: string | null;
+ createdAt: string;
+ }>;
+ mounts: Array<{
+ mountId: string;
+ type: "bind" | "volume" | "file";
+ hostPath: string | null;
+ volumeName: string | null;
+ filePath: string | null;
+ content: string | null;
+ serviceType:
+ | "application"
+ | "postgres"
+ | "mysql"
+ | "mariadb"
+ | "mongo"
+ | "redis"
+ | "compose";
+ mountPath: string;
+ applicationId: string | null;
+ postgresId: string | null;
+ mariadbId: string | null;
+ mongoId: string | null;
+ mysqlId: string | null;
+ redisId: string | null;
+ composeId: string | null;
+ }>;
+ domains: Array<{
+ domainId: string;
+ host: string;
+ path: string | null;
+ port: number | null;
+ https: boolean;
+ certificateType: string;
+ composeId: string | null;
+ createdAt: string;
+ }>;
+ github: any;
+ gitlab: any;
+ bitbucket: any;
+ gitea: any;
+ server: any;
+ backups: Array<{
+ backupId: string;
+ composeId: string | null;
+ destination: any;
+ deployments: any[];
+ }>;
+ hasGitProviderAccess: boolean;
+ unauthorizedProvider: string | null;
+ definedVolumesInComposeFile?: Record<
+ string,
+ {
+ config: any;
+ usage: Array<{ service: string; mountPath: string }>;
+ hostPath?: string;
+ isBindMount?: boolean;
+ }
+ >;
+}
diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts
index 9354988a8..515502dc0 100644
--- a/apps/dokploy/server/api/routers/compose.ts
+++ b/apps/dokploy/server/api/routers/compose.ts
@@ -20,6 +20,9 @@ import {
getComposeContainer,
getWebServerSettings,
IS_CLOUD,
+ loadDefinedVolumesInComposeFile,
+ loadDockerCompose,
+ loadDockerComposeRemote,
loadServices,
randomizeComposeFile,
randomizeIsolatedDeploymentComposeFile,
@@ -58,6 +61,7 @@ import {
apiUpdateCompose,
compose as composeTable,
} from "@/server/db/schema";
+import type { Compose } from "@/server/api/models/compose.models";
import type { DeploymentJob } from "@/server/queues/queue-types";
import {
cleanQueuesByCompose,
@@ -116,9 +120,12 @@ export const composeRouter = createTRPCRouter({
}
}),
+ /**
+ * Get a compose by ID
+ */
one: protectedProcedure
.input(apiFindCompose)
- .query(async ({ input, ctx }) => {
+ .query(async ({ input, ctx }): Promise
=> {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
@@ -172,10 +179,16 @@ export const composeRouter = createTRPCRouter({
}
}
+ // Load volumes defined in docker-compose.yml if exists
+ const definedVolumesInComposeFile = await loadDefinedVolumesInComposeFile(
+ input.composeId,
+ );
+
return {
...compose,
hasGitProviderAccess,
unauthorizedProvider,
+ definedVolumesInComposeFile,
};
}),
diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts
index 89a12a156..33c4ac0b8 100644
--- a/packages/server/src/services/compose.ts
+++ b/packages/server/src/services/compose.ts
@@ -108,6 +108,11 @@ export const createComposeByTemplate = async (
return newDestination;
};
+/**
+ * Find compose by ID
+ *
+ * @param composeId ID of the compose service
+ */
export const findComposeById = async (composeId: string) => {
const result = await db.query.compose.findFirst({
where: eq(compose.composeId, composeId),
@@ -185,6 +190,214 @@ export const loadServices = async (
return [...services];
};
+/**
+ * Load defined volumes from a docker-compose.yml file
+ *
+ * @param composeId ID of the compose service
+ */
+export const loadDefinedVolumesInComposeFile = async (composeId: string) => {
+ const compose = await findComposeById(composeId);
+
+ try {
+ // For raw compose type, we don't need to clone anything
+ if (compose.sourceType === "raw") {
+ let composeData: ComposeSpecification | null;
+ if (compose.serverId) {
+ composeData = await loadDockerComposeRemote(compose);
+ } else {
+ composeData = await loadDockerCompose(compose);
+ }
+
+ if (!composeData) {
+ return {};
+ }
+
+ return extractVolumesFromComposeData(composeData);
+ }
+
+ // Validate that we have the necessary provider configuration
+ const hasValidProvider = validateComposeProvider(compose);
+ if (!hasValidProvider) {
+ console.warn(
+ `No valid provider configuration for compose ${composeId}, returning empty volumes`,
+ );
+ return {};
+ }
+
+ // Clone and load the docker-compose file
+ const command = await cloneCompose(compose);
+ if (compose.serverId) {
+ await execAsyncRemote(compose.serverId, command);
+ } else {
+ await execAsync(command);
+ }
+
+ // Load and parse the docker-compose.yml file
+ let composeData: ComposeSpecification | null;
+ if (compose.serverId) {
+ composeData = await loadDockerComposeRemote(compose);
+ } else {
+ composeData = await loadDockerCompose(compose);
+ }
+
+ if (!composeData) {
+ return {};
+ }
+
+ return extractVolumesFromComposeData(composeData);
+ } catch (err) {
+ console.error("Error loading defined volumes:", err);
+ return {};
+ }
+};
+
+/**
+ * Validate that the compose has a valid provider configuration
+ */
+const validateComposeProvider = (compose: any): boolean => {
+ switch (compose.sourceType) {
+ case "github":
+ return !!compose.repository && !!compose.owner && !!compose.githubId;
+ case "gitlab":
+ return (
+ !!compose.gitlabRepository &&
+ !!compose.gitlabOwner &&
+ !!compose.gitlabId
+ );
+ case "bitbucket":
+ return (
+ !!compose.bitbucketRepository &&
+ !!compose.bitbucketOwner &&
+ !!compose.bitbucketId
+ );
+ case "gitea":
+ return (
+ !!compose.giteaRepository && !!compose.giteaOwner && !!compose.giteaId
+ );
+ case "git":
+ return !!compose.customGitUrl;
+ case "raw":
+ return true; // Raw always valid
+ default:
+ return false;
+ }
+};
+
+/**
+ * Extract volumes information from compose data
+ */
+const extractVolumesFromComposeData = (composeData: ComposeSpecification) => {
+ const volumesDefinition = composeData?.volumes || {};
+ const services = composeData?.services || {};
+
+ // Build a map of volume usage across services
+ const volumeUsage: Record<
+ string,
+ Array<{ service: string; mountPath: string }>
+ > = {};
+
+ // Track bind mounts (paths starting with / or .) that are not in volumes section
+ const bindMounts: Record<
+ string,
+ {
+ hostPath: string;
+ usage: Array<{ service: string; mountPath: string }>;
+ }
+ > = {};
+
+ // Iterate through all services to find volume usage
+ for (const [serviceName, serviceConfig] of Object.entries(services)) {
+ const serviceVolumes = (serviceConfig as any)?.volumes || [];
+
+ for (const volumeEntry of serviceVolumes) {
+ let volumeName: string | undefined;
+ let mountPath: string | undefined;
+ let hostPath: string | undefined;
+
+ if (typeof volumeEntry === "string") {
+ // Format: "volume_name:/path" or "/host/path:/container/path"
+ const parts = volumeEntry.split(":");
+ if (parts.length >= 2 && parts[0]) {
+ // Check if it's a named volume (not a path starting with / or .)
+ if (!parts[0].startsWith("/") && !parts[0].startsWith(".")) {
+ volumeName = parts[0];
+ mountPath = parts[1];
+ } else {
+ // It's a bind mount (path starting with / or .)
+ hostPath = parts[0];
+ mountPath = parts[1];
+ }
+ }
+ } else if (typeof volumeEntry === "object" && volumeEntry !== null) {
+ // Long syntax: { type: 'volume', source: 'volume_name', target: '/path' }
+ if (
+ (volumeEntry as any).type === "volume" ||
+ !(volumeEntry as any).type
+ ) {
+ volumeName = (volumeEntry as any).source;
+ mountPath = (volumeEntry as any).target;
+ } else if ((volumeEntry as any).type === "bind") {
+ hostPath = (volumeEntry as any).source;
+ mountPath = (volumeEntry as any).target;
+ }
+ }
+
+ if (volumeName && mountPath) {
+ if (!volumeUsage[volumeName]) {
+ volumeUsage[volumeName] = [];
+ }
+ volumeUsage[volumeName]!.push({
+ service: serviceName,
+ mountPath: mountPath,
+ });
+ } else if (hostPath && mountPath) {
+ // Track bind mount
+ const bindKey = `${hostPath}:${mountPath}`;
+ if (!bindMounts[bindKey]) {
+ bindMounts[bindKey] = {
+ hostPath: hostPath,
+ usage: [],
+ };
+ }
+ bindMounts[bindKey]!.usage.push({
+ service: serviceName,
+ mountPath: mountPath,
+ });
+ }
+ }
+ }
+
+ // Combine volume definitions with usage information
+ const result: Record<
+ string,
+ {
+ config: any;
+ usage: Array<{ service: string; mountPath: string }>;
+ hostPath?: string;
+ isBindMount?: boolean;
+ }
+ > = {};
+
+ for (const [volumeName, volumeConfig] of Object.entries(volumesDefinition)) {
+ result[volumeName] = {
+ config: volumeConfig,
+ usage: volumeUsage[volumeName] || [],
+ };
+ }
+
+ // Add bind mounts to the result
+ for (const [bindKey, bindData] of Object.entries(bindMounts)) {
+ result[bindKey] = {
+ config: null,
+ usage: bindData.usage,
+ hostPath: bindData.hostPath,
+ isBindMount: true,
+ };
+ }
+
+ return result;
+};
+
export const updateCompose = async (
composeId: string,
composeData: Partial,