diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index f4d96948cc..176ed3a07f 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx @@ -1,6 +1,6 @@ import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; +import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -51,6 +51,7 @@ import { api } from "@/utils/api"; const BitbucketProviderSchema = z.object({ composePath: z.string().min(1), + composeWorkingDir: z.string().optional(), repository: z .object({ repo: z.string().min(1, "Repo is required"), @@ -84,6 +85,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", + composeWorkingDir: "", repository: { owner: "", repo: "", @@ -141,6 +143,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { slug: data.bitbucketRepositorySlug || "", }, composePath: data.composePath, + composeWorkingDir: data.composeWorkingDir || "", bitbucketId: data.bitbucketId || "", watchPaths: data.watchPaths || [], enableSubmodules: data.enableSubmodules ?? false, @@ -156,6 +159,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { bitbucketOwner: data.repository.owner, bitbucketId: data.bitbucketId, composePath: data.composePath, + composeWorkingDir: data.composeWorkingDir ?? "", composeId, sourceType: "bitbucket", composeStatus: "idle", @@ -413,6 +417,43 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { )} /> + ( + +
+ Working Directory + + + + + + +

+ Optional subdirectory (relative to the repository + root) from which docker compose will be launched. + Useful when the compose file relies on a local .env, + build contexts or volumes that are colocated inside + a subfolder. Leave empty to run from the repository + root. +

+
+
+
+
+ + + + + +
+ )} + /> { branch: "", repositoryURL: "", composePath: "./docker-compose.yml", + composeWorkingDir: "", sshKey: undefined, watchPaths: [], enableSubmodules: false, @@ -83,6 +85,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { branch: data.customGitBranch || "", repositoryURL: data.customGitUrl || "", composePath: data.composePath, + composeWorkingDir: data.composeWorkingDir || "", watchPaths: data.watchPaths || [], enableSubmodules: data.enableSubmodules ?? false, }); @@ -97,6 +100,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { composeId, sourceType: "git", composePath: values.composePath, + composeWorkingDir: values.composeWorkingDir ?? "", composeStatus: "idle", watchPaths: values.watchPaths || [], enableSubmodules: values.enableSubmodules, @@ -225,6 +229,43 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { )} /> + ( + +
+ Working Directory + + + + + + +

+ Optional subdirectory (relative to the repository + root) from which docker compose will be launched. + Useful when the compose file relies on a local .env, + build contexts or volumes that are colocated inside a + subfolder. Leave empty to run from the repository + root. +

+
+
+
+
+ + + + + +
+ )} + /> { const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", + composeWorkingDir: "", repository: { owner: "", repo: "", @@ -141,6 +143,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { owner: data.giteaOwner || "", }, composePath: data.composePath || "./docker-compose.yml", + composeWorkingDir: data.composeWorkingDir || "", giteaId: data.giteaId || "", watchPaths: data.watchPaths || [], enableSubmodules: data.enableSubmodules ?? false, @@ -154,6 +157,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { giteaRepository: data.repository.repo, giteaOwner: data.repository.owner, composePath: data.composePath, + composeWorkingDir: data.composeWorkingDir ?? "", giteaId: data.giteaId, composeId, sourceType: "gitea", @@ -404,6 +408,43 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { )} /> + ( + +
+ Working Directory + + + + + + +

+ Optional subdirectory (relative to the repository + root) from which docker compose will be launched. + Useful when the compose file relies on a local .env, + build contexts or volumes that are colocated inside + a subfolder. Leave empty to run from the repository + root. +

+
+
+
+
+ + + + +
+ )} + /> + { const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", + composeWorkingDir: "", repository: { owner: "", repo: "", @@ -132,6 +134,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { owner: data.owner || "", }, composePath: data.composePath, + composeWorkingDir: data.composeWorkingDir || "", githubId: data.githubId || "", watchPaths: data.watchPaths || [], triggerType: data.triggerType || "push", @@ -147,6 +150,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { composeId, owner: data.repository.owner, composePath: data.composePath, + composeWorkingDir: data.composeWorkingDir ?? "", githubId: data.githubId, sourceType: "github", composeStatus: "idle", @@ -399,6 +403,43 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { )} /> + ( + +
+ Working Directory + + + + + + +

+ Optional subdirectory (relative to the repository + root) from which docker compose will be launched. + Useful when the compose file relies on a local .env, + build contexts or volumes that are colocated inside + a subfolder. Leave empty to run from the repository + root. +

+
+
+
+
+ + + + + +
+ )} + /> { const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", + composeWorkingDir: "", repository: { owner: "", repo: "", @@ -151,6 +153,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { gitlabPathNamespace: data.gitlabPathNamespace || "", }, composePath: data.composePath, + composeWorkingDir: data.composeWorkingDir || "", gitlabId: data.gitlabId || "", watchPaths: data.watchPaths || [], enableSubmodules: data.enableSubmodules ?? false, @@ -164,6 +167,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { gitlabRepository: data.repository.repo, gitlabOwner: data.repository.owner, composePath: data.composePath, + composeWorkingDir: data.composeWorkingDir ?? "", gitlabId: data.gitlabId, composeId, gitlabProjectId: data.repository.id, @@ -431,6 +435,43 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { )} /> + ( + +
+ Working Directory + + + + + + +

+ Optional subdirectory (relative to the repository + root) from which docker compose will be launched. + Useful when the compose file relies on a local .env, + build contexts or volumes that are colocated inside + a subfolder. Leave empty to run from the repository + root. +

+
+
+
+
+ + + + + +
+ )} + /> { try { const { COMPOSE_PATH } = paths(!!compose.serverId); - const projectPath = join(COMPOSE_PATH, compose.appName, "code"); + const projectPath = getComposeRunPath(compose, COMPOSE_PATH); const path = compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath; const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`; diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 790116cb6d..a63e4b2e8f 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -3,6 +3,7 @@ import { paths } from "@dokploy/server/constants"; import type { InferResultType } from "@dokploy/server/types/with"; import boxen from "boxen"; import { quote } from "shell-quote"; +import { sanitizeComposeWorkingDir } from "../compose/working-dir"; import { writeDomainsToCompose } from "../docker/domain"; import { encodeBase64, @@ -10,17 +11,29 @@ import { prepareEnvironmentVariables, } from "../docker/utils"; +export { sanitizeComposeWorkingDir } from "../compose/working-dir"; + export type ComposeNested = InferResultType< "compose", { environment: { with: { project: true } }; mounts: true; domains: true } >; +// Resolves the absolute directory from which `docker compose` should run. +export const getComposeRunPath = ( + compose: Pick, + composePath: string, +) => { + const base = join(composePath, compose.appName, "code"); + const workingDir = sanitizeComposeWorkingDir(compose.composeWorkingDir); + return workingDir ? join(base, workingDir) : base; +}; + export const getBuildComposeCommand = async (compose: ComposeNested) => { const { COMPOSE_PATH } = paths(!!compose.serverId); const { sourceType, appName, mounts, composeType, domains } = compose; const command = createCommand(compose); const envCommand = getCreateEnvFileCommand(compose); - const projectPath = join(COMPOSE_PATH, compose.appName, "code"); + const projectPath = getComposeRunPath(compose, COMPOSE_PATH); const exportEnvCommand = getExportEnvCommand(compose); const newCompose = await writeDomainsToCompose(compose, domains); @@ -99,8 +112,9 @@ export const createCommand = (compose: ComposeNested) => { export const getCreateEnvFileCommand = (compose: ComposeNested) => { const { COMPOSE_PATH } = paths(!!compose.serverId); const { env, composePath, appName } = compose; + const runPath = getComposeRunPath(compose, COMPOSE_PATH); const composeFilePath = - join(COMPOSE_PATH, appName, "code", composePath) || + join(runPath, composePath) || join(COMPOSE_PATH, appName, "code", "docker-compose.yml"); const envFilePath = join(dirname(composeFilePath), ".env"); diff --git a/packages/server/src/utils/compose/working-dir.ts b/packages/server/src/utils/compose/working-dir.ts new file mode 100644 index 0000000000..980bccda0b --- /dev/null +++ b/packages/server/src/utils/compose/working-dir.ts @@ -0,0 +1,12 @@ +// Strips leading "./" and any leading "/" so it can be safely joined onto the +// repo's "code" directory. +export const sanitizeComposeWorkingDir = ( + workingDir: string | null | undefined, +) => { + if (!workingDir) return ""; + const trimmed = workingDir.trim(); + if (!trimmed) return ""; + const normalized = trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, ""); + if (!normalized || normalized === "." || normalized === "./") return ""; + return normalized; +}; diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 8094f1df2a..ad251675b4 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -4,6 +4,7 @@ import { paths } from "@dokploy/server/constants"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; import { parse, stringify } from "yaml"; +import { sanitizeComposeWorkingDir } from "../compose/working-dir"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; import { cloneGitRepository } from "../providers/git"; @@ -53,7 +54,12 @@ export const getComposePath = (compose: Compose) => { path = composePath; } - return join(COMPOSE_PATH, appName, "code", path); + const workingDir = sanitizeComposeWorkingDir(compose.composeWorkingDir); + const base = workingDir + ? join(COMPOSE_PATH, appName, "code", workingDir) + : join(COMPOSE_PATH, appName, "code"); + + return join(base, path); }; export const loadDockerCompose = async (