diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-profiles.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-profiles.tsx new file mode 100644 index 0000000000..2f3af7e7dc --- /dev/null +++ b/apps/dokploy/components/dashboard/compose/advanced/add-profiles.tsx @@ -0,0 +1,219 @@ +import { VALID_COMPOSE_PROFILE_REGEX } from "@dokploy/server/utils/compose/profiles"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { HelpCircle, X } from "lucide-react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; + +interface Props { + composeId: string; +} + +const ProfilesSchema = z.object({ + composeProfiles: z.array( + z + .string() + .regex( + VALID_COMPOSE_PROFILE_REGEX, + "Use only letters, digits, '-' and '_' (must start with a letter or digit)", + ), + ), +}); + +type ProfilesForm = z.infer; + +export const AddProfilesCompose = ({ composeId }: Props) => { + const utils = api.useUtils(); + const { data, refetch } = api.compose.one.useQuery( + { composeId }, + { enabled: !!composeId }, + ); + + const { mutateAsync, isPending } = api.compose.update.useMutation(); + + const form = useForm({ + defaultValues: { + composeProfiles: [], + }, + resolver: zodResolver(ProfilesSchema), + }); + + useEffect(() => { + if (data) { + form.reset({ + composeProfiles: data.composeProfiles ?? [], + }); + } + }, [data, form]); + + const isStack = data?.composeType === "stack"; + + const addProfile = (raw: string) => { + const value = raw.trim(); + if (!value) return; + if (!VALID_COMPOSE_PROFILE_REGEX.test(value)) { + toast.error( + "Invalid profile name (allowed: letters, digits, '-', '_')", + ); + return; + } + const current = form.getValues("composeProfiles") ?? []; + if (current.includes(value)) return; + form.setValue("composeProfiles", [...current, value], { + shouldDirty: true, + }); + }; + + const onSubmit = async (values: ProfilesForm) => { + await mutateAsync({ + composeId, + composeProfiles: values.composeProfiles, + }) + .then(async () => { + toast.success("Compose profiles updated"); + await refetch(); + await utils.compose.one.invalidate({ composeId }); + await utils.compose.getDefaultCommand.invalidate({ composeId }); + }) + .catch(() => { + toast.error("Error updating compose profiles"); + }); + }; + + return ( + + +
+ Compose Profiles + + Activate one or more docker compose profiles. Only services that + belong to an activated profile (or no profile at all) will be + deployed. + +
+
+ + {isStack && ( + + Compose profiles are not supported by Docker Swarm{" "} + stack deploy. Switch the compose type to{" "} + docker-compose for profiles to take effect. + + )} +
+ + ( + +
+ Active Profiles + + + + + + +

+ Each profile name passed here is forwarded to docker + compose as --profile <name>. + Services declared with a matching profiles:{" "} + entry will be started; services with no profile are + always started. Leave empty to deploy all services. +

+
+
+
+
+
+ {field.value?.map((profile, index) => ( + + {profile} + { + const next = [...(field.value ?? [])]; + next.splice(index, 1); + form.setValue("composeProfiles", next, { + shouldDirty: true, + }); + }} + /> + + ))} +
+ +
+ { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const input = e.currentTarget; + addProfile(input.value); + input.value = ""; + } + }} + /> + +
+
+ +
+ )} + /> +
+ +
+ + +
+
+ ); +}; 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. +

