feat(compose): transactional deployments with automatic rollback (port of upstream #5182) - #207
Merged
Merged
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a one-click option to redeploy Docker Compose services with clean volumes, removing the need to manually edit the run command. When triggered, it runs `docker compose down --volumes` before the standard deploy build step. The feature is only available for docker-compose type projects (not swarm stacks).
Consistent with the rest of the codebase where every docker compose invocation uses `env -i PATH="$PATH"` for a clean environment.
…ests Two cases where the UI silently did nothing: - DashboardLayout declared a `metaName` prop and 18 settings pages pass it, but the component only destructured `children`, so the value was discarded. None of those pages render their own <Head> either, so every settings page fell back to the default title from _app. The layout now renders a <title>, using the whitelabeling app name so the suffix is correct on rebranded instances. - The requests table's fallback cell only rendered its message when `statsLogs?.data.length === 0`. While the query is in flight statsLogs is undefined, so that guard is false and the cell rendered nothing at all -- a blank panel, indistinguishable from a broken page. The query's isLoading flag wasn't even destructured. It now shows a spinner, matching the queue and deployments tables.
Enable deploy-time ${{vault.*}} resolution from Phase via the REST API
(Service Account token + SSE-enabled apps), matching existing Infisical/Doppler providers.
Fixes Dokploy#5122
Co-authored-by: Cursor <cursoragent@cursor.com>
Providers, domains and records now each have their own page instead of a stack of modals. A provider opens its domains as cards, with the record count loading separately so the domains appear right away. A domain opens its records as a table with search, type filter and pagination. Creating or editing a record happens in a panel that slides in next to the table. The type list was capped at A and CNAME in the zod schema, so widening the dropdown alone would not have worked. AAAA, MX, TXT, NS, SRV, CAA and PTR work now. MX carries a priority that Cloudflare takes as a separate field and Route53 takes inline in the value, so the Cloudflare client splits it out on write and puts it back on read. The form keeps one value field for both providers. Cloudflare proxy status is now editable and visible. A, AAAA and CNAME records get a Proxied / DNS only toggle in the form and a cloud icon in the table. The proxy field only goes to the API when the caller sets it, so an update from another path cannot silently disable the proxy. Tests cover the MX priority round trip and the proxy rules in the Cloudflare client.
Cloudflare treats content as read-only for SRV and CAA and expects a data object instead, so both types were rejected on write. The client now parses the inline value into the fields Cloudflare wants, and builds the payload before the lookup request so a malformed value fails without spending an API call. The form rejects a malformed SRV or CAA value up front and shows the expected shape, so the error lands on the field instead of coming back from the provider.
Filtering from a later page left pageIndex past the end of the filtered set, so the table said no records matched while the counter above it reported matches. The page index is clamped to the available page count, and changing the search or the type filter goes back to the first page.
Route53 returns a record set as a list of values, but listing joined them into one string and writing sent that string back as a single ResourceRecord. Editing a multi-value NS, MX or TXT set therefore either failed validation or collapsed the set into one bogus value, and creating a record for a name that already had values replaced them silently. Values are now newline separated end to end: listing joins with a newline, writing splits back into one ResourceRecord per line, and creating merges into the existing set instead of replacing it. Unquoted TXT values get the quotes Route53 requires. The record panel shows a textarea for Route53 and validates every line.
Adds Porkbun as a supported DNS provider alongside Cloudflare and AWS Route53, allowing Dokploy to automatically create DNS records for domains managed on Porkbun. - New DnsClient implementation for the Porkbun API v3 - porkbun enum value and config schema (apiKey/secretApiKey) - Drizzle migration for the new DnsProviderType enum value - UI: provider icon, form fields and provider selector entry - Unit tests covering listZones/listRecords/upsertRecord/updateRecord/deleteRecord/testConnection
getCurrentPlanForUser and getProducts only inspected subscriptions.data[0], so a customer with more than one active Stripe subscription (e.g. Startup plus a separately purchased additional server) could have their plan resolved from the wrong subscription, resulting in currentPlan !== "startup" and the HubSpot chat bubble not rendering.
Summing across all active subscriptions could mix amounts with different billing intervals (isAnnualCurrent only reflects the matched plan subscription), producing an inconsistent total.
…kerContextPath" lied that the default path is ".", while being the Dockerfile directory.
Previously reset-password only reset the owner account's password. Passing an email as an argument now resets that specific user's password instead, while omitting it keeps the existing owner-reset behavior. Also scopes the update to the credential (password-based) account row via providerId, and fixes the success check to verify a row was actually updated instead of always reporting success.
Trims and lowercases the CLI email arg to match the normalization used elsewhere for user emails, so a differently-cased or whitespace-padded email no longer falsely reports "User not found".
…ery fails The loading branch added in the previous commit split the fallback cell two ways: spinner while in flight, "No results." otherwise. That second branch also catches the failure case -- when readStatsLogs errors, statsLogs stays undefined and isLoading goes false, so a failed request renders as a successful empty response. The query's isError/error were not destructured. The cell now branches three ways and reports the error through AlertBlock, matching how ShowTraefikSystem surfaces a failed readDirectories query.
…it-hash-overflow-mobile fix: prevent deployment commit hash overflow on mobile
feat: allow reset-password script to reset a specific user by email
feat: allow reset-password script to reset a specific user by email (cherry picked from commit 5345c00) [skip ci]
…nagement feat(dns): rework provider management and support all record types
…ubscription-plan fix: detect billing plan across all active Stripe subscriptions
Adds a modal to select and import multiple secrets from an assigned
vault provider at once, instead of typing each ${{vault.x.y}} reference
by hand. Existing keys are skipped by default and can be overridden per
row; also adds an "Access all" shortcut to the vault provider assignment
picker.
writeTraefikConfigRemote moved from an execAsyncRemote echo command to writeFileRemote (SFTP) a while back; the test still mocked the old execAsyncRemote path and asserted on an echo command that no longer runs.
feat: bulk import secrets from vault providers
Show the services attached to a remote server directly in its delete confirmation modal, with a link to each service and a per-service delete action, instead of only showing a generic 'has active services' blocker.
feat: list associated services in delete server modal
gitlab.one, github.one, gitea.one and bitbucket.one returned the full DB record (OAuth access/refresh tokens, client secrets, private keys, webhook secrets, app passwords) to any org member who merely had access to *use* a shared provider (sharedWithOrganization: true), not just its owner or an org owner/admin. Add canViewGitProviderSecrets() and null out the secret fields in each .one response when the caller isn't the provider owner or an org owner/admin.
findComposeById embedded the full github/gitlab/bitbucket/gitea relations (client secrets, OAuth tokens, private keys, app passwords) and compose.one only used canEditDeployGitSource to set a hasGitProviderAccess flag, never to hide the fields — so any member with read access to a compose service got the connected git provider's raw credentials, regardless of their access to that provider itself. Exclude the same secret columns findApplicationById already excludes. Deploys are unaffected: the actual clone step always re-fetches the provider fresh by id (findGithubById/findGitlabById/...), it never reads secrets off the embedded relation.
…leak fix: stop leaking git provider secrets to non-owner org members
🚀 Release v0.30.4
…server setup - Onboarding wizard (Welcome -> Plan -> Project -> Server -> Deploy -> Complete), shown once to an org owner with zero projects and no active plan/trial; skippable per step or entirely - Billing page shows the org's current plan, and a no-card 14-day trial card when eligible - Post-checkout "Welcome to Dokploy Cloud" modal simplified to reuse the onboarding wizard's own project/server/deploy steps behind a modal instead of its previous standalone 6-step flow, using the app's regular typography instead of the wizard's display serif - Onboarding wizard validates a persisted project still exists before resuming a stale session, and the dashboard layout no longer gets stuck redirecting to /dashboard/home once the local onboarding-active flag goes stale mid-session - onboardingCompletedAt column on user, with a backfill so existing users aren't shown the wizard - pnpm reset-onboarding dev script to reset a test account's onboarding state end to end
…an-gate Cloud onboarding wizard, billing trial card, and post-checkout server setup
…h-default fix/docker-context-path-default: Placeholder with name attribute "doc…
…sh-volumes feat: add Deploy with Fresh Volumes for Docker Compose
🚀 Release v0.30.5
Merges the upstream release tag v0.30.5 (v0.30.4 + v0.30.5, 78 commits) into the fork and sets the version to v0.30.5-community.1. Conflict resolutions (12): - apps/dokploy/package.json: theirs + fork version. - drizzle/meta/0188-0190_snapshot.json: ours. The fork released migrations at those numbers long ago, so upstream's colliding .sql files and journal entries were dropped and their schema delta is re-issued as a fork migration (follow-up commit). - db/schema/notification.ts + settings/notifications/handle-notifications.tsx: upstream's serverThreshold for gotify/ntfy, plus the fork's SMTP-no-auth nullable credentials and scheduleFailure flag re-applied on top. - compose/general/actions.tsx: theirs (upstream now ships the fresh-volumes action the fork had ported); the fork's Load Template action re-applied. - services/compose.ts: theirs for the git-provider secret stripping in findComposeById and the freshVolumes plumbing; the fork's runComposeBuild helper is kept because the compose-preview path calls it, and it already performs the identical clone -> patches -> down --volumes -> build sequence, so fresh volumes is not applied twice. Backup-policy hooks and the deployment error-message fix survive. - utils/builders/compose.ts: theirs for --project-directory-only-with-mounts and the new --env-file flag; the fork's isolated-network MTU handling and pullImagesOnDeploy flag re-applied. - utils/docker/utils.ts: both kept. Upstream's waitForSwarmServiceConvergence is a new function with new call sites in the six database services; the fork's waitForSwarmServiceStable is only used by services/application.ts. The conflict was positional (both blocks land before checkPostgresHealth), not semantic. Upstream's check is not a superset: it returns as soon as running >= desired, with no post-running stability window, no exclusion of pre-existing tasks and no daemon-clock anchoring, so replacing the fork's helper with it would reintroduce the rolling-update and crash-loop bugs fixed in #184/#188. - docker/logs/terminal-line.tsx: theirs (identical change, upstream spelling). - docker/logs/docker-logs-id.tsx: upstream's responsive classes kept; the viewport stays rounded-t because the fork's command input form is attached below it. Stream termination on CONNECTING and the paused-input disable survive. Adaptation in an auto-merged file: upstream's new application.deployNginxQuickstart called generateTraefikMeDomain expecting a bare host string. The fork's version takes a projectId and returns { domain, baseDomain, source }, so the call site now passes the project id and uses .domain. Two tests were retargeted at upstream's replacements: the Traefik remote writer now goes through writeFileRemote (SFTP) instead of a base64 shell command, and the MCP tool scope snapshot gains six new upstream procedures.
Upstream v0.30.4/v0.30.5 added migrations 0188_volatile_piledriver, 0189_wooden_nextwave and 0190_nappy_anita_blake. The fork released its own 0188/0189/0190 long ago, so instances have already run migrations at those numbers and upstream's copies cannot be adopted as-is. Per the playbook's number-collision rule, upstream's three .sql files, their snapshots and their _journal.json entries were dropped, the fork's kept, and the schema delta regenerated at the next free fork slot. The generated SQL contains exactly the three upstream schema changes and nothing else - no DROP COLUMN, so no fork column was lost in the schema merge (organization.wildcard_domain, project.wildcardDomain, project.useOrganizationWildcard and the mcp-oauth tables all survive). Edits on top of the generated file: - IF NOT EXISTS guards on both ADD VALUE and the ADD COLUMN, so an instance that already has the objects is a no-op. - The data backfill from upstream's 0190 is hand-carried verbatim after a statement breakpoint; drizzle cannot emit data migrations. It is deliberately not a column DEFAULT: existing users must skip the onboarding wizard, new users must keep a NULL onboardingCompletedAt so it still shows. Journal validated: idx unique, contiguous and monotonic 0..199, `when` monotonic, and 0199's when (1788711192772) is above the previous fork tail (0198 at 1788561607051). Files kept at LF.
Adds four notes for the next sync: - Rule 4 (number collisions) now records the v0.30.5 case: upstream 0188/0189/ 0190 re-issued as the fork's 0199, including the hand-carried onboarding backfill and why it must not become a column DEFAULT. - Adapted at v0.30.5: why waitForSwarmServiceConvergence and waitForSwarmServiceStable both stay, with the specific behaviours upstream's check lacks. - Adapted at v0.30.5: every upstream call site of generateTraefikMeDomain has to be adapted to the fork's projectId argument and object return - the new onboarding quickstart auto-merged cleanly and only the typecheck caught it. - Superseded at v0.30.5: remote Traefik writes moved to SFTP; the fork's regression test was retargeted rather than deleted. - The cloud onboarding wizard's gate on self-hosted instances.
…lt context Upstream f1e2467 ("fix/docker-context-path-default") changed the default Docker build context for dockerfile apps from "the directory containing the Dockerfile" to the repository root, so the UI placeholder that always claimed the default is "." is now accurate. getDockerContextPath no longer returns null and builders/docker-file.ts lost its defaultContextPath fallback. The fork's real-execution test builds Dokploy/examples /deno, whose Dockerfile does `COPY deno.json .`. With the context now at the repo root that COPY cannot resolve and the build fails in about a second. Set dockerContextPath explicitly - exactly the migration every existing subdirectory-Dockerfile app has to make. Also add getDeploymentErrorMessage to the services/deployment mock. It is called from deployApplication's catch block, so without it every real deploy failure was reported as "No getDeploymentErrorMessage export is defined on the mock" and the actual build error never reached the log - which is what hid this one. The behaviour break is recorded in docs/UPSTREAM_SYNC.md so it reaches the release notes: users whose Dockerfile lives in a subdirectory and copies paths relative to it must now set dockerContextPath.
…ast good release Port of Dokploy#5182. A failed `docker compose` deploy no longer leaves the service down. The compose file and .env on disk are snapshotted before the deploy touches the code directory, every successful deploy refreshes a "last good" snapshot, and a failure restores that snapshot and brings the previous release back up. The deployment entry still records the failure; the service itself is only marked broken when the rollback also failed. The compose service header gains Live / Deploying / Deploy failed badges, clickable domain chips and a click-to-copy project name so a rolled-back service reads correctly in the UI. Adapted for the fork: - the snapshot is taken inside runComposeBuild, so compose preview deployments get the same transactional behaviour with snapshots isolated under the preview appName - rollback is skipped for fresh-volumes deploys, whose pre-state has already been destroyed by `down --volumes` - every generated path goes through shell-quote; composePath and appName are user-controlled - the restore command drops the fork's `--pull always` alongside `--build` - the restore only runs when the files were actually restored, instead of re-upping a broken compose file on a first-ever deploy
Upstream Dokploy#5182 ships no tests. These pin the parts that are easy to get subtly wrong: - snapshot/restore paths resolve to the same files the deploy writes, and the backup directory sits outside code/ so a clone cannot wipe it - previews snapshot under their own isolated appName - a hostile composePath stays inside a single shell word - the rollback marker is anchored and bound to one deployment id - the restore prefers the last-good snapshot, drops --build and --pull always, and never emits the marker unless files were restored - the transactional wrapper is off for stack deploys and for fresh-volumes deploys - didRollbackSucceed fails closed and routes to the remote server
…t the constant filename
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Port of Dokploy/dokploy#5182 by @EngAbo3lia (Ahmed Aboalia). The feature commit keeps his authorship; the follow-up commit adds the tests and is mine.
What it does
A failed
docker composedeploy used to leave the service down until someone noticed and redeployed by hand. Now the deploy is transactional and the previous release keeps serving traffic.What "last good release" means
It is a filesystem snapshot, not a DB record — no schema change and no migration. Two pairs of files live in
<COMPOSE_PATH>/<appName>/.deploy-backup/(deliberately next tocode/, so agit cloneor raw-file rewrite can't wipe it):docker-compose.yml.bak/env.baklast-good-docker-compose.yml.bak/last-good-env.bakOnly the rendered compose file and the generated
.envare snapshotted. Images, volumes and the git ref are not — the restore re-runs the samedocker compose up -dagainst the restored files, which is what brings the previous containers back.How the rollback triggers
backupCurrentDeploymentruns before the clone / raw-file rewrite. If the snapshot can't be taken the deploy aborts rather than mutatecode/untransactionally.docker compose upfails, the generated script restoreslast-good-*(falling back to the pre-deploy*.bak), re-runs the deploy command without--buildand without--pull always, and — only if both the file restore and theupsucceeded — echoes__DOKPLOY_ROLLBACK_OK__:<deploymentId>into the deployment log.didRollbackSucceedgreps the log for that exact anchored, deployment-id-bound line. The deployment is always recorded aserror; the service (composeStatus) is only flipped toerrorwhen the rollback did not succeed..env).The UI half of the upstream PR is included as-is: pulsing green Live / amber Deploying / red Deploy failed badges in the compose service header, clickable domain chips (disabled ones struck through), and a click-to-copy compose project name. Both new queries are permission-gated (
deployment.read,domain.read) and use existing procedures — no new tRPC procedures, so no MCP scope-snapshot change.Fresh-volumes interaction
deployCompose/rebuildComposeacceptfreshVolumes, which runsdocker compose -p <app> down --volumesbefore the build. Reasoning through the combination:up-ing it would boot the previous release on empty volumes — a database re-initialising from scratch — whilecomposeStatuswas flipped back todone, i.e. the UI would report a data-wiped service as live and healthy. Worse, a later fixed deploy would inherit whatever that fresh boot wrote.So the transactional wrapper is disabled entirely when
freshVolumesis set (isTransactional = composeType === "docker-compose" && !freshVolumes). A fresh-volumes deploy is explicitly destructive and stays non-transactional; a failure marks the serviceerror, as it does today. Upstream does not consider this case — it has nofreshVolumesflag.Stack (
composeType === "stack") deploys are excluded too, same as upstream:stack deployis declarative and converges on its own.Compose previews
Upstream hooks
deployComposeandrebuildCompose. The fork routes bothdeployComposeand the compose-preview path through the sharedrunComposeBuild, so the snapshot was placed there instead of indeployCompose— previews get the same transactional behaviour for free, and because a preview entity'sappNameis the isolated preview app name, its snapshots land in their own.deploy-backupdirectory and can never collide with the base service's. A broken PR push therefore no longer takes the existing preview URL down.What previews deliberately do not get: the status flip.
previewStatusstayserroron a failed preview deploy — that status describes the PR's deployment, not a long-lived service, and the marker/composeStatusmechanism is specific tocompose.Deviations from upstream, and why
deploymentId?: stringpositional 2nd argoptions: { deploymentId?, freshVolumes? }cp "${composeFilePath}" …)quote()fromshell-quotecomposePathandappNameare user-controlled — upstream's form is a command-injection vector (composePath: './x.yml"; touch /tmp/pwned; #'). Follows the fork'squoteAdditionalFlagsprecedent--build--buildand--pull alwayspullImagesOnDeploy; re-pulling on a restore would re-fetch the very tag that broke the deploydocker … upruns unconditionally, then&& [ "$RESTORE_FILES_OK" = "1" ]upruns only insideif [ "$RESTORE_FILES_OK" = "1" ]upthe broken compose filepersistLastGoodis baremkdir/cpunderset -emkdirafter a successfuldocker compose upwould flip a successful deploy to faileddeployCompose+rebuildComposerunComposeBuild(coversdeployCompose+ previews) +rebuildComposefreshVolumesconceptgetComposePathfromutils/docker/domaingetComposeFilePath/getComposeEnvFilePath/getComposeBackupDirlocal tobuilders/compose.tsutils/docker/domainwholesale (build-compose-command.test.tsmocks it down towriteDomainsToComposealone); the env path also has to mirrorgetCreateEnvFileCommand, which is notgetComposePathfor raw services with a nestedcomposePath.getCreateEnvFileCommandwas refactored onto the shared helper so the two can't drift// @ts-ignore→// @ts-expect-erroronsendBuildErrorNotificationsgetDeploymentErrorMessage, so that line doesn't exist hereSecrets in logs
The restore block writes only fixed strings plus
docker composeoutput; no env values are echoed. The${exportEnvCommand}expansion inherited from the deploy line is provably empty in the transactional branch (it is non-empty only forstack, which is excluded).redactSecretsalready runs onExecErrorfor anything that surfaces to the user.Tests
23 + 7 new cases across two files (upstream ships none):
__test__/compose/transactional-rollback.test.ts— pure command construction:code/; compose/env paths match what the deploy actually writes (incl. the raw-vs-nested-composePathsplit); previews resolve to their own isolated dircomposePathstays inside one single-quoted shell wordlast-good-*over the pre-deploy snapshot, drops--buildand--pull alwayswhile the forward deploy keeps them, requires an.envrestore whencreateEnvFileis on and tolerates its absence when it is off, and never emits the marker outside theRESTORE_FILES_OKguardstack, none forfreshVolumes: true, wrapper present forfreshVolumes: false__test__/compose/transactional-rollback-decision.test.ts— decision logic withexecAsync/execAsyncRemotemocked:didRollbackSucceedtrue onLIVE_OK, false onLIVE_FAILED, false when the probe itself throws (fails closed), and routed viaexecAsyncRemotefor remote servicesbackupCurrentDeploymentappends to the deployment log, routes local vs remote, and propagates failure so the deploy aborts before mutatingcode/Verification
pnpm --filter=dokploy run typecheck— clean (stale*.tsbuildinfodeleted first)pnpm --filter=@dokploy/server build— cleancompose-project-directory.test.ts(--env-file deploy\.envpath separator),wss/readValidDirectory,backups/restore-use-statement,server/server-setup, and the two*.real.test.tsDocker/swarm suites. None are touched by this change.biome format— clean repo-wide.Not verified here: end-to-end behaviour on a live instance (no rollback was exercised against a real Docker daemon); upstream reports doing that in the PR description.