Skip to content

feat(compose): support additional compose files (-f a -f b) - #5457

Open
OrtegaMatias wants to merge 2 commits into
Dokploy:canaryfrom
OrtegaMatias:feat/additional-compose-files
Open

OrtegaMatias wants to merge 2 commits into
Dokploy:canaryfrom
OrtegaMatias:feat/additional-compose-files

Conversation

@OrtegaMatias

@OrtegaMatias OrtegaMatias commented Sep 13, 2026

Copy link
Copy Markdown

Problem

#1727 asked for native support for multiple compose files (docker compose -f a -f b). It was closed as "already supported via custom command" — but that workaround only patches the deploy command. Two follow-up comments on the same thread after the close report what's still broken:

  • @NikoKS: "Dokploy is not able to find any service from the additional compose.yaml file using the Domains feature... the deployment will fail. And also the environment variables are not applied on the other compose file."
  • @edemir206: "we are forced to break the DRY principle and duplicate our entire base configuration into a monolithic production.yml just to override a few specific settings."

Root cause: getComposePath/loadDockerCompose/readComposeFile in domain.ts only ever resolve compose.composePath (the single UI field). loadServices (used by the Domains picker and collision checks) calls straight into that, so any service defined only in the second -f file is invisible — even if the custom command correctly builds/starts it.

What this adds

  • compose.composePathAdditional: text[] — new column, same shape as the existing watchPaths array.
  • domain.ts: loadDockerCompose/loadDockerComposeRemote now read every additional file and shallow-merge their services on top of the primary one (new mergeComposeSpecifications), so loadServices sees the full set.
  • builders/compose.ts: the generated deploy command appends -f <path> per additional file — a native alternative to hand-writing a Command override for exactly this case.
  • UI: "Additional Compose Files" field on the GitHub provider form, same tag-input pattern as Watch Paths.
  • Test: 6 cases for mergeComposeSpecifications (null handling, additive services, key-level override across files, ordering, mixed-null input).

Scope note