+
+
+
+
+ + + + + +
+ )} + />
+ diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 126e80b1db..539479dcfc 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -727,6 +727,7 @@ export const composeRouter = createTRPCRouter({ branch: null, owner: null, composePath: undefined, + composeWorkingDir: "", githubId: null, triggerType: "push", diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 7803cb0a76..c2bd447de0 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -77,6 +77,8 @@ export const compose = pgTable("compose", { // enableSubmodules: boolean("enableSubmodules").notNull().default(false), composePath: text("composePath").notNull().default("./docker-compose.yml"), + composeWorkingDir: text("composeWorkingDir").notNull().default(""), + composeProfiles: text("composeProfiles").array().notNull().default([]), suffix: text("suffix").notNull().default(""), randomize: boolean("randomize").notNull().default(false), isolatedDeployment: boolean("isolatedDeployment").notNull().default(false), @@ -162,6 +164,8 @@ const createSchema = createInsertSchema(compose, { customGitSSHKeyId: z.string().optional(), command: z.string().optional(), composePath: z.string().min(1), + composeWorkingDir: z.string().optional(), + composeProfiles: z.array(z.string()).optional(), composeType: z.enum(["docker-compose", "stack"]).optional(), watchPaths: z.array(z.string()).optional(), sourceType: z diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 7a887cdc41..9071d18bed 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -7,7 +7,11 @@ import { cleanAppName, compose, } from "@dokploy/server/db/schema"; -import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose"; +import { + buildComposeProfilesFlags, + getBuildComposeCommand, + getComposeRunPath, +} from "@dokploy/server/utils/builders/compose"; import { randomizeSpecificationFile } from "@dokploy/server/utils/docker/compose"; import { cloneCompose, @@ -469,10 +473,12 @@ export const startCompose = async (composeId: string) => { 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`; + const profilesFlags = buildComposeProfilesFlags(compose); + const profilesPart = profilesFlags ? `${profilesFlags} ` : ""; + const baseCommand = `env -i PATH="$PATH" docker compose ${profilesPart}-p ${compose.appName} -f ${path} up -d`; if (compose.composeType === "docker-compose") { if (compose.serverId) { await execAsyncRemote( diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 790116cb6d..ced6a36fcb 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -3,6 +3,8 @@ import { paths } from "@dokploy/server/constants"; import type { InferResultType } from "@dokploy/server/types/with"; import boxen from "boxen"; import { quote } from "shell-quote"; +import { sanitizeComposeProfiles } from "../compose/profiles"; +import { sanitizeComposeWorkingDir } from "../compose/working-dir"; import { writeDomainsToCompose } from "../docker/domain"; import { encodeBase64, @@ -10,17 +12,45 @@ import { prepareEnvironmentVariables, } from "../docker/utils"; +export { + sanitizeComposeProfiles, + VALID_COMPOSE_PROFILE_REGEX, +} from "../compose/profiles"; +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; +}; + +// Builds the `--profile foo --profile bar` fragment that's inserted right +// after `compose` so only the requested service set is brought up. Stack mode +// is ignored because Swarm's `stack deploy` doesn't honor compose profiles. +export const buildComposeProfilesFlags = ( + compose: Pick, +) => { + if (compose.composeType !== "docker-compose") return ""; + const profiles = sanitizeComposeProfiles(compose.composeProfiles); + if (!profiles.length) return ""; + return profiles.map((p) => `--profile ${p}`).join(" "); +}; + 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); @@ -88,7 +118,9 @@ export const createCommand = (compose: ComposeNested) => { let command = ""; if (composeType === "docker-compose") { - command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`; + const profilesFlags = buildComposeProfilesFlags(compose); + const profilesPart = profilesFlags ? `${profilesFlags} ` : ""; + command = `compose ${profilesPart}-p ${appName} -f ${path} up -d --build --remove-orphans`; } else if (composeType === "stack") { command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`; } @@ -99,8 +131,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/profiles.ts b/packages/server/src/utils/compose/profiles.ts new file mode 100644 index 0000000000..83dbe92a88 --- /dev/null +++ b/packages/server/src/utils/compose/profiles.ts @@ -0,0 +1,24 @@ +// Profiles must match docker compose's identifier rules (alphanumerics plus +// `_` `-`); anything else risks being parsed as a flag and broken into bash. +export const VALID_COMPOSE_PROFILE_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + +export const sanitizeComposeProfiles = ( + profiles: ReadonlyArray | null | undefined, +): string[] => { + if (!profiles?.length) return []; + const seen = new Set(); + const result: string[] = []; + for (const raw of profiles) { + const trimmed = raw?.trim(); + if ( + !trimmed || + !VALID_COMPOSE_PROFILE_REGEX.test(trimmed) || + seen.has(trimmed) + ) { + continue; + } + seen.add(trimmed); + result.push(trimmed); + } + return result; +}; 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 (