Compare commits

...

97 Commits

Author SHA1 Message Date
autofix-ci[bot] 9c194b276c [autofix.ci] apply automated fixes 2026-08-28 21:38:37 +00:00
Mauricio Siu cf0e8d236c fix: keep native log container id in sync when Swarm/compose replaces it
Closes #5134. The container list queries in the Logs tab only fetched
once on mount, so if the underlying container was replaced (restart,
reschedule, redeploy) while the page stayed open, the native log
WebSocket kept streaming from the stale id and either froze or failed
with 'No such container'.

Poll the container list every 5s and reconcile the selected id against
it, matching the pattern already used for automatic selection.
2026-08-28 15:38:01 -06:00
Mauricio Siu 5b6a8bd1ef Merge pull request #5142 from Dokploy/feat/add-terminal-permission
feat(permissions): add server.terminal to decouple server access from root SSH
2026-08-28 11:20:29 -06:00
Mauricio Siu d26f70642d Add journal entry for granting terminal permission to read roles 2026-08-28 11:20:13 -06:00
Mauricio Siu e2d2dbb00a Merge branch 'canary' into feat/add-terminal-permission 2026-08-28 11:19:02 -06:00
Mauricio Siu e4012d857d fix: enhance error handling and directory management in updateFileMount function 2026-08-28 11:18:53 -06:00
Mauricio Siu ab62080e9d Merge pull request #5203 from Dokploy/fix/file-mount-nested-path-update-5152
fix: File Mount update fails silently on nested paths
2026-08-27 10:49:56 -06:00
Mauricio Siu 3a8fad57e6 fix: mkdir parent dir before writing file mount updates
Fixes #5152. updateFileMount wrote directly to the target path without
ensuring its parent directory existed, and silently swallowed errors
instead of surfacing them. If the path already existed as a directory
(e.g. Docker pre-creating a bind mount target), the write silently
failed and left an empty directory instead of the file.
2026-08-27 10:49:15 -06:00
Mauricio Siu 203cddbeef Merge pull request #5202 from Dokploy/fix/traefik-empty-dynamic-config-5189
fix: don't write invalid empty traefik config for apps without domains
2026-08-27 09:37:56 -06:00
Mauricio Siu 032e804114 fix: remove empty routers/services traefik config instead of writing invalid yaml
Attaching Basic Auth or a Redirect to an application with no domains
wrote an empty routers/services block to the app's dynamic config
file. Traefik's file provider rejects that as invalid and aborts its
watcher, blocking config updates for every other application.

Fixes #5189
2026-08-27 09:37:14 -06:00
Mauricio Siu ca3da4dc5d Merge pull request #5201 from Dokploy/fix/compose-git-mounts-5181
fix: resolve relative bind mounts against code dir for git-based compose deploys
2026-08-27 09:15:38 -06:00
Mauricio Siu 87b914996f fix: resolve relative bind mounts against code dir for git-based compose deploys
Compose deployments using a git provider (GitHub/GitLab/Gitea/Bitbucket/
custom Git) clone the repo verbatim, so composePath can point into a
subdirectory (e.g. deploy/docker-compose.yml). Docker Compose resolves
relative bind mounts (../files) against the compose file's own directory,
not against the code/ dir where file mounts are actually written, so the
mount silently binds an empty directory instead of the configured content.

Pin --project-directory to code/ when building the docker compose command
so relative mounts always resolve the same way raw deployments already do.

Fixes #5181
2026-08-27 09:15:06 -06:00
Mauricio Siu 8f3230e2ed Merge pull request #5021 from Souvik-Cyclic/fix/railpack-deploy-sudo-5007
fix: run Railpack install with sudo during remote deploy
2026-08-27 09:10:47 -06:00
Mauricio Siu cebd380856 Merge pull request #5200 from Dokploy/fix/network-chart-labels-5115
fix: correct network monitoring chart labels and units
2026-08-27 01:47:16 -06:00
Mauricio Siu a822db5967 fix: correct network monitoring chart labels and units
Network chart showed values labeled as KB/s, but the underlying data
is MB totals accumulated since boot. Convert to GB and relabel as
Network Total (since Boot).

Fixes #5115
2026-08-27 01:44:49 -06:00
Mauricio Siu 71816d0853 chore: remove obsolete .mcp.json configuration file 2026-08-27 01:39:28 -06:00
Mauricio Siu 85ed75a5c6 Merge pull request #5199 from Dokploy/fix/org-form-unsaved-reset-window-focus-5146
fix: unsaved organization form fields reset on window visibility change
2026-08-27 01:31:42 -06:00
Mauricio Siu f46cd4601c fix: prevent unsaved organization form fields from resetting on window focus 2026-08-27 01:29:20 -06:00
Mauricio Siu 2a585933e9 Merge pull request #5198 from Dokploy/fix/network-sync-docker-id-5179
fix: detect network delete/recreate by Docker ID during sync
2026-08-27 01:17:52 -06:00
autofix-ci[bot] 62e6a4c017 [autofix.ci] apply automated fixes 2026-08-27 07:15:51 +00:00
Mauricio Siu 4710c28db5 docs: add CLAUDE.md code style notes 2026-08-27 01:15:17 -06:00
Mauricio Siu 63efbffcfd fix: detect network delete/recreate by Docker ID during sync
Network sync compared only by name, so a network deleted and
recreated with the same name but different attributes (driver,
attachable, etc.) was reported as already in sync.

Store Docker's network Id (dockerId column) and compare it against
the live Id during sync. A name match with a dockerId mismatch is
now surfaced as a new "changed" state, with an Update action to
refresh the DB row from the live network.

Fixes #5179
2026-08-27 01:15:10 -06:00
Mauricio Siu 523e8f4e4f Merge pull request #5197 from Dokploy/fix/env-file-var-interpolation-5151
fix: preserve ${VAR} interpolation in .env values
2026-08-27 00:54:45 -06:00
Mauricio Siu b67519ebd5 fix: preserve parameter expansion (${VAR:-default}) in .env interpolation 2026-08-27 00:54:02 -06:00
Mauricio Siu 452b7aa2f2 fix: preserve ${VAR} interpolation in .env files
Escaping in prepareEnvironmentVariablesForFile escaped every $,
including inside a deliberate ${VAR} reference, breaking Compose's
documented .env variable interpolation. Only escape $ that isn't
part of a ${IDENTIFIER} sequence.

Fixes #5151
2026-08-27 00:47:01 -06:00
Mauricio Siu 3c6a96a30a Merge pull request #5141 from Dokploy/fix/monitoring-zombie-container
fix(monitoring): remove legacy container and default empty cronJob
2026-08-27 00:03:36 -06:00
Narciso E. Núñez Arias 546686ea35 Merge pull request #5190 from somuai/fix/allow-plus-at-in-read-valid-directory
fix(server): allow '+' and '@' in readValidDirectory path validation
2026-08-26 12:07:29 -04:00
autofix-ci[bot] 217ca07842 [autofix.ci] apply automated fixes 2026-08-26 15:05:57 +00:00
somuai bd8ba128cd fix(server): allow plus, at, and valid path characters in readValidDirectory 2026-08-26 04:54:13 +05:30
Mauricio Siu 72a554de5f chore: add script to spawn agent worktree with environment setup 2026-08-25 01:29:51 -06:00
Mauricio Siu cda047feb2 chore: add install-worktree-deps.sh script for dependency installation 2026-08-25 01:20:10 -06:00
Mauricio Siu feccb51287 Merge pull request #5183 from Dokploy/chore/worktree-automation
chore: automate git worktree setup
2026-08-25 01:03:38 -06:00
Mauricio Siu 1ccd633153 chore: add SKILL.md for issue reproduction and verification process
chore: create .mcp.json for MCP server configuration
2026-08-25 01:02:36 -06:00
Mauricio Siu 8be1e68fe1 chore: automate git worktree setup (shared node_modules, per-worktree dev port) 2026-08-25 00:59:44 -06:00
Narciso 875baa139c mend 2026-08-24 20:59:30 -04:00
Narciso e2ab2eb6bc remove unused code 2026-08-24 20:52:35 -04:00
Dokploy Bot d675db10bb chore: sync hotfix from main into canary [skip ci] 2026-08-24 15:40:57 +00:00
Narciso E. Núñez Arias 903789bfe5 Merge pull request #5160 from bestmaa/codex/fix-5145-traefik-empty-reconnect
fix(traefik): skip empty service reconnect

(cherry picked from commit d1273e2fe9)

[skip ci]
2026-08-24 15:40:52 +00:00
Narciso E. Núñez Arias d1273e2fe9 Merge pull request #5160 from bestmaa/codex/fix-5145-traefik-empty-reconnect
fix(traefik): skip empty service reconnect
2026-08-24 11:40:38 -04:00
Aditya Nandlal ede4626396 fix(traefik): skip empty service reconnect 2026-08-22 08:02:21 +00:00
Dokploy Bot 772b768217 chore: sync hotfix from main into canary [skip ci] 2026-08-21 15:14:50 +00:00
Narciso E. Núñez Arias d3d8d9ec83 Merge pull request #5137 from bestmaa/fix/5111-preserve-log-container-selection
fix: preserve selected log container on refetch
(cherry picked from commit f4dfb6a903)

[skip ci]
2026-08-21 15:14:48 +00:00
Narciso E. Núñez Arias f4dfb6a903 Merge pull request #5137 from bestmaa/fix/5111-preserve-log-container-selection
fix: preserve selected log container on refetch
2026-08-21 11:14:37 -04:00
Narciso bbde5ebbc3 feat(permissions): add server.terminal to decouple server access from root SSH
Reaching a server implied being able to open an SSH root shell on it: the
/terminal websocket only checked that the server was in the caller's accessible
set, so granting a server to a developer necessarily granted root on it.

Add a server.terminal action, assignable on custom roles, and require it on the
remote-server terminal websocket on top of server access. Owner/admin keep it
through the enterprise bypass; the local host terminal stays owner/admin only.

A migration grants server.terminal to existing custom roles that already have
server.read, which is the permission that surfaces the terminal today, so
current setups keep working. Roles without a server entry are left alone.

Note: server.create still implies root execution (server.update persists
server.command and server.setup runs it over SSH), reflected in the Create
description in the role editor.
2026-08-21 11:08:31 -04:00
Aditya Nandlal 62f701425a refactor: share log container selection 2026-08-21 12:32:49 +00:00
Narciso bee6918d9a fix(monitoring): remove legacy container and default empty cronJob
The swarm migration in 3848fa9c0 dropped the container.remove({force:true})
that the standalone deploy path used to run. Swarm tasks are named
dokploy-monitoring.<slot>.<id>, so there is no name collision and the
pre-v0.30.0 container survives every redeploy. It also stays pinned to an
orphaned image ID once pullRemoteImage moves the latest tag, so neither a
pull nor a Save clears it and it restarts forever.

Cloud setup spread metricsConfig straight from the row, shipping
cronJob: "" to the agent. robfig/cron rejects an empty spec, so the Go
binary exits before Fiber binds 4500 and Docker restarts it every ~60s.

- remove the legacy container in deployMonitoringService, which covers both
  setupMonitoring and setupWebMonitoring. Cleanup is best effort: a failure
  is logged and the deploy continues, matching the pre-migration behaviour
- default cronJob when configuring monitoring for cloud
- on build servers, clean up the legacy container but deploy no service.
  They never join the swarm, yet cloud setup did create the standalone
  container there before v0.30.0, and the monitoring form has always been
  hidden for them, so those agents are all stuck with an empty cronJob
- cover the above with real-docker tests
2026-08-20 11:17:41 -04:00
Aditya Nandlal d7c152af0d fix: preserve selected log container on refetch 2026-08-20 07:03:39 +00:00
Dokploy Bot 5ff0c26954 chore: sync hotfix from main into canary [skip ci] 2026-08-19 20:00:41 +00:00
Narciso E. Núñez Arias b4edb56132 Merge pull request #5116 from thaingnguyen/agent/fix-stack-log-container-selection
fix: preserve selected stack log container
(cherry picked from commit 9dab15ad47)

[skip ci]
2026-08-19 20:00:39 +00:00
Narciso E. Núñez Arias 9dab15ad47 Merge pull request #5116 from thaingnguyen/agent/fix-stack-log-container-selection
fix: preserve selected stack log container
2026-08-19 16:00:26 -04:00
Thai Nguyen 0be822ef5b refactor: keep stack log fix focused 2026-08-19 10:33:49 +07:00
Dokploy Bot a8e0477b70 chore: sync hotfix from main into canary [skip ci] 2026-08-18 22:40:23 +00:00
Narciso E. Núñez Arias 5fa963fe87 Merge pull request #5082 from bestmaa/fix/volume-backup-restart
fix(volume-backups): restart services after backup failure

(cherry picked from commit 3054cf53be)

[skip ci]
2026-08-18 22:40:21 +00:00
Narciso E. Núñez Arias 3054cf53be Merge pull request #5082 from bestmaa/fix/volume-backup-restart
fix(volume-backups): restart services after backup failure
2026-08-18 18:40:08 -04:00
Dokploy Bot 8c2d4d5c19 chore: release v0.30.2 2026-08-18 21:32:24 +00:00
Dokploy Bot 64c3a93590 chore: sync hotfix from main into canary [skip ci] 2026-08-18 21:22:28 +00:00
Mauricio Siu fa3cab8bcc Merge pull request #5125 from Dokploy/fix/issue-5096-stack-env-quotes
fix: don't quote .env values for stack deploys
(cherry picked from commit 31ddd7fe1a)

[skip ci]
2026-08-18 21:22:27 +00:00
Mauricio Siu 31ddd7fe1a Merge pull request #5125 from Dokploy/fix/issue-5096-stack-env-quotes
fix: don't quote .env values for stack deploys
2026-08-18 15:22:14 -06:00
Dokploy Bot edc8fc1d7f chore: sync hotfix from main into canary [skip ci] 2026-08-18 21:21:22 +00:00
Narciso E. Núñez Arias 8f42dced38 Merge pull request #5079 from NoiceHax/fix/issue-4074
fix(github-webhook): apply preview limit only to new previews

(cherry picked from commit fc6f4d9af8)

[skip ci]
2026-08-18 21:21:20 +00:00
Narciso E. Núñez Arias fc6f4d9af8 Merge pull request #5079 from NoiceHax/fix/issue-4074
fix(github-webhook): apply preview limit only to new previews
2026-08-18 17:21:08 -04:00
Mauricio Siu d183018201 fix: don't quote .env values for stack deploys
docker stack deploy reads env_file literally without stripping
quotes (unlike docker compose), so the quoting/escaping added to
fix #4694 was shipping literal quote characters into stack
containers.

Fixes #5096, #5110.
2026-08-18 15:20:55 -06:00
Narciso E. Núñez Arias effa3ea502 Merge pull request #4702 from Xeoneid/canary
feat(testing): add upgrade version integration test workflow
2026-08-18 16:37:55 -04:00
Dokploy Bot b10cad4f78 chore: sync hotfix from main into canary [skip ci] 2026-08-18 20:22:29 +00:00
Mauricio Siu cf6f5e39d2 Merge pull request #5118 from andwati/fix/tag-assignment-on-project-create
fix: assign tags created from the project dialog
2026-08-18 14:22:12 -06:00
Dokploy Bot 33367fb86b chore: sync hotfix from main into canary [skip ci] 2026-08-18 20:21:28 +00:00
Mauricio Siu e15b38be38 Merge pull request #5121 from Dokploy/fix/issue-5120-switch-thumb-position
fix: correct switch thumb translate-x for checked state
2026-08-18 14:21:11 -06:00
Mauricio Siu 76c2416613 fix: correct switch thumb translate-x for checked state
Fixes #5120
2026-08-18 14:20:26 -06:00
Thai Nguyen 60612b0986 fix: reset stack log selection when switching modes 2026-08-18 22:10:54 +07:00
andwati 0ea7de7d31 fix: assign tags created from the project dialog
The tag dialog's form is nested inside the project dialog's form in the
React tree, and React propagates events along the React tree rather than
the DOM tree, so submitting the tag form also submitted the project form.
The project was created immediately with no tags and the dialog closed
before more than one tag could be picked.

Stop propagation in the tag form's onSubmit, and select the new tag in
the selector so it is included when the project is saved.

Fixes #5117
2026-08-18 14:18:39 +03:00
Thai Nguyen 49055a05b1 fix: surface native stack inspection failures 2026-08-18 17:52:46 +07:00
Thai Nguyen 2221b8a99c fix: preserve stack log selection on fetch failures 2026-08-18 17:47:59 +07:00
Thai Nguyen c77f6d889d fix: clear stale stack log container selection 2026-08-18 17:34:18 +07:00
Thai Nguyen a5b8626345 fix: preserve selected stack log container 2026-08-18 17:24:15 +07:00
Mauricio Siu fa788b00a6 Merge pull request #5114 from Dokploy/fix/issue-5108-changepassword-container-check
fix: false "container is not running" on database password change
2026-08-18 02:20:26 -06:00
Mauricio Siu 41d355c7dc fix: use dockerode API instead of shell docker ps for changePassword container lookup
Fixes #5108. The changePassword mutations for postgres/mysql/mariadb/mongo/redis
detected the running container via a shell one-liner (docker ps -q --filter
status=running | head -n1) executed through execAsync/execAsyncRemote. When that
shell command failed for any reason the CONTAINER_ID ended up empty and the
mutation incorrectly reported the container as not running even though it was.

Replaced it with getServiceContainer(), the existing dockerode-based lookup
already used by mount.ts and schedules/utils.ts for the same purpose.