I scoped the UI to the GitHub provider form only for this PR (it's the one I could verify end-to-end against a real 34-service compose file). GitLab/Gitea/Bitbucket/Git carry an identical composePath+watchPaths block today and would need the same ~90-line addition — happy to add all four here if you'd rather land it in one PR, or as a fast-follow.

Verification

  • Real-world case: base docker-compose.yml (34 services) + a ~200-line override adding build:/volumes:/profiles: to 22 of them. docker compose -f base.yml -f override.yml config resolves correctly with the command this PR generates.
  • pnpm --filter=@dokploy/server typecheck and pnpm --filter=dokploy typecheck: clean.
  • biome check: clean on all touched files.
  • New test suite: 6/6 passing.
  • Migration generated with drizzle-kit generate (not hand-written) against the current schema.

RetriggerConfidence Score: 0/5

This PR should not merge until remote path execution is secured and multi-file deployment preparation preserves a complete, consistently transformed configuration.

Summary

  • Includes a nullable array migration and six merge-helper tests.
  • Deployment preparation also consumes the new merge result, exposing configuration-loss, missing-declaration, patch-ordering, and randomized-deployment failures.
  • Remote reads introduce an additional command-injection input and unbounded SSH connection fan-out.

Reviews (1) · Last reviewed commit: "feat(compose): support additional compos..."

Closes the follow-ups left open on Dokploy#1727 after it was closed as
'already supported via custom command': the custom-command workaround
only patches the deploy command, so Dokploy's own service discovery
(getComposePath/loadDockerCompose/readComposeFile) still only reads
the primary composePath. Any service that only exists in the second
file is invisible to the Domains picker and collision checks, exactly
what was reported in the thread after the close.

- compose.composePathAdditional: text[] column (new migration), mirrors
  the existing watchPaths array field.
- domain.ts: loadDockerCompose/loadDockerComposeRemote now read every
  additional file and shallow-merge their services on top of the
  primary one via the new mergeComposeSpecifications, so anything that
  reads service names through loadServices (Domains picker, collision
  checks) sees the full merged set - not just the primary file.
- builders/compose.ts: the generated deploy command now appends
  '-f <path>' for each additional file, same as
  'docker compose -f a.yml -f b.yml up'. This is the native
  alternative to hand-writing a Command override for this exact case.
- UI: 'Additional Compose Files' field on the GitHub provider form
  (compose/general/generic/save-github-provider-compose.tsx), same
  tag-input pattern already used for Watch Paths. Scoped to GitHub for
  this PR since it is the primary provider I could verify end-to-end;
  the other four provider forms (gitlab/gitea/bitbucket/git) carry an
  identical composePath+watchPaths block today and would need the same
  ~90 line addition, happy to extend in this PR or a fast-follow if
  preferred.
- Test: mergeComposeSpecifications (6 cases - null handling, additive
  services, key-level override, multi-file ordering).

Verified against a real 34-service compose file (base + a ~200 line
override adding build:/volumes:/profiles: to 22 services): 'docker
compose -f base.yml -f override.yml config' resolves correctly with
the generated command, and loadServices returns all 34 service names
instead of only the base file's set.

typecheck (server + dokploy) and biome check both clean; existing
should-deploy/traefik test suites untouched.
Comment on lines +141 to +144
const { stdout, stderr } = await execAsyncRemote(
compose.serverId as string,
`cat ${path}`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Additional paths execute shell commands

A user permitted to update a Compose resource can save override.yml; id # in composePathAdditional and trigger cached service discovery. The resulting cat .../override.yml; id # executes the second command under the managed server’s SSH account without deploying anything. Although the primary path already had this unsafe pattern, this change extends it to the newly accepted additional paths. Quote the complete path with quote([path]) or read it through SFTP.

How this was verified: The update schema accepts unrestricted strings, the update persists them, and cached service discovery passes each joined path unescaped to the SSH command executor.

Suggested change
const { stdout, stderr } = await execAsyncRemote(
compose.serverId as string,
`cat ${path}`,
);
const { stdout, stderr } = await execAsyncRemote(
compose.serverId as string,
`cat ${quote([path])}`,
);

Comment on lines +100 to +103
merged.services[serviceName] = {
...(merged.services[serviceName] ?? {}),
...serviceDefinition,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shallow merge deletes base configuration

This merge is also used for deployment, not just service discovery. If the base service has environment: { DATABASE_URL: "...", LOG_LEVEL: "info" } and the override contains only environment: { LOG_LEVEL: "debug" }, this spread drops DATABASE_URL. writeDomainsToCompose then overwrites the primary file with that result before Docker reads either file, so Docker cannot recover the base value. Nested build/deploy settings and additive collections have the same problem. Preserve Compose merge semantics before writing the deployment model, or keep this discovery-only approximation out of the write-back path.

Knowledge Base Used: Build and Compose workflows

Comment on lines +93 to +98
const merged: ComposeSpecification = { ...validSpecs[0] };
merged.services = { ...(validSpecs[0]?.services ?? {}) };

for (const spec of validSpecs.slice(1)) {
for (const [serviceName, serviceDefinition] of Object.entries(
spec.services ?? {},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Additional root declarations are lost

An override that adds a database service using data:/var/lib/postgresql/data and declares volumes: { data: {} } produces a rewritten primary file referencing an undeclared volume: this merge retains the service but discards the override’s root declarations. Docker Compose deployment recovers the declaration by reading the additional file, but Start reads only the rewritten primary and fails. Stack deployment also reads only the primary and fails immediately. Merge additional root volumes, networks, configs, and secrets into the prepared model instead of retaining only the first specification’s root properties.

Knowledge Base Used: Build and Compose workflows

Comment on lines +148 to +154
const additionalFileFlags = (compose.composePathAdditional ?? [])
.filter((additionalPath): additionalPath is string =>
Boolean(additionalPath?.trim()),
)
.map((additionalPath) => `-f ${quote([additionalPath])} `)
.join("");
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} ${additionalFileFlags}up -d --build --remove-orphans${pullFlag}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Overrides break randomized deployments

With randomize enabled and a suffix set, preparation renames merged service web to web-<suffix> and writes it into the primary file. The additional file still declares web, so appending it here makes Docker treat the override as another service rather than applying it to the renamed service. An environment-only override then fails because the extra service has neither an image nor a build; a complete definition can start an unintended duplicate. Merge the source files before transformation and deploy the prepared model without replaying the original overrides.

Knowledge Base Used: Build and Compose workflows

}
return null;

return mergeComposeSpecifications([primary, ...additionalPaths.map(loadOne)]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Primary patches discard additional services

When an enabled patch supplies the primary Compose file, the Domains picker now offers additional-file services, but deployment preparation loses them. addDomainToCompose loads this merged result and then replaces it entirely with applyComposeFilePatch(compose). A domain attached to an override-only service consequently fails with “does not exist in the compose,” even though the configured additional file contains that service. Apply the primary-file patch before merging additional specifications so discovery and deployment preparation use the same service set. The remote loader has the same ordering problem.

Knowledge Base Used: Build and Compose workflows

return primary;
}

const additional = await Promise.all(additionalPaths.map(loadOne));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Remote reads open unbounded connections

Each additional file opens a separate SSH connection, and this Promise.all starts every connection simultaneously. A longer list creates a burst of SSH handshakes on every service-discovery or preparation request, while connection failures are silently treated as absent specifications. As a non-blocking improvement, use bounded concurrency or a shared connection, and surface failures for explicitly configured files rather than returning an incomplete service set.

Addresses the review on this PR (5 P1 + 1 P2):

- Command injection: additional-file paths were interpolated into
  `cat ${path}` unescaped when read over SSH. Every path is now quoted
  with shell-quote before it reaches a shell command.
- Shallow merge deletes base configuration / additional root
  declarations are lost: the hand-rolled per-service spread only
  merged `services`, dropped root-level volumes/networks/configs/
  secrets declared only in an additional file, and replaced nested
  maps (environment, labels, deploy...) instead of merging them.
  loadDockerCompose/loadDockerComposeRemote now resolve every file
  through `docker compose -f a -f b ... config --no-interpolate`,
  Compose's own merge engine, instead of reimplementing it in JS.
  --no-interpolate keeps ${VAR} references literal so env resolution
  still happens at actual `docker compose up` time.
- Primary patches discard additional services: addDomainToCompose
  loaded the merge, then unconditionally replaced it with the patched
  primary alone, dropping every additional-file service. A pending
  patch is now materialized to a temp file and merged with the
  additional files the same way the real primary would be, then
  cleaned up.
- Overrides break randomized deployments: writeDomainsToCompose
  already bakes the full merge into the primary file before
  `docker compose up` runs, so createCommand no longer re-adds
  `-f additionalFile` flags at deploy time -- re-layering them a
  second time duplicated array-typed keys (ports/volumes) and broke
  `randomize` (merged primary has suffixed names the un-suffixed
  additional file no longer matches).
- Unbounded connections / swallowed failures: additional-file
  existence is now resolved with a single batched remote probe
  instead of one SSH connection per file via Promise.all, and a
  genuine docker compose config failure (auth, malformed YAML) now
  throws instead of being silently treated as an empty spec.

mergeComposeSpecifications is removed; its test file is replaced with
an integration test that exercises loadDockerCompose against real
compose files and the real docker compose binary (services merge
across files, environment deep-merges, root volumes only in an
additional file survive, a not-yet-pushed additional file is skipped,
a missing primary returns null).
@OrtegaMatias

Copy link
Copy Markdown
Author

Thanks for the review -- all 6 points addressed in 2760ac8:

  • Command injection: every path is now quoted with shell-quote before reaching a shell command (both the existence probe and the docker compose invocation).
  • Shallow merge / lost root declarations: replaced the hand-rolled JS merge with the real docker compose -f a -f b ... config --no-interpolate, so environment/labels/deploy deep-merge correctly and root-level volumes/networks/configs/secrets declared only in an additional file survive. --no-interpolate keeps ${VAR} literal.
  • Patches discarding additional services: a pending patch is now materialized to a temp file and merged with the additional files the same way the real primary would be, instead of replacing the merge outright.
  • Randomize breaking with overrides: since writeDomainsToCompose already bakes the full merge into the primary file before docker compose up runs, createCommand no longer re-adds -f additionalFile flags at deploy time (that was re-layering the override a second time on an already-merged primary).
  • Unbounded SSH connections / swallowed failures: existence is resolved with one batched probe instead of one connection per file, and a genuine docker compose config failure now throws instead of being treated as an empty spec.

mergeComposeSpecifications is gone; the test file is replaced with an integration test against real compose files and the real docker compose binary (CI already has Docker available via docker swarm init in the test job).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant