Skip to content

feat(compose): implement docker compose rollbacks#3879

Open
freddysae0 wants to merge 2 commits intoDokploy:canaryfrom
freddysae0:feat/compose-rollbacks
Open

feat(compose): implement docker compose rollbacks#3879
freddysae0 wants to merge 2 commits intoDokploy:canaryfrom
freddysae0:feat/compose-rollbacks

Conversation

@freddysae0
Copy link

@freddysae0 freddysae0 commented Mar 4, 2026

What is this PR about?

This PR implements the ability to perform Rollbacks on Docker Compose services. Previously, the platform only supported rolling back Dockerfile-based deployments.

This change:

  • Extends the rollbacks database schema to save the Compose context (like the raw composeFile contents and the precise Git commitHash).
  • Updates the Git clone provider utilities to accept an optional commitHash parameter and perform a hard checkout when a rollback is triggered.
  • Enables the existing "Rollback" UI button for compose type deployments in the dashboard deployments table.

Checklist

Before submitting this PR, please make sure that:

  • You created a dedicated branch based on the canary branch.
  • You have read the suggestions in the CONTRIBUTING.md file
  • You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.

Issues related (if applicable)

Docker-compose: ability to rollback to a previous deployment #2771

Screenshots (if applicable)

Screenshot 2026-03-04 at 03 21 42

Greptile Summary

This PR implements Docker Compose rollbacks by extending the rollback schema with a commitHash/composeFile context, threading commitHash through the git providers for a full-clone-and-checkout flow, and enabling the rollback UI button for compose-type deployments. The overall approach is sound but there are several correctness bugs that will cause rollbacks to fail silently or create unexpected side effects.

Issues found:

  • GitHub and Bitbucket providers not updatedcloneGithubRepository (github.ts:170) and cloneBitbucketRepository (bitbucket.ts:128) were not given commitHash support. Because git clone --depth 1 --branch is always used, any rollback for a GitHub- or Bitbucket-sourced compose service will silently deploy the latest branch HEAD instead of the saved commit. gitlab.ts and gitea.ts were correctly updated; github.ts and bitbucket.ts need the same treatment.

  • Compose rollbacks cannot be deleted — In removeRollbackById (rollbacks.ts:149–176), the db.delete call sits inside if (rollback?.image). Compose rollbacks have image: null, so this block is never entered and the record is never deleted from the database. The delete call must be moved outside the image guard.

  • Each rollback creates a new rollback entryrollbackComposeApplication calls deployCompose, which on a successful deployment calls createRollback. This means every rollback produces a new rollback snapshot, bloating the history with rollback-of-rollback entries and making the deployment list confusing.

  • Authorization bypass risk — In the rollback router, resolving organizationId via || means that if neither the application nor the compose path resolves (e.g., due to data inconsistency), organizationId is undefined. When ctx.session.activeOrganizationId is also nullish, undefined !== undefined is false and the auth guard is silently bypassed.

Confidence Score: 1/5

  • Not safe to merge — GitHub/Bitbucket rollbacks are silently broken and compose rollback deletion is non-functional.
  • Multiple runtime bugs: GitHub and Bitbucket providers ignore the commitHash parameter so rollbacks for those source types always deploy HEAD, the removeRollbackById function never deletes compose rollback records from the database, and each rollback operation creates an ever-growing chain of new rollback entries by calling deployComposecreateRollback recursively. The auth guard also has a potential nullish-bypass edge case.
  • packages/server/src/utils/providers/github.ts and bitbucket.ts (missing commitHash support), packages/server/src/services/rollbacks.ts (removeRollbackById deletion bug and recursive rollback creation), and apps/dokploy/server/api/routers/rollbacks.ts (auth guard).

Last reviewed commit: f4a4734

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

@freddysae0 freddysae0 requested a review from Siumauricio as a code owner March 4, 2026 02:28
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. enhancement New feature or request labels Mar 4, 2026
@dosubot
Copy link

dosubot bot commented Mar 4, 2026

Related Documentation

Checked 7 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