Also fixes the postgres ALTER USER command, which never selected a database
and defaulted to a database named after the user, causing a false
'database "<user>" does not exist' failure whenever the db name differs
from the username.
2026-08-18 02:19:41 -06:00
Narciso E. Núñez Arias ded23bb05f Merge pull request #4660 from pparage/fix/ubuntu-26.04-docker-version
fix(setup): install Docker 29.4.2 on Ubuntu 26.04 to fix failed setup
2026-08-17 12:45:37 -04:00
Dokploy Bot 717792f186 chore: sync hotfix from main into canary [skip ci] 2026-08-17 08:06:53 +00:00
Mauricio Siu b53fface69 Merge pull request #5104 from Dokploy/fix/issue-5092-terminal-resize-desync
fix: sync terminal size with backend PTY (fixes #5092)
2026-08-17 02:06:41 -06:00
Mauricio Siu ff275fe94e fix: sync terminal size with backend PTY, avoid connecting with placeholder container ID
Fixes #5092 — typing in the database/docker terminal overwrote text
instead of continuing on new lines. The xterm.js client never told the
backend PTY its actual size (node-pty/ssh2 always defaulted to 80x24
and were never resized), so whenever the rendered terminal width
differed from that, the remote shell's cursor math diverged from what
the client displayed.

- docker-container-terminal.ts / terminal.ts: pass the client's initial
  cols/rows into node-pty spawn and ssh2 exec/shell, and resize the PTY
  on a {type:"resize"} control message sent over the same ws channel.
- docker-terminal.tsx / settings/web-server/terminal.tsx: send cols/rows
  on connect and on every term.onResize, and watch the container with a
  ResizeObserver to refit on dialog/window resize.

Also fixes two related terminal/logs bugs surfaced while testing this:
- docker-terminal.tsx no longer opens a connection with the
  "select-a-container" placeholder ID before a real container is
  selected, and defers terminal creation a frame so React StrictMode's
  dev-only phantom mount doesn't hit a known xterm.js dispose race
  (xtermjs/xterm.js#5011, "Cannot read properties of undefined
  (reading 'dimensions')").
- docker-logs-id.tsx (shared by application/compose/compose-stack logs)
  had the same placeholder-ID issue, surfacing Docker's raw
  "No such container: select-a-container" daemon error instead of a
  proper empty state.
2026-08-17 02:03:47 -06:00
Dokploy Bot ca578ed075 chore: sync hotfix from main into canary [skip ci] 2026-08-17 07:43:04 +00:00
Mauricio Siu 6d8aa9d8da Merge pull request #5103 from shuv-o/fix/project-permission-column-limit
fix: prevent Postgres 100-arg limit crash and restore data parity for restricted-member project access
2026-08-17 01:42:50 -06:00
Mauricio Siu 3b8611771c Merge pull request #4252 from JamBalaya56562/fix/tini-pid1-zombie-reap-v2
fix: reap HEALTHCHECK zombies by running tini as PID 1
2026-08-17 01:39:40 -06:00
Shuvo 2cb499fb64 fix: prevent Postgres 100-argument limit crash and restore data parity for restricted-member project access
The application table has 101 columns. The restricted-member branch of
project.one (apps/dokploy/server/api/routers/project.ts) queried the
applications relation with no columns narrowing, so Drizzle's relational
query builder generated a json_build_array call with one argument per
column, exceeding Postgres's FUNC_MAX_ARGS (100). Any non-owner/admin
member with limited access to a project containing at least one
application hit an opaque INTERNAL_SERVER_ERROR and got redirected away
from the project/environment page instead of seeing their project.

The same branch was also missing the "server" relation that the
owner/admin path (findProjectById) already includes, so restricted
members saw different (incomplete) data than owners/admins for the same
project.

Fixes this by extracting the existing serviceColumns column-selection
constant to a module-level export in packages/server/src/services/project.ts
and applying it (plus the server relation) to all 8 service relations in
the restricted-member query path, matching the owner/admin path.
2026-08-17 13:37:11 +06:00
Aditya Nandlal c6ab76fe09 fix(volume-backups): preserve backup failure status 2026-08-14 12:51:28 +00:00
Aditya Nandlal 2598a62771 fix(volume-backups): restart services after backup failure 2026-08-14 11:40:21 +00:00
JamBalaya56562 dec05aca22 fix: reap HEALTHCHECK zombies by running tini as PID 1
Node.js as PID 1 does not reap children it did not spawn, so the
HEALTHCHECK curl fired every 10s accumulates as <defunct>. Install
tini and set it as ENTRYPOINT so any orphaned child is reaped while
signals still reach Node.
2026-08-13 19:03:04 +00:00
NoiceHax 98dbc59a02 fix(github-webhook): apply preview limit only to new previews
The preview deployment limit was checked before looking up whether the
pull request already had a preview, so once an application reached the
limit every `synchronize` event was dropped and existing previews went
stale while GitHub still saw a 200. The check now runs inside the branch
that creates a preview, uses the schema default of 3 when no limit is
configured (instead of 0, which blocked every app without an explicit
value) and compares with `>=` so the configured limit is inclusive.

Closes #4074
2026-08-13 22:46:40 +05:30
Souvik Kumar 679dbf98cb fix: run Railpack install with sudo during remote deploy
Deploy-time Railpack install ran without sudo, so deploys to a remote
server with a non-root user (passwordless sudo) failed because the
install script could not write to /usr/local/bin ("A terminal is
required to authenticate").

Detect whether sudo is needed (root vs. passwordless-sudo user) and run
the install through it, matching the server-setup script.

Fixes #5007
2026-08-09 20:01:06 +05:30
Claude a504449f1d feat: add target_version input, replace extra_pairs field
- Replace second form field (extra_pairs) with target_version
- target_version sets version B ("to"); empty = highest available tag
- Pair every tag in [floor_version, target_version) with target_version
2026-06-26 13:11:38 -03:00
Claude eaec68023e feat: consecutive upgrade pairs, drop curated pair and extra_pairs input
- Replace "every version → latest" with consecutive pairs (v[i] → v[i+1])
- Remove curated v0.25→v0.26 pair logic
- Remove extra_pairs workflow_dispatch input
2026-06-26 13:11:38 -03:00
Claude 2643ae0a86 fix(ci): deploy postgres/mongo instead of start
The create step was calling postgres.start / mongo.start immediately after
create. In Dokploy, .start only scales an already-deployed Swarm service;
on a freshly created resource the service doesn't exist yet, so the server
ran 'docker service scale ci-pg-db-xxx=1' against a missing service and
returned a 500 (curl -sf → exit 22 → step failure).

.deploy is the mutation that actually builds and creates the Swarm service.
Applications already used .deploy correctly; this aligns the databases.
2026-06-26 13:11:38 -03:00
Claude f59f11474b fix(ci): raise floor to v0.29.4, require environmentId from project.create
v0.20.x used a completely different postgres/mongo/app API schema —
projectId fallback returned boolean false, not the expected resource object.
Since the goal is testing v0.29.4+, drop the old-API compatibility shim
and fail fast with a clear message if environmentId is missing.

Also: default floor_version changed from v0.20.0 to v0.29.4 to reduce
the number of matrix jobs and focus on the stable modern API surface.
2026-06-26 13:11:37 -03:00
Claude f29a1a1976 fix(ci): handle both old (flat) and new (nested) project.create response formats
In Dokploy <= ~v0.24, project.create returns a flat object with just
projectId at the top level; no environment concept exists yet, so
resources (postgres, mongo, apps) are created with projectId.

In Dokploy >= ~v0.25, project.create returns nested {project, environment}
objects and resources require environmentId.

Previous workflow always used .project.projectId and .environment.environmentId,
both of which evaluated to null on old versions, causing curl to exit with
code 22 (HTTP error) on every subsequent resource-create call.

Fix: extract PROJECT_ID with a // fallback, check ENV_ID nullness, and
build a SCOPE fragment (either 'environmentId' or 'projectId') used in
all resource-creation tRPC calls.
2026-06-26 13:11:37 -03:00
Claude ade7a79bf1 fix(ci): run dokploy install.sh as root, install version A directly via DOKPLOY_VERSION
Previous run failed with 'This script must be run as root'. install.sh also
respects DOKPLOY_VERSION and ADVERTISE_ADDR env vars, so we can install
version A directly instead of installing latest and downgrading (which
would risk B's migrations running on A's expected schema).
2026-06-26 13:11:37 -03:00
Blas 55704ff98f feat: add upgrade integration test workflow (manual, matrix-based) 2026-06-26 13:11:37 -03:00
Philippe Parage b9a8064371 fix(setup): use Docker 29.4.2 on Ubuntu 26.04 to fix install failure
The provisioning script pins DOCKER_VERSION=28.5.0 and passes it to
get.docker.com via --version. Ubuntu 26.04 (resolute) ships a docker-ce
repo with no 28.5.0, so the pin is not found in apt-cache madison and
Docker is never installed, aborting setup with 'Failed to initialize
Docker Swarm'.

Repin to a version present on every architecture Docker ships resolute
for. amd64/arm64/armhf start at 29.3.1, but the s390x pool only carries
29.4.0-29.4.2, so 29.3.1 still fails there; 29.4.2 is the newest version
common to all shipped arches (ppc64le is not shipped for resolute). The
override applies to Ubuntu 26.04 only; all other releases keep 28.5.0,
and it runs before the install banner so the reported version stays
accurate and both the full-server and build-server paths are covered.

Fixes #4327, #4349. Closes #4501.
2026-06-17 21:21:16 +02:00
63 changed files with 20382 additions and 168 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"worktree": {
"baseRef": "fresh"
},
"hooks": {
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
}
]
}
]
}
}
+55
View File
@@ -0,0 +1,55 @@
---
name: fix-issue
description: Implement a GitHub issue with reproduction and verification
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
---
The issue number is passed as $1.
## Instance
No instance is running yet — start your own, isolated to this worktree:
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
to relaunch with `DOKPLOY_BASE_URL` exported first.
## Tools
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
state (create a project, an app, an env var) and to verify backend behavior.
Search for the tool you need; they are not all loaded upfront.
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
a user would see or click.
Pick by where the bug lives, not by convenience:
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
The API returning correct data proves nothing here.
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
A green screenshot proves nothing here.
- Unclear → do both.
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
click through ten screens to create a project the API can create in one call.
## Steps
1. Run `gh issue view $1` and read the full issue, including comments.
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
comment on the issue explaining what you tried and STOP.
Do not implement anything.
3. Implement the fix. Keep the change minimal and scoped to the issue.
4. Run `pnpm test`, then re-run the same reproduction from step 2.
5. Only if both pass: commit and run `gh pr create`. The PR description must
include the before/after reproduction steps and reference the issue.
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
@@ -0,0 +1,437 @@
# Upgrade Integration Test
#
# Tests that Dokploy can upgrade from version A to version B while keeping
# user projects (Postgres, MongoDB, two web apps, one static site) alive.
#
# Generates upgrade pairs: each stable tag in [floor_version, target_version)
# is paired with target_version. If target_version is empty, the highest tag
# available on Docker Hub is used as the target.
#
# Only triggered manually to avoid burning Actions minutes.
name: Upgrade Integration Test
on:
workflow_dispatch:
inputs:
floor_version:
description: 'Oldest version tag to include in pairs (e.g. v0.29.4)'
required: false
default: 'v0.29.4'
target_version:
description: 'Target version to upgrade to (version B). Leave empty to use the highest available.'
required: false
default: ''
env:
DOKPLOY_IMAGE: dokploy/dokploy
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
# ──────────────────────────────────────────────────────────────────────────────
jobs:
# ── 1. Build the matrix ─────────────────────────────────────────────────────
build-matrix:
name: Build upgrade pair matrix
runs-on: ubuntu-latest
outputs:
pairs: ${{ steps.pairs.outputs.pairs }}
steps:
- name: Generate pairs
id: pairs
env:
FLOOR: ${{ inputs.floor_version }}
TARGET: ${{ inputs.target_version }}
run: |
set -euo pipefail
# semver "a >= b" comparison helper (vX.Y.Z, strips leading v)
ge() {
[ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ]
}
# Fetch all semver tags from Docker Hub (dokploy/dokploy)
ALL_TAGS=$(curl -fsSL \
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \
jq -r '.results[].name' | \
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
sort -V)
echo "All tags found:"
echo "$ALL_TAGS"
# Target version (version B): explicit input, else highest available.
if [ -n "$TARGET" ]; then
TO="$TARGET"
echo "Using user-supplied target version: $TO"
else
TO=$(echo "$ALL_TAGS" | tail -n1)
echo "No target supplied; using highest available: $TO"
fi
# Floor semver components
FLOOR_CLEAN="${FLOOR#v}"
IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN"
# Each tag in [FLOOR, TO) → TO
PAIRS='[]'
while read -r TAG; do
[ -z "$TAG" ] && continue
# skip tags above-or-equal to the target (incl. the target itself)
ge "$TAG" "$TO" && continue
TAG_CLEAN="${TAG#v}"
IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN"
if [ "$T_MAJ" -gt "$F_MAJ" ] || \
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then
PAIRS=$(echo "$PAIRS" | jq -c \
--arg f "$TAG" --arg t "$TO" \
'. + [{"from":$f,"to":$t}]')
fi
done <<< "$ALL_TAGS"
COUNT=$(echo "$PAIRS" | jq 'length')
echo "Total pairs: $COUNT"
echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"'
echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT"
# ── 2. Run one upgrade test per pair ────────────────────────────────────────
upgrade-test:
name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}"
needs: build-matrix
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }}
steps:
# ── Environment setup ──────────────────────────────────────────────────
- name: Free disk space
run: |
sudo rm -rf \
/usr/share/dotnet /opt/ghc /usr/local/share/boost \
"$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \
/usr/local/share/chromium /opt/hostedtoolcache
docker system prune -af --volumes
df -h
# ── Install Dokploy directly at VERSION A ──────────────────────────────
# install.sh:
# • requires root (run via sudo bash)
# • respects DOKPLOY_VERSION → installs that tag directly
# (so we don't need a separate "downgrade" step that would risk
# running B's migrations on A's expected schema)
# • respects ADVERTISE_ADDR → skip the external IP lookup
# • initializes Docker Swarm + dokploy-network itself
- name: Install Dokploy at VERSION A (${{ matrix.pair.from }})
run: |
curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh
chmod +x /tmp/install.sh
sudo -E env \
DOKPLOY_VERSION="${{ matrix.pair.from }}" \
ADVERTISE_ADDR="127.0.0.1" \
bash /tmp/install.sh
- name: Wait for dokploy service to converge on ${{ matrix.pair.from }}
run: |
echo "Waiting for 'dokploy' Swarm service to reach 1/1..."
timeout 240 bash -c '
until docker service ls --filter name=dokploy \
--format "{{.Name}} {{.Replicas}}" \
| grep "^dokploy " | grep -q " 1/1"; do
sleep 4
done
'
docker service ls
echo "✅ Service running on ${{ matrix.pair.from }}"
- name: Wait for Dokploy API to accept requests
run: |
timeout 180 bash -c '
until curl -sf -o /dev/null \
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
sleep 3
done
'
echo "✅ Dokploy API is up"
# ── Bootstrap admin user ───────────────────────────────────────────────
- name: Register first admin user
id: auth
run: |
COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt"
: > "$COOKIE_JAR"
chmod 600 "$COOKIE_JAR"
echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT"
# better-auth: sign-up/email (allowed only before any owner exists)
set -x
curl -sS -i -X POST \
"http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \
-H "Content-Type: application/json" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \
| tee /tmp/signup.out
set +x
# Verify we got a session cookie
if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then
echo "⚠️ No session cookie matched expected name; jar contents:"
cat "$COOKIE_JAR"
fi
# ── Create test resources ──────────────────────────────────────────────
- name: Create project, databases and applications
id: create
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
run: |
set -euo pipefail
# --- tRPC POST helper ---
trpc_mut() {
curl -sf -X POST "$BASE/api/trpc/$1" \
-H "Content-Type: application/json" \
-b "$COOKIE" -c "$COOKIE" \
-d "{\"json\":$2}"
}
# --- Project ---
PROJECT=$(trpc_mut project.create \
'{"name":"ci-upgrade-test","description":"CI upgrade integration test"}')
echo "project.create → $PROJECT"
# project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}}
PROJECT_ID=$(echo "$PROJECT" | jq -r \
'.result.data.json.project.projectId // .result.data.json.projectId // empty')
ENV_ID=$(echo "$PROJECT" | jq -r \
'.result.data.json.environment.environmentId // empty')
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then
echo "❌ Could not extract projectId from project.create response:"
echo "$PROJECT" | jq .
exit 1
fi
if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then
echo "❌ Could not extract environmentId — this version may not support environments."
echo " project.create response: $PROJECT"
exit 1
fi
echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT"
echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID"
# --- PostgreSQL 15 ---
PG=$(trpc_mut postgres.create \
"{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
\"databasePassword\":\"CiPg1pass\",\
\"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}")
echo "postgres.create → $PG"
PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId')
# deploy (not start) — deploy creates the Swarm service; start only
# scales an already-deployed service and 500s on a fresh resource.
trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null
echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT"
# --- MongoDB 7.0 ---
MG=$(trpc_mut mongo.create \
"{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
\"databasePassword\":\"CiMg1pass\",\
\"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}")
echo "mongo.create → $MG"
MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId')
trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null
echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT"
# --- Docker-image application helper ---
make_app() {
local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3
APP=$(trpc_mut application.create \
"{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}")
APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId')
trpc_mut application.saveDockerProvider \
"{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\
\"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \
> /dev/null
trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \
> /dev/null
echo "$APP_ID"
}
# Static site (nginx)
APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine")
echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT"
# Node.js hello-world (echo server image — small, no args needed)
APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest")
echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT"
# Go HTTP server (traefik/whoami is a tiny Go binary, port 80)
APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest")
echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT"
echo "✅ All resources created"
# ── Pre-upgrade health check ───────────────────────────────────────────
- name: Wait for all services → 'done' (pre-upgrade)
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
PG_ID: ${{ steps.create.outputs.pg_id }}
MG_ID: ${{ steps.create.outputs.mg_id }}
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
run: |
wait_done() {
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
echo "Waiting for $NAME to reach 'done'..."
timeout 360 bash -c "
until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \
--data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \
-b '$COOKIE' | \
jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \
= 'done' ]; do
sleep 5
done
"
echo "✅ $NAME is done"
}
wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus
wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus
wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus
wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus
wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus
- name: Assert Docker Swarm services healthy (pre-upgrade)
run: |
echo "=== docker service ls ==="
docker service ls
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
grep -E '^ci-' | grep -v ' 1/1' || true)
if [ -n "$FAIL" ]; then
echo "❌ User services not healthy before upgrade:"
echo "$FAIL"
exit 1
fi
echo "✅ All user services healthy before upgrade"
# ── Upgrade ────────────────────────────────────────────────────────────
- name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }})
run: |
docker service update \
--image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \
--force \
"${{ env.DOKPLOY_SERVICE }}"
echo "Waiting for service to converge on ${{ matrix.pair.to }}..."
timeout 240 bash -c '
until ! docker service inspect dokploy \
--format "{{.UpdateStatus.State}}" 2>/dev/null \
| grep -q "^updating$"; do
sleep 4
done
until docker service ls --filter name=dokploy \
--format "{{.Name}} {{.Replicas}}" \
| grep "^dokploy " | grep -q " 1/1"; do
sleep 4
done
'
echo "✅ Service running on ${{ matrix.pair.to }}"
- name: Wait for Dokploy API to respond post-upgrade
run: |
timeout 180 bash -c '
until curl -sf -o /dev/null \
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
sleep 3
done
'
echo "✅ Dokploy API is up after upgrade"
# ── Post-upgrade health check ──────────────────────────────────────────
- name: Verify all services still healthy (post-upgrade)
env:
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
PG_ID: ${{ steps.create.outputs.pg_id }}
MG_ID: ${{ steps.create.outputs.mg_id }}
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
run: |
check_status() {
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \
--data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \
-b "$COOKIE" | \
jq -r ".result.data.json.$STATUS_KEY // \"unknown\"")
if [ "$STATUS" != "done" ]; then
echo "❌ $NAME status after upgrade: $STATUS"
return 1
fi
echo "✅ $NAME: $STATUS"
}
check_status postgres postgres.one postgresId "$PG_ID" applicationStatus
check_status mongo mongo.one mongoId "$MG_ID" applicationStatus
check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus
check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus
check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus
echo "=== docker service ls (post-upgrade) ==="
docker service ls
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
grep -E '^ci-' | grep -v ' 1/1' || true)
if [ -n "$FAIL" ]; then
echo "❌ User services not healthy after upgrade:"
echo "$FAIL"
exit 1
fi
echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}"
# ── Diagnostics on failure ─────────────────────────────────────────────
- name: Dump state on failure
if: failure()
run: |
echo "=== docker service ls ===" && docker service ls || true
echo "=== dokploy service tasks ===" && \
docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true
echo "=== dokploy logs (last 200) ===" && \
docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true
echo "=== install.sh tail ===" && \
tail -100 /tmp/install.sh 2>&1 || true
echo "=== signup response ===" && \
cat /tmp/signup.out 2>&1 || true
echo "=== disk usage ===" && df -h
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
run: |
STATUS="${{ job.status }}"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
| Service | Image |
|---------|-------|
| ci-pg-db | postgres:15 |
| ci-mongo-db | mongo:7.0 |
| ci-static-app | nginx:alpine (static site) |
| ci-node-app | ealen/echo-server (Node.js hello-world) |
| ci-go-app | traefik/whoami (Go HTTP server) |
EOF
+2
View File
@@ -0,0 +1,2 @@
.env
.env.local
+6
View File
@@ -0,0 +1,6 @@
## Code style
- Don't write comments that restate what the code already says.
- Comment only the "why" when something isn't obvious: workarounds,
counterintuitive decisions, constraints from an external API.
- No section-divider comments like `// --- Helpers ---`.
- Don't leave comments describing the change you just made.
+4 -1
View File
@@ -31,7 +31,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -69,5 +69,8 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
ENTRYPOINT ["/usr/bin/tini", "--"]
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
@@ -0,0 +1,82 @@
import { spawnSync } from "node:child_process";
import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup";
import { describe, expect, it } from "vitest";
const runCommand = (command: string) =>
spawnSync("bash", ["-c", command], {
encoding: "utf8",
});
const outputLines = (stdout: string) =>
stdout
.trim()
.split("\n")
.filter((line) => line.length > 0);
describe("createRestartSafeBackupCommand", () => {
it("restarts the service and preserves the backup error status", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"; exit 23',
startCommand: 'echo "start"',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(23);
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
});
it("preserves the backup error status when the restart also fails", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"; exit 23',
startCommand: 'echo "start"; exit 17',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(23);
expect(outputLines(result.stdout)).toEqual([
"stop",
"backup",
"start",
"Service restart also failed with exit code 17",
]);
});
it("returns the restart error status when the backup succeeds", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"',
startCommand: 'echo "start"; exit 17',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(17);
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
});
it("uploads only after a successful backup and service restart", () => {
const result = runCommand(
createRestartSafeBackupCommand({
stopCommand: 'echo "stop"',
backupCommand: 'echo "backup"',
startCommand: 'echo "start"',
uploadCommand: 'echo "upload"',
}),
);
expect(result.status).toBe(0);
expect(outputLines(result.stdout)).toEqual([
"stop",
"backup",
"start",
"upload",
]);
});
});
@@ -0,0 +1,58 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
command: "",
};
describe("compose createCommand --project-directory", () => {
it("pins --project-directory to the code dir when composePath is nested", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
});
it("omits --project-directory when no projectPath is passed", () => {
const cmd = createCommand({
...base,
composePath: "./deploy/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
});
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--project-directory");
expect(cmd.startsWith("stack deploy")).toBe(true);
});
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
const cmd = createCommand(
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f docker-compose.yml");
});
});
@@ -32,6 +32,9 @@ const cases: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
APP_URL: "https://example.com",
ASSET_URL: "https://example.com",
DB_HOST: "localhost",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
@@ -46,6 +49,9 @@ const inputEncoding: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
APP_URL: "https://example.com",
ASSET_URL: '"${APP_URL}"',
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
describe("getCreateEnvFileCommand", () => {
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
createPreviewDeployment: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
@@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: vi.fn(),
createPreviewDeployment: mocks.createPreviewDeployment,
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
findPreviewDeploymentByApplicationId:
mocks.findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
@@ -321,3 +324,157 @@ describe("GitHub app webhook auto-deploy", () => {
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
});
});
describe("GitHub app webhook preview deployments", () => {
const createApplication = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
applicationId: "application-id",
name: "my-app",
serverId: null,
previewLabels: [],
previewLimit: 3,
previewDeployments: [],
previewRequireCollaboratorPermissions: false,
...overrides,
});
const createPreviewDeployments = (total: number) =>
Array.from({ length: total }, (_, index) => ({
previewDeploymentId: `existing-preview-${index}`,
}));
const createPullRequestRequest = (action: string) =>
({
headers: {
"x-hub-signature-256": "sha256=test-signature",
"x-github-event": "pull_request",
},
body: {
installation: {
id: 12345,
},
action,
pull_request: {
id: 987,
number: 42,
title: "feat: add preview",
html_url: "https://github.com/agentHits/dokploy/pull/42",
labels: [],
user: {
login: "agentHits",
},
head: {
ref: "feature",
sha: "abc123",
},
base: {
ref: "main",
},
},
repository: {
name: "dokploy",
owner: {
login: "agentHits",
},
},
},
}) as unknown as NextApiRequest;
beforeEach(() => {
vi.clearAllMocks();
mocks.githubFindFirst.mockResolvedValue({
githubId: "github-provider-id",
githubInstallationId: 12345,
githubWebhookSecret: "webhook-secret",
});
mocks.verify.mockResolvedValue(true);
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
mocks.createPreviewDeployment.mockResolvedValue({
previewDeploymentId: "new-preview-id",
});
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
});
it("redeploys an existing preview even when the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(3),
}),
]);
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
previewDeploymentId: "existing-preview-0",
});
const res = createResponse();
await handler(createPullRequestRequest("synchronize"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "existing-preview-0",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it("does not create a new preview once the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it("falls back to the default limit when none is configured", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: null,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith(
expect.objectContaining({
applicationId: "application-id",
branch: "feature",
pullRequestId: 987,
pullRequestNumber: 42,
}),
);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "new-preview-id",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
});
@@ -50,6 +50,15 @@ describe("getRailpackCommand", () => {
expect(command).toContain("--build-arg cache-key=");
});
it("installs Railpack through sudo for non-root users", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain(
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
);
expect(command).toContain("sudo -n true 2>/dev/null");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
const containers = [
{ containerId: "first-container" },
{ containerId: "selected-container" },
];
describe("resolveContainerSelection", () => {
it("selects the first container when no container is selected", () => {
expect(resolveContainerSelection(undefined, containers)).toBe(
"first-container",
);
});
it("preserves a manual selection when refreshed data contains it", () => {
const refreshedContainers = containers.map((container) => ({
...container,
}));
expect(
resolveContainerSelection("selected-container", refreshedContainers),
).toBe("selected-container");
});
it("falls back to the first container when the selection disappears", () => {
expect(resolveContainerSelection("removed-container", containers)).toBe(
"first-container",
);
});
it("keeps the current selection while container data is loading", () => {
expect(resolveContainerSelection("selected-container", undefined)).toBe(
"selected-container",
);
});
it("clears the selection when no containers are available", () => {
expect(resolveContainerSelection("selected-container", [])).toBeUndefined();
});
});
@@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
});
it("member is denied server.terminal", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
});
it("member is denied registry.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
@@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => {
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.server.terminal).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (role: string) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
accessedServers: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn = mockMemberData("deployer");
let rolesToReturn: { permission: string }[] = [];
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(true)),
}));
const { checkPermission, resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const withPermissions = (permissions: Record<string, string[]>) => {
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
};
beforeEach(() => {
vi.clearAllMocks();
memberToReturn = mockMemberData("deployer");
rolesToReturn = [];
});
describe("server.terminal on custom roles", () => {
it("a role with server.read alone cannot open a terminal", async () => {
withPermissions({ server: ["read"] });
await expect(
checkPermission(ctx, { server: ["read"] }),
).resolves.toBeUndefined();
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(true);
expect(perms.server.terminal).toBe(false);
});
it("a role with server.terminal can open a terminal", async () => {
withPermissions({ server: ["read", "terminal"] });
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
});
it("owner and admin keep terminal access", async () => {
for (const role of ["owner", "admin"]) {
memberToReturn = mockMemberData(role);
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
}
});
});
@@ -0,0 +1,236 @@
import { execSync } from "node:child_process";
import { docker } from "@dokploy/server/constants";
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 120000;
const SERVICE_NAME = "dokploy-monitoring";
const TEST_IMAGE = "busybox:latest";
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
// the local daemon, so the legacy-container cleanup is exercised for real.
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn().mockResolvedValue({
serverId: "test-server",
serverType: "deploy",
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
metricsConfig: {
server: {
type: "Remote",
port: 4500,
token: "test-token",
urlCallback: "http://localhost/callback",
cronJob: "0 0 * * *",
retentionDays: 2,
refreshRate: 60,
thresholds: { cpu: 0, memory: 0 },
},
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
},
}),
}));
vi.mock("@dokploy/server/services/settings", () => ({
getDokployImageTag: vi.fn(() => "latest"),
}));
vi.mock("@dokploy/server/utils/docker/utils", () => ({
pullImage: vi.fn().mockResolvedValue(undefined),
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
}));
const containerExists = async (name: string) => {
try {
await docker.getContainer(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const serviceExists = async (name: string) => {
try {
await docker.getService(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const swarmTaskNames = async () => {
const list = await docker.listContainers({ all: true });
return list
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
};
const cleanup = async () => {
try {
await docker.getService(SERVICE_NAME).remove();
} catch {}
try {
await docker.getContainer(SERVICE_NAME).remove({ force: true });
} catch {}
for (const name of await swarmTaskNames()) {
try {
await docker.getContainer(name).remove({ force: true });
} catch {}
}
};
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
const createLegacyZombie = async () => {
const container = await docker.createContainer({
name: SERVICE_NAME,
Image: TEST_IMAGE,
Cmd: [
"sh",
"-c",
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
],
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
});
await container.start();
};
const pullTestImage = async () => {
try {
await docker.getImage(TEST_IMAGE).inspect();
return;
} catch {}
await new Promise<void>((resolve, reject) =>
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
err
? reject(err)
: docker.modem.followProgress(stream, (e: any) =>
e ? reject(e) : resolve(),
),
),
);
};
// The code under test hardcodes the production name, so this suite cannot
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
const hasRealMonitoring = () => {
const query = (cmd: string) => {
try {
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return "";
}
};
return (
query(
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
) !== "" ||
query(
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
) !== ""
);
};
describe.skipIf(hasRealMonitoring())(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
await pullTestImage();
await cleanup();
}, REAL_TEST_TIMEOUT);
afterAll(async () => {
await cleanup();
}, REAL_TEST_TIMEOUT);
it(
"removes the legacy standalone container left behind by the swarm migration",
async () => {
await createLegacyZombie();
expect(await containerExists(SERVICE_NAME)).toBe(true);
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
async () => {
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
const task = await docker.createContainer({
name: taskName,
Image: TEST_IMAGE,
Cmd: ["sleep", "3600"],
});
await task.start();
await createLegacyZombie();
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await containerExists(taskName)).toBe(true);
const inspect = await docker.getContainer(taskName).inspect();
expect(inspect.State.Running).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"is idempotent when no legacy container exists",
async () => {
expect(await containerExists(SERVICE_NAME)).toBe(false);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"deploys the service even when removing the legacy container fails",
async () => {
const failingDocker = {
getContainer: () => ({
remove: async () => {
const error: any = new Error("device or resource busy");
error.statusCode = 500;
throw error;
},
}),
getService: docker.getService.bind(docker),
createService: docker.createService.bind(docker),
};
const remoteDocker = await import(
"@dokploy/server/utils/servers/remote-docker"
);
const spy = vi
.spyOn(remoteDocker, "getRemoteDocker")
.mockResolvedValue(failingDocker as any);
try {
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
expect(await serviceExists(SERVICE_NAME)).toBe(true);
} finally {
spy.mockRestore();
}
},
REAL_TEST_TIMEOUT,
);
},
);
@@ -0,0 +1,71 @@
import { reconnectServicesToTraefik } from "@dokploy/server/services/settings";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
compose: {
findMany: mocks.findMany,
},
},
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
describe("reconnectServicesToTraefik", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findMany.mockResolvedValue([]);
});
it("does not execute an empty local command when no isolated deployments exist", async () => {
await reconnectServicesToTraefik();
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it("does not execute an empty remote command when no isolated deployments exist", async () => {
await reconnectServicesToTraefik("server-id");
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("reconnects isolated deployments to the local Traefik network", async () => {
mocks.findMany.mockResolvedValue([
{ appName: "first-compose" },
{ appName: "second-compose" },
]);
await reconnectServicesToTraefik();
expect(mocks.execAsync).toHaveBeenCalledOnce();
expect(mocks.execAsync).toHaveBeenCalledWith(
'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' +
'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
);
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("reconnects isolated deployments on a remote server", async () => {
mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]);
await reconnectServicesToTraefik("server-id");
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
"server-id",
'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
);
expect(mocks.execAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,106 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: mocks.execAsyncRemote,
};
});
describe("writeAppTraefikConfig", () => {
let cwd: string;
let dynamicPath: string;
beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
fs.mkdirSync(dynamicPath, { recursive: true });
vi.spyOn(process, "cwd").mockReturnValue(cwd);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cwd, { recursive: true, force: true });
});
// Regression test for #5189: Traefik's file provider rejects a standalone
// `routers: {}` / `services: {}` map and aborts its watcher for every
// dynamic config once it hits one, so an app with no domains must never
// get an on-disk config file at all.
it("removes the file instead of writing empty routers/services", async () => {
const appName = "no-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
fs.writeFileSync(configPath, "stale content", "utf8");
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
appName,
);
expect(fs.existsSync(configPath)).toBe(false);
});
it("writes the file when routers/services are present", async () => {
const appName = "with-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
await writeAppTraefikConfig(
{
http: {
routers: { [`${appName}-router-1`]: { rule: "Host(`x`)" } },
services: {},
},
},
appName,
);
expect(fs.existsSync(configPath)).toBe(true);
});
it("removes the remote file instead of writing empty routers/services", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
"no-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0];
expect(command).toMatch(/^rm -f /);
expect(command).toContain("no-domain-app.yml");
});
it("writes the remote file when routers/services are present", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
await writeAppTraefikConfig(
{
http: {
routers: { "with-domain-app-router-1": { rule: "Host(`x`)" } },
services: {},
},
},
"with-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0];
expect(command).toMatch(/^echo /);
});
});
+26 -1
View File
@@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => {
});
it("gates a remote server terminal on server access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// role lookup must not be needed for the remote path
// the remote path must never fall through to the owner/admin local branch
expect(mockFindMember).not.toHaveBeenCalled();
});
it("denies a remote server terminal without the server.terminal permission", async () => {
// Reaching a server (to deploy on it) must not imply a root shell on it.
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(false);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
expect(mockHasPermission).toHaveBeenCalledWith(
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
{ server: ["terminal"] },
);
});
it("allows a remote server terminal with the server.terminal permission", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("does not check permissions for a server the caller cannot access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
expect(mockHasPermission).not.toHaveBeenCalled();
});
});
@@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => {
),
).toBe(true);
});
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
),
).toBe(true);
});
});
@@ -1,6 +1,7 @@
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
},
);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -54,6 +55,7 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "swarm",
refetchInterval: 5000,
},
);
@@ -66,22 +68,18 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "native",
refetchInterval: 5000,
},
);
const containers = data?.filter((container) => container.containerId);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@@ -106,6 +104,7 @@ export const ShowDockerLogsStack = ({
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({
},
{
enabled: !!appName,
refetchInterval: 5000,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, data),
);
}, [data]);
return (
@@ -7,6 +7,28 @@ export interface LogLine {
message: string;
}
interface ContainerOption {
containerId: string;
}
export const resolveContainerSelection = (
currentContainerId: string | undefined,
containers: readonly ContainerOption[] | undefined,
) => {
if (!containers) {
return currentContainerId;
}
if (
currentContainerId &&
containers.some(({ containerId }) => containerId === currentContainerId)
) {
return currentContainerId;
}
return containers[0]?.containerId;
};
interface LogStyle {
type: LogType;
variant: LogVariant;
@@ -19,6 +19,11 @@ interface NetworkChartProps {
data: any[];
}
function formatNetworkGB(valueInMB: number) {
if (Number.isNaN(valueInMB)) return "0";
return (valueInMB / 1024).toFixed(1);
}
const chartConfig = {
networkIn: {
label: "Network In",
@@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
<CardHeader className="border-b py-5">
<CardTitle>Network</CardTitle>
<CardDescription>
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
{latestData.networkIn} KB/s
Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "}
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
@@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${value} KB/s`} />
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
@@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
Network
</span>
<span className="font-bold">
↑ {data.networkOut} KB/s
<br />↓ {data.networkIn} KB/s
↑ {formatNetworkGB(data.networkOut)} GB
<br />↓ {formatNetworkGB(data.networkIn)} GB
</span>
</div>
</div>
@@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const resyncMutation = api.network.resync.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
@@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => {
}
};
const onResync = async (networkId: string, name: string) => {
try {
await resyncMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" updated from Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error updating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
@@ -177,6 +192,45 @@ export const SyncNetworks = ({ serverId }: Props) => {
)}
</div>
{!!data?.changed.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Changed ({data.changed.length})
</span>
<span className="text-xs text-muted-foreground">
These networks were deleted and recreated in Docker under
the same name — attributes in Dokploy are outdated.
</span>
{data.changed.map((changed) => (
<div
key={changed.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<div className="flex items-center gap-3">
<span className="text-sm">{changed.name}</span>
{changed.driver && (
<Badge variant="outline">{changed.driver}</Badge>
)}
</div>
<Button
variant="outline"
size="xs"
isLoading={resyncMutation.isPending}
onClick={() =>
onResync(changed.networkId, changed.name)
}
>
<RotateCcw className="size-3.5" />
Update
</Button>
</div>
))}
</div>
</>
)}
{!!data?.missing.length && (
<>
<Separator />
@@ -48,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) {
},
{
enabled: !!organizationId,
refetchOnWindowFocus: false,
},
);
const { mutateAsync, isPending } = organizationId
@@ -250,29 +250,30 @@ export const ShowServers = () => {
</div>
<TooltipProvider>
{server.sshKeyId && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
{server.sshKeyId &&
permissions?.server.terminal && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
@@ -328,6 +328,10 @@ const ACTION_META: Record<
label: "Delete",
description: "Remove servers from the organization",
},
terminal: {
label: "Terminal",
description: "Open an SSH root shell on remote servers",
},
},
registry: {
read: { label: "Read", description: "View configured Docker registries" },
@@ -569,7 +573,7 @@ const ROLE_PRESETS: {
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
server: ["read", "create", "delete", "terminal"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
backup: ["read", "create", "delete", "restore"],
@@ -0,0 +1 @@
ALTER TABLE "network" ADD COLUMN "dockerId" text;
@@ -0,0 +1,13 @@
-- Grant the new "server.terminal" permission to existing custom roles that already have
-- "server.read", which is the permission that surfaces the terminal in the UI today.
-- Roles without a "server" entry are deliberately left alone: they never saw the terminal in the
-- UI, so from now on they are denied at the websocket too.
UPDATE "organization_role" AS r
SET "permission" = jsonb_set(
r."permission"::jsonb,
'{server}',
(r."permission"::jsonb->'server') || '["terminal"]'::jsonb
)::text
WHERE jsonb_typeof(r."permission"::jsonb->'server') = 'array'
AND r."permission"::jsonb->'server' @> '["read"]'::jsonb
AND NOT r."permission"::jsonb->'server' @> '["terminal"]'::jsonb;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1303,6 +1303,20 @@
"when": 1786526512677,
"tag": "0185_needy_kingpin",
"breakpoints": true
},
{
"idx": 186,
"version": "7",
"when": 1787813846067,
"tag": "0186_tearful_dragon_man",
"breakpoints": true
},
{
"idx": 187,
"version": "7",
"when": 1787937580323,
"tag": "0187_grant_terminal_permission_to_read_roles",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.1",
"version": "v0.30.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
+9 -4
View File
@@ -484,10 +484,6 @@ export default async function handler(
if (!hasLabel) continue;
}
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
continue;
}
const previewDeploymentResult =
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
@@ -495,6 +491,15 @@ export default async function handler(
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult && shouldCreateDeployment) {
// The limit only applies to new previews, existing ones must
// still be redeployed when the pull request is updated.
const previewLimit = app?.previewLimit ?? 3;
if ((app?.previewDeployments?.length ?? 0) >= previewLimit) {
console.warn(
`⚠️ Preview deployment limit (${previewLimit}) reached for ${app.name}, skipping preview for pull request #${prNumber}`,
);
continue;
}
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
+5 -1
View File
@@ -1,3 +1,4 @@
import { join } from "node:path";
import {
addDomainToCompose,
clearOldDeployments,
@@ -32,6 +33,7 @@ import {
updateCompose,
updateDeploymentStatus,
} from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
@@ -547,7 +549,9 @@ export const composeRouter = createTRPCRouter({
service: ["create"],
});
const compose = await findComposeById(input.composeId);
const command = createCommand(compose);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(compose, projectPath);
return `docker ${command}`;
}),
refreshToken: protectedProcedure
+10 -10
View File
@@ -11,7 +11,7 @@ import {
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getServiceContainer,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
@@ -410,17 +410,17 @@ export const mariadbRouter = createTRPCRouter({
const maria = await findMariadbById(mariadbId);
const { appName, serverId, databaseUser, databaseRootPassword } = maria;
const containerCmd = getServiceContainerCommand(appName);
const container = await getServiceContainer(appName, serverId);
if (!container) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `No running container found for ${appName}`,
});
}
const targetUser = type === "root" ? "root" : databaseUser;
const command = `
CONTAINER_ID=$(${containerCmd})
if [ -z "$CONTAINER_ID" ]; then
echo "No running container found for ${appName}" >&2
exit 1
fi
docker exec "$CONTAINER_ID" mariadb -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;"
`;
const command = `docker exec ${container.Id} mariadb -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;"`;
await db.transaction(async (tx) => {
const setData =
+10 -10
View File
@@ -11,7 +11,7 @@ import {
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getServiceContainer,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
@@ -431,15 +431,15 @@ export const mongoRouter = createTRPCRouter({
const mongo = await findMongoById(mongoId);
const { appName, serverId, databaseUser, databasePassword } = mongo;
const containerCmd = getServiceContainerCommand(appName);
const command = `
CONTAINER_ID=$(${containerCmd})
if [ -z "$CONTAINER_ID" ]; then
echo "No running container found for ${appName}" >&2
exit 1
fi
docker exec "$CONTAINER_ID" mongosh -u '${databaseUser}' -p '${databasePassword}' --authenticationDatabase admin --eval "db.getSiblingDB('admin').changeUserPassword('${databaseUser}', '${password}')"
`;
const container = await getServiceContainer(appName, serverId);
if (!container) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `No running container found for ${appName}`,
});
}
const command = `docker exec ${container.Id} mongosh -u '${databaseUser}' -p '${databasePassword}' --authenticationDatabase admin --eval "db.getSiblingDB('admin').changeUserPassword('${databaseUser}', '${password}')"`;
await db.transaction(async (tx) => {
await tx
+10 -10
View File
@@ -11,7 +11,7 @@ import {
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getServiceContainer,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
@@ -428,17 +428,17 @@ export const mysqlRouter = createTRPCRouter({
const my = await findMySqlById(mysqlId);
const { appName, serverId, databaseUser, databaseRootPassword } = my;
const containerCmd = getServiceContainerCommand(appName);
const container = await getServiceContainer(appName, serverId);
if (!container) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `No running container found for ${appName}`,
});
}
const targetUser = type === "root" ? "root" : databaseUser;
const command = `
CONTAINER_ID=$(${containerCmd})
if [ -z "$CONTAINER_ID" ]; then
echo "No running container found for ${appName}" >&2
exit 1
fi
docker exec "$CONTAINER_ID" mysql -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;"
`;
const command = `docker exec ${container.Id} mysql -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;"`;
await db.transaction(async (tx) => {
const setData =
@@ -6,6 +6,7 @@ import {
inspectNetwork,
recreateNetwork,
removeNetwork,
resyncNetwork,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, isNull } from "drizzle-orm";
@@ -129,6 +130,29 @@ export const networkRouter = createTRPCRouter({
return recreated;
}),
resync: protectedProcedure
.input(apiFindOneNetwork)
.mutation(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
const resynced = await resyncNetwork(
input.networkId,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "update",
resourceType: "network",
resourceId: resynced.networkId,
resourceName: resynced.name,
});
return resynced;
}),
remove: protectedProcedure
.input(apiRemoveNetwork)
.mutation(async ({ ctx, input }) => {
+10 -10
View File
@@ -12,7 +12,7 @@ import {
getAccessibleServerIds,
getContainerLogs,
getMountPath,
getServiceContainerCommand,
getServiceContainer,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
@@ -437,15 +437,15 @@ export const postgresRouter = createTRPCRouter({
const pg = await findPostgresById(postgresId);
const { appName, serverId, databaseUser } = pg;
const containerCmd = getServiceContainerCommand(appName);
const command = `
CONTAINER_ID=$(${containerCmd})
if [ -z "$CONTAINER_ID" ]; then
echo "No running container found for ${appName}" >&2
exit 1
fi
docker exec "$CONTAINER_ID" psql -U ${databaseUser} -c "ALTER USER \\"${databaseUser}\\" WITH PASSWORD '${password}';"
`;
const container = await getServiceContainer(appName, serverId);
if (!container) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `No running container found for ${appName}`,
});
}
const command = `docker exec ${container.Id} psql -U ${databaseUser} -d postgres -c "ALTER USER \\"${databaseUser}\\" WITH PASSWORD '${password}';"`;
await db.transaction(async (tx) => {
await tx
+10 -10
View File
@@ -10,7 +10,7 @@ import {
findRedisById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getServiceContainer,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
@@ -418,15 +418,15 @@ export const redisRouter = createTRPCRouter({
const rd = await findRedisById(redisId);
const { appName, serverId, databasePassword } = rd;
const containerCmd = getServiceContainerCommand(appName);
const command = `
CONTAINER_ID=$(${containerCmd})
if [ -z "$CONTAINER_ID" ]; then
echo "No running container found for ${appName}" >&2
exit 1
fi
docker exec "$CONTAINER_ID" redis-cli -a '${databasePassword}' CONFIG SET requirepass '${password}'
`;
const container = await getServiceContainer(appName, serverId);
if (!container) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `No running container found for ${appName}`,
});
}
const command = `docker exec ${container.Id} redis-cli -a '${databasePassword}' CONFIG SET requirepass '${password}'`;
await db.transaction(async (tx) => {
await tx
+7 -2
View File
@@ -62,7 +62,8 @@ export const canAccessDockerOverWss = async (
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
// host terminal is a root shell on the control-plane host, so it is restricted
// to owner/admin. A remote server terminal is gated on server access.
// to owner/admin. A remote server terminal needs server access plus
// server.terminal.
export const canAccessTerminalOverWss = async (
user: WssUser,
session: WssSession,
@@ -75,7 +76,11 @@ export const canAccessTerminalOverWss = async (
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
return accessible.has(serverId);
if (!accessible.has(serverId)) return false;
return await hasPermission(buildCtx(user, session.activeOrganizationId), {
server: ["terminal"],
});
}
try {
+1
View File
@@ -21,6 +21,7 @@ export const network = pgTable("network", {
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
dockerId: text("dockerId"),
driver: networkDriver("driver").notNull().default("bridge"),
internal: boolean("internal").notNull().default(false),
attachable: boolean("attachable").notNull().default(false),
+3 -3
View File
@@ -35,7 +35,7 @@ export const statements = {
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
server: ["read", "create", "delete", "terminal"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
@@ -104,7 +104,7 @@ export const ownerRole = ac.newRole({
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
server: ["read", "create", "delete", "terminal"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
@@ -143,7 +143,7 @@ export const adminRole = ac.newRole({
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
server: ["read", "create", "delete", "terminal"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
+13 -3
View File
@@ -262,18 +262,28 @@ export const updateFileMount = async (mountId: string) => {
if (!mount || !mount.filePath) return;
const basePath = await getBaseFilesPath(mountId);
const fullPath = path.join(basePath, mount.filePath);
const directory = path.dirname(fullPath);
try {
const serverId = await getServerId(mount);
const encodedContent = encodeBase64(mount.content || "");
const command = `echo "${encodedContent}" | base64 -d > ${quote([fullPath])}`;
const command = `
mkdir -p ${quote([directory])};
if [ -d ${quote([fullPath])} ]; then rm -rf ${quote([fullPath])}; fi;
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
} catch {
console.log("Error updating file mount");
} catch (error) {
console.log(`Error updating the file mount: ${error}`);
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error updating the mount ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
};
+80 -8
View File
@@ -1,6 +1,7 @@
import { db } from "@dokploy/server/db";
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import type Dockerode from "dockerode";
import { and, eq, inArray, isNull } from "drizzle-orm";
import type { z } from "zod";
import { IS_CLOUD } from "../constants";
@@ -17,6 +18,7 @@ const RESERVED_NETWORKS = [
];
type DockerNetworkInfo = {
Id?: string;
Name: string;
Driver: string;
Internal?: boolean;
@@ -35,6 +37,12 @@ type DockerNetworkInfo = {
};
};
// EnableIPv4 is missing from dockerode's NetworkCreateOptions but supported
// by the daemon (API >= 1.47); the body is sent as-is
type NetworkCreateOptions = Dockerode.NetworkCreateOptions & {
EnableIPv4?: boolean;
};
const parseMtu = (value: string | undefined) => {
const mtu = Number.parseInt(value ?? "", 10);
return Number.isNaN(mtu) ? null : mtu;
@@ -51,6 +59,7 @@ const mapDockerNetworkToRow = (
serverId: string | null,
) => ({
name: dockerNetwork.Name,
dockerId: dockerNetwork.Id ?? null,
driver: dockerNetwork.Driver as "bridge" | "overlay",
internal: dockerNetwork.Internal ?? false,
attachable: dockerNetwork.Attachable ?? false,
@@ -110,7 +119,7 @@ export const findNetworksToSync = async (
const existing = await findNetworksByServer(organizationId, serverId);
const existingNames = new Set(existing.map((row) => row.name));
const dockerNames = new Set(dockerNetworks.map((d) => d.Name));
const dockerByName = new Map(dockerNetworks.map((d) => [d.Name, d] as const));
const importable = dockerNetworks
.filter(
@@ -128,12 +137,28 @@ export const findNetworksToSync = async (
.filter((s): s is string => !!s),
}));
// Rows in Dokploy whose network no longer exists in Docker
const missing = existing
.filter((row) => !dockerNames.has(row.name))
.filter((row) => !dockerByName.has(row.name))
.map((row) => ({ networkId: row.networkId, name: row.name }));
return { importable, missing };
const changed = existing
.filter((row) => {
if (!row.dockerId) return false;
const dockerNetwork = dockerByName.get(row.name);
return !!dockerNetwork?.Id && dockerNetwork.Id !== row.dockerId;
})
.map((row) => {
const dockerNetwork = dockerByName.get(row.name);
return {
networkId: row.networkId,
name: row.name,
driver: dockerNetwork?.Driver,
internal: dockerNetwork?.Internal ?? false,
attachable: dockerNetwork?.Attachable ?? false,
};
});
return { importable, missing, changed };
};
export const importDockerNetworks = async (
@@ -184,6 +209,49 @@ export const importDockerNetworks = async (
return { imported, errors };
};
export const resyncNetwork = async (
networkId: string,
organizationId: string,
) => {
const row = await findNetworkById(networkId);
if (row.organizationId !== organizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
const docker = await getRemoteDocker(row.serverId ?? null);
let info: DockerNetworkInfo;
try {
info = (await docker.getNetwork(row.name).inspect()) as DockerNetworkInfo;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Failed to inspect Docker network",
cause: error,
});
}
const [updated] = await db
.update(network)
.set(mapDockerNetworkToRow(info, organizationId, row.serverId))
.where(eq(network.networkId, networkId))
.returning();
if (!updated) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return updated;
};
export const findNetworkById = async (networkId: string) => {
const [row] = await db
.select()
@@ -252,14 +320,12 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
const docker = await getRemoteDocker(row.serverId ?? null);
try {
await docker.createNetwork({
const createOptions: NetworkCreateOptions = {
Name: row.name,
Driver: row.driver,
CheckDuplicate: true,
Internal: row.internal,
Attachable: row.attachable,
// EnableIPv4 is missing from dockerode's types but supported by
// the daemon (API >= 1.47); the body is sent as-is
EnableIPv4: row.enableIPv4,
EnableIPv6: row.enableIPv6,
Options: row.mtu
@@ -269,7 +335,13 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
Driver: ipam.driver || "default",
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
},
} as Parameters<typeof docker.createNetwork>[0]);
};
const created = await docker.createNetwork(createOptions);
await db
.update(network)
.set({ dockerId: created.id })
.where(eq(network.networkId, row.networkId));
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
+1 -1
View File
@@ -485,7 +485,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => {
),
});
if (!composeResult) {
if (composeResult.length === 0) {
return;
}
let commands = "";
@@ -21,11 +21,31 @@ const getMonitoringImage = () => {
return imageName;
};
// Swarm tasks are dokploy-monitoring.<slot>.<id>, so this only matches the
// pre-v0.30.0 standalone container. A cleanup failure must not block the deploy.
const removeLegacyContainer = async (
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
serviceName: string,
) => {
try {
await docker.getContainer(serviceName).remove({ force: true });
console.log("Removed legacy monitoring container ✅");
} catch (error: any) {
if (error?.statusCode !== 404) {
console.warn(
`Could not remove legacy monitoring container: ${error?.message ?? error}`,
);
}
}
};
const deployMonitoringService = async (
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
serviceName: string,
settings: CreateServiceOptions,
) => {
await removeLegacyContainer(docker, serviceName);
try {
const service = docker.getService(serviceName);
const inspect = await service.inspect();
+12
View File
@@ -86,6 +86,7 @@ export const serverSetup = async (
...server.metricsConfig.server,
token: token,
urlCallback: urlCallback,
cronJob: server.metricsConfig.server.cronJob || "0 0 * * *",
},
containers: server.metricsConfig.containers,
},
@@ -177,6 +178,17 @@ else
OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
fi
# Ubuntu 26.04 (resolute) ships a docker-ce repo that has no 28.5.0, so the
# default pin is absent from apt-cache madison and get.docker.com aborts before
# installing Docker. Repin to a version present on every architecture Docker
# ships resolute for: amd64/arm64/armhf start at 29.3.1, but s390x only carries
# 29.4.0-29.4.2, so 29.4.2 is the newest version common to all of them. This
# must run after OS_VERSION is resolved and before the banner so the reported
# Docker version stays accurate.
if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "26.04" ]; then
DOCKER_VERSION=29.4.2
fi
if [ "$OS_TYPE" = 'amzn' ]; then
$SUDO_CMD dnf install -y findutils >/dev/null
fi
+20 -8
View File
@@ -7,6 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain";
import {
encodeBase64,
getEnvironmentVariablesObject,
prepareEnvironmentVariables,
prepareEnvironmentVariablesForFile,
} from "../docker/utils";
import { withResolvedVaultRefs } from "../vault";
@@ -20,11 +21,11 @@ export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
const compose = await withResolvedVaultRefs(rawCompose);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const { sourceType, appName, mounts, composeType, domains } = compose;
const command = createCommand(compose);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(compose, projectPath);
const envCommand = compose.createEnvFile
? getCreateEnvFileCommand(compose)
: "";
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const exportEnvCommand = getExportEnvCommand(compose);
const newCompose = await writeDomainsToCompose(compose, domains);
@@ -118,7 +119,7 @@ const sanitizeCommand = (command: string) => {
return restCommand.join(" ");
};
export const createCommand = (compose: ComposeNested) => {
export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const { composeType, appName, sourceType } = compose;
if (compose.command) {
return `${sanitizeCommand(compose.command)}`;
@@ -129,7 +130,10 @@ export const createCommand = (compose: ComposeNested) => {
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
const projectDirectoryFlag = projectPath
? `--project-directory ${quote([projectPath])} `
: "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}-f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
@@ -157,10 +161,18 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
}
const envFileContent = prepareEnvironmentVariablesForFile(
envContent,
compose.environment.project.env,
compose.environment.env,
const envFileContent = (
compose.composeType === "stack"
? prepareEnvironmentVariables(
envContent,
compose.environment.project.env,
compose.environment.env,
)
: prepareEnvironmentVariablesForFile(
envContent,
compose.environment.project.env,
compose.environment.env,
)
).join("\n");
const encodedContent = encodeBase64(envFileContent);
@@ -87,7 +87,15 @@ export const getRailpackCommand = (application: ApplicationNested) => {
# Ensure we have a builder with containerd (isolated per build)
export RAILPACK_VERSION=${application.railpackVersion}
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
# use sudo for non-root so the install can write to /usr/local/bin
if [ "$(id -u)" -eq 0 ]; then
SUDO_CMD=""
elif sudo -n true 2>/dev/null; then
SUDO_CMD="sudo"
else
SUDO_CMD=""
fi
$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"
docker buildx create --name ${builderName} --driver docker-container || true
echo "Preparing Railpack build plan..." ;
+1 -1
View File
@@ -548,7 +548,7 @@ export const prepareEnvironmentVariablesForFile = (
const escapedValue = value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\$/g, "\\$");
.replace(/\$(?!\{[A-Za-z_][A-Za-z0-9_]*(?::?[-+?][^{}]*)?\})/g, "\\$");
return `${key}="${escapedValue}"`;
});
};
@@ -292,6 +292,26 @@ export const writeTraefikConfigRemote = async (
}
};
const isEmptyHttpRoutersAndServices = (traefikConfig: FileConfig) =>
Object.keys(traefikConfig.http?.routers || {}).length === 0 &&
Object.keys(traefikConfig.http?.services || {}).length === 0;
export const writeAppTraefikConfig = async (
traefikConfig: FileConfig,
appName: string,
serverId?: string | null,
) => {
if (isEmptyHttpRoutersAndServices(traefikConfig)) {
await removeTraefikConfig(appName, serverId);
return;
}
if (serverId) {
await writeTraefikConfigRemote(traefikConfig, appName, serverId);
} else {
writeTraefikConfig(traefikConfig, appName);
}
};
export const createServiceConfig = (
appName: string,
domain: Domain,
@@ -3,7 +3,7 @@ import type { ApplicationNested } from "../builders";
import {
loadOrCreateConfig,
loadOrCreateConfigRemote,
writeTraefikConfig,
writeAppTraefikConfig,
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig } from "./file-types";
@@ -89,11 +89,10 @@ export const createRedirectMiddleware = async (
if (serverId) {
await writeTraefikConfigRemote(config, "middlewares", serverId);
await writeTraefikConfigRemote(appConfig, appName, serverId);
} else {
writeMiddleware(config);
writeTraefikConfig(appConfig, appName);
}
await writeAppTraefikConfig(appConfig, appName, serverId);
};
export const removeRedirectMiddleware = async (
@@ -124,9 +123,8 @@ export const removeRedirectMiddleware = async (
if (serverId) {
await writeTraefikConfigRemote(config, "middlewares", serverId);
await writeTraefikConfigRemote(appConfig, appName, serverId);
} else {
writeTraefikConfig(appConfig, appName);
writeMiddleware(config);
}
await writeAppTraefikConfig(appConfig, appName, serverId);
};
@@ -4,7 +4,7 @@ import type { ApplicationNested } from "../builders";
import {
loadOrCreateConfig,
loadOrCreateConfigRemote,
writeTraefikConfig,
writeAppTraefikConfig,
writeTraefikConfigRemote,
} from "./application";
import type {
@@ -62,11 +62,10 @@ export const createSecurityMiddleware = async (
addMiddleware(appConfig, middlewareName);
if (serverId) {
await writeTraefikConfigRemote(config, "middlewares", serverId);
await writeTraefikConfigRemote(appConfig, appName, serverId);
} else {
writeTraefikConfig(appConfig, appName);
writeMiddleware(config);
}
await writeAppTraefikConfig(appConfig, appName, serverId);
};
export const removeSecurityMiddleware = async (
@@ -89,6 +88,7 @@ export const removeSecurityMiddleware = async (
appConfig = loadOrCreateConfig(appName);
}
const middlewareName = `auth-${appName}`;
let removedLastUser = false;
if (config.http?.middlewares) {
const currentMiddleware = config.http.middlewares[middlewareName];
@@ -106,11 +106,7 @@ export const removeSecurityMiddleware = async (
delete config.http.middlewares[middlewareName];
}
deleteMiddleware(appConfig, middlewareName);
if (serverId) {
await writeTraefikConfigRemote(appConfig, appName, serverId);
} else {
writeTraefikConfig(appConfig, appName);
}
removedLastUser = true;
}
}
}
@@ -120,6 +116,9 @@ export const removeSecurityMiddleware = async (
} else {
writeMiddleware(config);
}
if (removedLastUser) {
await writeAppTraefikConfig(appConfig, appName, serverId);
}
};
const isBasicAuthMiddleware = (
@@ -9,6 +9,43 @@ import {
normalizeS3Path,
} from "../backups/utils";
interface RestartSafeBackupCommandOptions {
stopCommand: string;
backupCommand: string;
startCommand: string;
uploadCommand: string;
}
export const createRestartSafeBackupCommand = ({
stopCommand,
backupCommand,
startCommand,
uploadCommand,
}: RestartSafeBackupCommandOptions) => `
${stopCommand}
set +e
(
${backupCommand}
)
DOKPLOY_VOLUME_BACKUP_STATUS=$?
(
set -e
${startCommand}
)
DOKPLOY_VOLUME_RESTART_STATUS=$?
set -e
if [ "$DOKPLOY_VOLUME_BACKUP_STATUS" -ne 0 ]; then
if [ "$DOKPLOY_VOLUME_RESTART_STATUS" -ne 0 ]; then
echo "Service restart also failed with exit code $DOKPLOY_VOLUME_RESTART_STATUS"
fi
exit "$DOKPLOY_VOLUME_BACKUP_STATUS"
fi
if [ "$DOKPLOY_VOLUME_RESTART_STATUS" -ne 0 ]; then
exit "$DOKPLOY_VOLUME_RESTART_STATUS"
fi
${uploadCommand}
`;
export const getVolumeServiceAppName = (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
): string => {
@@ -117,16 +154,20 @@ export const backupVolume = async (
);
if (serviceType === "application") {
return lockWrapper(`
echo "Stopping application to 0 replicas"
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
echo "Actual replicas: $ACTUAL_REPLICAS"
docker service update --replicas=0 ${volumeBackup.application?.appName}
${backupCommand}
echo "Starting application to $ACTUAL_REPLICAS replicas"
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}
${uploadCommand}
`);
return lockWrapper(
createRestartSafeBackupCommand({
stopCommand: `
echo "Stopping application to 0 replicas"
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
echo "Actual replicas: $ACTUAL_REPLICAS"
docker service update --replicas=0 ${volumeBackup.application?.appName}`,
backupCommand,
startCommand: `
echo "Starting application to $ACTUAL_REPLICAS replicas"
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}`,
uploadCommand,
}),
);
}
if (serviceType === "compose") {
const compose = await findComposeById(
@@ -158,11 +199,13 @@ export const backupVolume = async (
echo "Compose container started"
`;
}
return lockWrapper(`
${stopCommand}
${backupCommand}
${startCommand}
${uploadCommand}
`);
return lockWrapper(
createRestartSafeBackupCommand({
stopCommand,
backupCommand,
startCommand,
uploadCommand,
}),
);
}
};
+1 -1
View File
@@ -40,7 +40,7 @@ export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
if (!/^[\w/. :[\]-]{1,500}$/.test(directory)) {
if (!/^[\w/. :[\]+@~(),=%-]{1,500}$/.test(directory)) {
return false;
}
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
PAYLOAD=$(cat)
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
if [ -z "$WORKTREE_PATH" ]; then
exit 0
fi
ENV_FILE="$WORKTREE_PATH/apps/dokploy/.env"
if [ ! -f "$ENV_FILE" ]; then
exit 0
fi
FREE_PORT=$(node "$CLAUDE_PROJECT_DIR/scripts/find-free-port.mjs")
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" "$ENV_FILE"
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
echo "Worktree $WORKTREE_PATH -> dokploy dev PORT=$FREE_PORT"
+26
View File
@@ -0,0 +1,26 @@
import { createServer } from "node:net";
function isFree(port) {
return new Promise((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, "0.0.0.0", () => {
server.close(() => resolve(true));
});
});
}
async function findFreePort(start) {
let port = start;
while (!(await isFree(port))) {
port++;
}
return port;
}
const start = Number.parseInt(
process.argv[2] || process.env.PORT || "3000",
10,
);
const port = await findFreePort(start);
process.stdout.write(String(port));
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
PAYLOAD=$(cat)
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
if [ -z "$WORKTREE_PATH" ]; then
exit 0
fi
# Turbopack refuses to resolve through anything outside its detected
# workspace root, so a symlinked node_modules (whole dir or per-entry)
# doesn't work for apps/dokploy. A real `pnpm install` is required, but
# since pnpm's global content-addressable store is already warm, this
# only links locally — no network fetch, a few seconds.
cd "$WORKTREE_PATH"
pnpm install --prefer-offline
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
NAME="${1:?usage: spawn-agent-worktree.sh <name>}"
# --show-toplevel would return the CURRENT worktree's own root if this is
# run from inside one (e.g. another agent's worktree) instead of the main
# checkout. --git-common-dir always points at the shared .git regardless of
# which worktree you're standing in.
REPO_ROOT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/$NAME"
BRANCH="worktree-$NAME"
if [ -e "$WORKTREE_PATH" ]; then
echo "Worktree already exists: $WORKTREE_PATH" >&2
exit 1
fi
git -C "$REPO_ROOT" fetch origin canary --quiet
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WORKTREE_PATH" origin/canary >&2
# .worktreeinclude lists gitignored files (.env, .env.local) that a plain
# `git worktree add` won't check out on its own - copy them in.
while IFS= read -r pattern; do
[ -z "$pattern" ] && continue
find "$REPO_ROOT" \( -path "$REPO_ROOT/.claude/worktrees" -o -path "$REPO_ROOT/node_modules" \) -prune -o -name "$pattern" -print 2>/dev/null
done < "$REPO_ROOT/.worktreeinclude" | while read -r src; do
rel="${src#"$REPO_ROOT"/}"
dest="$WORKTREE_PATH/$rel"
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
done
cd "$WORKTREE_PATH"
pnpm install --prefer-offline >&2
FREE_PORT=$(node "$REPO_ROOT/scripts/find-free-port.mjs")
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" apps/dokploy/.env
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" apps/dokploy/.env
rm -f apps/dokploy/.env.bak
pnpm --filter=dokploy run dev > "$WORKTREE_PATH/dev-server.log" 2>&1 &
echo $! > "$WORKTREE_PATH/dev-server.pid"
BASE_URL="http://localhost:$FREE_PORT"
for _ in $(seq 1 30); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$BASE_URL/" || true)
if [ "$CODE" != "000" ]; then
break
fi
sleep 1
done
echo "export WORKTREE_PATH=$WORKTREE_PATH"
echo "export DOKPLOY_BASE_URL=$BASE_URL"
echo "export PORT=$FREE_PORT"