Comment on lines +209 to 228
const rollbackComposeApplication = async (
composeId: string,
fullContext: Compose & { commitHash?: string },
) => {
await updateCompose(composeId, {
composeFile: fullContext.composeFile,
env: fullContext.env,
// Provide everything else correctly if needed, but the primary drivers are the files.
});

// For git repos, we need a way to pass the commitHash to the deployment function so it checks it out.
// We'll call deployCompose with an optional commitHash override.
const { deployCompose } = await import("./compose");
await deployCompose({
composeId,
titleLog: "Rollback deployment",
descriptionLog: "Rolled back to a previous configuration",
commitHash: fullContext.commitHash,
});
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Each rollback triggers a new rollback entry — unbounded chain

rollbackComposeApplication calls deployCompose(), which on success calls createRollback() at lines 289–294 of compose.ts. This means every rollback operation creates a new rollback snapshot in the database, which in turn creates another rollback entry, ad infinitum as users perform successive rollbacks.

Unlike rollbackApplication (which directly updates the Docker service without going through the full deployment pipeline), this path:

  1. Creates a new deployment record
  2. Creates a new rollback record pointing to that deployment
  3. Populates the deployments list with rollback-triggered entries, making the history hard to read

Consider either:

  • Passing a flag like isRollback: true to deployCompose to skip calling createRollback when the deployment is itself a rollback, or
  • Implementing rollbackComposeApplication similarly to rollbackApplication — applying the Docker Compose state directly without going through deployCompose.

Comment on lines +32 to +38
const organizationId =
currentRollback?.deployment?.application?.environment?.project
.organizationId !== ctx.session.activeOrganizationId
) {
?.organizationId ||
currentRollback?.deployment?.compose?.environment?.project
?.organizationId;

if (organizationId !== ctx.session.activeOrganizationId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Authorization bypass when neither applicationId nor composeId is set

organizationId is resolved via ||. If both the application and compose paths return null/undefined (e.g., due to corrupted or partial data), organizationId becomes undefined. If ctx.session.activeOrganizationId is also undefined or null, then undefined !== undefined evaluates to false and the check passes — granting unauthorized access.

The original code raised a TypeError in this scenario (because it accessed .organizationId without optional chaining on the final property), which effectively blocked the action. The new code silently allows it when both sides are nullish.

Consider adding an explicit guard:

if (!organizationId) {
    throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "You are not authorized to rollback this deployment",
    });
}
if (organizationId !== ctx.session.activeOrganizationId) {
    throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "You are not authorized to rollback this deployment",
    });
}

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (2)

packages/server/src/utils/providers/github.ts
GitHub provider ignores commitHash — rollbacks silently deploy HEAD

cloneGithubRepository always runs git clone --branch ${branch} --depth 1 ... regardless of whether a commitHash was passed in entity. The CloneGithubRepository interface doesn't include a commitHash field, so the value is never read or acted upon.

When deployCompose triggers a rollback for a GitHub-sourced compose service, it passes commitHash in entity, but this function ignores it and checks out the latest commit of the branch — effectively rolling forward instead of backward.

The same fix applied to gitlab.ts and gitea.ts should be applied here:

// In CloneGithubRepository interface:
commitHash?: string;

// In cloneGithubRepository:
if (entity.commitHash) {
  command += `git clone ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
  command += `cd ${outputPath} && git checkout ${entity.commitHash};`;
} else {
  command += `git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
}

Note: The same issue exists in packages/server/src/utils/providers/bitbucket.ts at line 128 — cloneBitbucketRepository also uses a hard-coded --depth 1 clone without any commitHash handling.


packages/server/src/services/rollbacks.ts
Compose rollbacks are never deleted from the database

The db.delete call is nested inside if (rollback?.image). For compose rollbacks, image is explicitly set to null in createRollback, so this block is never entered and the record is never removed from the database. removeRollbackById simply returns the rollback object as if it succeeded, giving false positive feedback to the caller.

// Current (broken for compose):
if (rollback?.image) {
    try {
        // ...delete image...
        await db.delete(rollbacks).where(...); // ← only reached when image is non-null
    } catch (error) { ... }
}
return rollback; // ← always reached, even when nothing was deleted

The db.delete should be moved outside of the if (rollback?.image) guard so it always runs:

if (rollback?.image) {
    try {
        const deployment = await findDeploymentById(rollback.deploymentId);
        if (deployment?.applicationId) {
            const application = await findApplicationById(deployment.applicationId);
            await deleteRollbackImage(rollback.image, application.serverId);
        }
    } catch (error) {
        console.error(error);
    }
}

await db
    .delete(rollbacks)
    .where(eq(rollbacks.rollbackId, rollbackId))
    .returning()
    .then((res) => res[0]);

return rollback;

- Add commitHash support to GitHub and Bitbucket clone functions,
  performing a full clone + git checkout when a hash is provided
- Fix compose rollback deletion by moving db.delete() outside the
  image guard so records with image: null are also removed
- Prevent recursive rollback entries by adding isRollback flag to
  deployCompose(), skipping createRollback() during rollback ops
- Fix authorization bypass by using nullish coalescing and adding
  an explicit organizationId null check in the rollbacks router
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant