From b9a8064371354052a574b23fbd6fe3fcb01b7889 Mon Sep 17 00:00:00 2001 From: Philippe Parage <69145356+pparage@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:11:01 +0200 Subject: [PATCH 01/27] 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. --- packages/server/src/setup/server-setup.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index e2d7fc3e5..557bb3b1b 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -162,6 +162,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 From 55704ff98fff53a1c36fa79497efff4a062c7d52 Mon Sep 17 00:00:00 2001 From: Blas Date: Fri, 26 Jun 2026 06:10:03 -0300 Subject: [PATCH 02/27] feat: add upgrade integration test workflow (manual, matrix-based) --- .../workflows/upgrade-integration-test.yml | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 .github/workflows/upgrade-integration-test.yml diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml new file mode 100644 index 000000000..05d789c69 --- /dev/null +++ b/.github/workflows/upgrade-integration-test.yml @@ -0,0 +1,418 @@ +# 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. +# +# Two modes, combined into a single matrix: +# • Auto: every stable tag >= floor_version → latest stable tag (N jobs) +# • Curated: hand-picked pairs (default: latest v0.25.x → latest v0.26.x) +# +# 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 auto pairs (e.g. v0.20.0)' + required: false + default: 'v0.20.0' + extra_pairs: + description: 'Extra pairs as JSON, e.g. [{"from":"v0.25.2","to":"v0.26.0"}]' + 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 }} + EXTRA: ${{ inputs.extra_pairs }} + run: | + set -euo pipefail + + # 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) + + LATEST=$(echo "$ALL_TAGS" | tail -n1) + echo "Latest tag on Docker Hub: $LATEST" + + # Floor semver components + FLOOR_CLEAN="${FLOOR#v}" + IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN" + + # Build "each version >= FLOOR, != LATEST → LATEST" pairs + AUTO_PAIRS=$(echo "$ALL_TAGS" | while read -r TAG; do + [ "$TAG" = "$LATEST" ] && 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 + printf '{"from":"%s","to":"%s"}\n' "$TAG" "$LATEST" + fi + done | jq -sc '.') + + # Curated pairs (latest v0.25.x → latest v0.26.x) + # Find the highest v0.25.x and v0.26.x tags from Docker Hub + V25=$(echo "$ALL_TAGS" | grep '^v0\.25\.' | tail -n1) + V26=$(echo "$ALL_TAGS" | grep '^v0\.26\.' | tail -n1) + CURATED='[]' + if [ -n "$V25" ] && [ -n "$V26" ] && [ "$V25" != "$V26" ]; then + CURATED=$(jq -cn --arg f "$V25" --arg t "$V26" '[{"from":$f,"to":$t}]') + fi + + EXTRA_JSON="${EXTRA:-[]}" + if [ -z "$EXTRA_JSON" ] || [ "$EXTRA_JSON" = "" ]; then + EXTRA_JSON="[]" + fi + + # Merge all sources, deduplicate by "from+to" + ALL=$(jq -cn \ + --argjson auto "$AUTO_PAIRS" \ + --argjson curated "$CURATED" \ + --argjson extra "$EXTRA_JSON" \ + '$auto + $curated + $extra | unique_by(.from + "-" + .to)') + + COUNT=$(echo "$ALL" | jq 'length') + echo "Total pairs: $COUNT" + echo "$ALL" | jq -r '.[] | " \(.from) → \(.to)"' + + echo "pairs=$ALL" >> "$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 + + - name: Pre-init Docker Swarm (so install.sh skips its own swarm init) + run: | + docker swarm init --advertise-addr 127.0.0.1 + docker network create --driver overlay --attachable dokploy-network || true + + # ── Install Dokploy infrastructure ───────────────────────────────────── + - name: Run Dokploy install.sh (sets up Traefik, Redis, Postgres, service) + run: curl -sSL https://dokploy.com/install.sh | bash + + # ── Pin to version A before any data is written ──────────────────────── + - name: Pin Dokploy to VERSION A (${{ matrix.pair.from }}) + run: | + docker service update \ + --image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.from }}" \ + --force \ + "${{ env.DOKPLOY_SERVICE }}" + + echo "Waiting for service to converge on ${{ matrix.pair.from }}..." + timeout 180 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.from }}" + + - name: Wait for Dokploy API to accept requests + run: | + timeout 120 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=$(mktemp /tmp/dokploy-cookies-XXXXXX) + echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT" + + # better-auth: sign-up/email (allowed only before any owner exists) + RESP=$(curl -sf -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!"}') + + echo "Sign-up response: $RESP" + # Session cookie is stored in the jar; also capture token if present + TOKEN=$(echo "$RESP" | jq -r '.token // empty' 2>/dev/null || true) + if [ -n "$TOKEN" ]; then + echo "::add-mask::$TOKEN" + # Inject token as a cookie for older versions that don't use cookie jar + echo "dokploy.com FALSE / FALSE 0 auth_token $TOKEN" >> "$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: | + # --- tRPC helpers --- + # Mutation (POST) + trpc_mut() { + curl -sf -X POST "$BASE/api/trpc/$1" \ + -H "Content-Type: application/json" \ + -b "$COOKIE" \ + -d "{\"json\":$2}" + } + + # --- Project --- + PROJECT=$(trpc_mut project.create \ + '{"name":"ci-upgrade-test","description":"CI upgrade integration test"}') + echo "project.create → $PROJECT" + + PROJECT_ID=$(echo "$PROJECT" | jq -r '.result.data.json.project.projectId') + ENV_ID=$(echo "$PROJECT" | jq -r '.result.data.json.environment.environmentId') + echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT" + echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT" + + # --- PostgreSQL 15 --- + PG=$(trpc_mut postgres.create \ + "{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\ + \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ + \"databasePassword\":\"CiPg1!\",\ + \"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}") + PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId') + trpc_mut postgres.start "{\"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\":\"CiMg1!\",\ + \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}") + MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId') + trpc_mut mongo.start "{\"mongoId\":\"$MG_ID\"}" > /dev/null + echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT" + + # --- Docker-image application helper --- + # Creates an application, sets Docker image provider, triggers deploy. + 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: | + # Poll a tRPC query endpoint until the service status field == "done" + wait_done() { + local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5 + echo "Waiting for $NAME to reach 'done'..." + timeout 300 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 + + # User services should all be 1/1; check none are 0/1 + 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 180 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 120 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 100) ===" && \ + docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 100 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" < Date: Fri, 26 Jun 2026 09:32:52 +0000 Subject: [PATCH 03/27] 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). --- .../workflows/upgrade-integration-test.yml | 92 ++++++++++--------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml index 05d789c69..4f1b1d3b2 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -123,41 +123,39 @@ jobs: docker system prune -af --volumes df -h - - name: Pre-init Docker Swarm (so install.sh skips its own swarm init) + # ── 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: | - docker swarm init --advertise-addr 127.0.0.1 - docker network create --driver overlay --attachable dokploy-network || true + 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 - # ── Install Dokploy infrastructure ───────────────────────────────────── - - name: Run Dokploy install.sh (sets up Traefik, Redis, Postgres, service) - run: curl -sSL https://dokploy.com/install.sh | bash - - # ── Pin to version A before any data is written ──────────────────────── - - name: Pin Dokploy to VERSION A (${{ matrix.pair.from }}) + - name: Wait for dokploy service to converge on ${{ matrix.pair.from }} run: | - docker service update \ - --image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.from }}" \ - --force \ - "${{ env.DOKPLOY_SERVICE }}" - - echo "Waiting for service to converge on ${{ matrix.pair.from }}..." - timeout 180 bash -c ' - until ! docker service inspect dokploy \ - --format "{{.UpdateStatus.State}}" 2>/dev/null \ - | grep -q "^updating$"; do - sleep 4 - done + 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 120 bash -c ' + timeout 180 bash -c ' until curl -sf -o /dev/null \ "http://localhost:${{ env.DOKPLOY_PORT }}"; do sleep 3 @@ -169,23 +167,25 @@ jobs: - name: Register first admin user id: auth run: | - COOKIE_JAR=$(mktemp /tmp/dokploy-cookies-XXXXXX) + 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) - RESP=$(curl -sf -X POST \ + 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!"}') + -d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \ + | tee /tmp/signup.out + set +x - echo "Sign-up response: $RESP" - # Session cookie is stored in the jar; also capture token if present - TOKEN=$(echo "$RESP" | jq -r '.token // empty' 2>/dev/null || true) - if [ -n "$TOKEN" ]; then - echo "::add-mask::$TOKEN" - # Inject token as a cookie for older versions that don't use cookie jar - echo "dokploy.com FALSE / FALSE 0 auth_token $TOKEN" >> "$COOKIE_JAR" + # 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 ────────────────────────────────────────────── @@ -195,12 +195,13 @@ jobs: BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" COOKIE: ${{ steps.auth.outputs.cookie_jar }} run: | - # --- tRPC helpers --- - # Mutation (POST) + set -euo pipefail + + # --- tRPC POST helper --- trpc_mut() { curl -sf -X POST "$BASE/api/trpc/$1" \ -H "Content-Type: application/json" \ - -b "$COOKIE" \ + -b "$COOKIE" -c "$COOKIE" \ -d "{\"json\":$2}" } @@ -218,7 +219,7 @@ jobs: PG=$(trpc_mut postgres.create \ "{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\ \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ - \"databasePassword\":\"CiPg1!\",\ + \"databasePassword\":\"CiPg1pass\",\ \"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}") PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId') trpc_mut postgres.start "{\"postgresId\":\"$PG_ID\"}" > /dev/null @@ -228,14 +229,13 @@ jobs: MG=$(trpc_mut mongo.create \ "{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\ \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ - \"databasePassword\":\"CiMg1!\",\ + \"databasePassword\":\"CiMg1pass\",\ \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}") MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId') trpc_mut mongo.start "{\"mongoId\":\"$MG_ID\"}" > /dev/null echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT" # --- Docker-image application helper --- - # Creates an application, sets Docker image provider, triggers deploy. make_app() { local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3 APP=$(trpc_mut application.create \ @@ -276,11 +276,10 @@ jobs: APP_NODE_ID: ${{ steps.create.outputs.app_node_id }} APP_GO_ID: ${{ steps.create.outputs.app_go_id }} run: | - # Poll a tRPC query endpoint until the service status field == "done" wait_done() { local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5 echo "Waiting for $NAME to reach 'done'..." - timeout 300 bash -c " + timeout 360 bash -c " until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \ --data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \ -b '$COOKIE' | \ @@ -303,7 +302,6 @@ jobs: echo "=== docker service ls ===" docker service ls - # User services should all be 1/1; check none are 0/1 FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \ grep -E '^ci-' | grep -v ' 1/1' || true) if [ -n "$FAIL" ]; then @@ -322,7 +320,7 @@ jobs: "${{ env.DOKPLOY_SERVICE }}" echo "Waiting for service to converge on ${{ matrix.pair.to }}..." - timeout 180 bash -c ' + timeout 240 bash -c ' until ! docker service inspect dokploy \ --format "{{.UpdateStatus.State}}" 2>/dev/null \ | grep -q "^updating$"; do @@ -338,7 +336,7 @@ jobs: - name: Wait for Dokploy API to respond post-upgrade run: | - timeout 120 bash -c ' + timeout 180 bash -c ' until curl -sf -o /dev/null \ "http://localhost:${{ env.DOKPLOY_PORT }}"; do sleep 3 @@ -395,8 +393,12 @@ jobs: 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 100) ===" && \ - docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 100 2>&1 || 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 ──────────────────────────────────────────────────────── From f29a1a1976db0526de13bf39ef7b457d8dc87fde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 13:43:30 +0000 Subject: [PATCH 04/27] 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. --- .../workflows/upgrade-integration-test.yml | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml index 4f1b1d3b2..d0e3531e4 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -210,17 +210,34 @@ jobs: '{"name":"ci-upgrade-test","description":"CI upgrade integration test"}') echo "project.create → $PROJECT" - PROJECT_ID=$(echo "$PROJECT" | jq -r '.result.data.json.project.projectId') - ENV_ID=$(echo "$PROJECT" | jq -r '.result.data.json.environment.environmentId') + # Support both API eras: + # Old (pre-environments, ≤ ~v0.24): project.create returns a flat + # object → .result.data.json.projectId + # New (environments feature, ≥ ~v0.25): returns nested objects + # → .result.data.json.project.projectId + .environment.environmentId + PROJECT_ID=$(echo "$PROJECT" | jq -r \ + '.result.data.json.project.projectId // .result.data.json.projectId') + ENV_ID=$(echo "$PROJECT" | jq -r \ + '.result.data.json.environment.environmentId // empty') echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT" - echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT" + echo "env_id=${ENV_ID:-none}" >> "$GITHUB_OUTPUT" + echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=${ENV_ID:-}" + + # Build the JSON scope fragment used in every resource-create call. + # Old API uses projectId; new API uses environmentId. + if [ -n "$ENV_ID" ] && [ "$ENV_ID" != "null" ]; then + SCOPE="\"environmentId\":\"$ENV_ID\"" + else + SCOPE="\"projectId\":\"$PROJECT_ID\"" + fi + echo "Resource scope: $SCOPE" # --- 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\"}") + \"dockerImage\":\"postgres:15\",$SCOPE}") PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId') trpc_mut postgres.start "{\"postgresId\":\"$PG_ID\"}" > /dev/null echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT" @@ -230,7 +247,7 @@ jobs: "{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\ \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ \"databasePassword\":\"CiMg1pass\",\ - \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}") + \"dockerImage\":\"mongo:7.0\",$SCOPE}") MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId') trpc_mut mongo.start "{\"mongoId\":\"$MG_ID\"}" > /dev/null echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT" @@ -239,8 +256,7 @@ jobs: 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\"}") + "{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",$SCOPE}") APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId') trpc_mut application.saveDockerProvider \ "{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\ From f59f11474b6a40892020742d7c8cf6f6854057fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 14:15:13 +0000 Subject: [PATCH 05/27] fix(ci): raise floor to v0.29.4, require environmentId from project.create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../workflows/upgrade-integration-test.yml | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml index d0e3531e4..875496c42 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -15,9 +15,9 @@ on: workflow_dispatch: inputs: floor_version: - description: 'Oldest version tag to include in auto pairs (e.g. v0.20.0)' + description: 'Oldest version tag to include in auto pairs (e.g. v0.29.4)' required: false - default: 'v0.20.0' + default: 'v0.29.4' extra_pairs: description: 'Extra pairs as JSON, e.g. [{"from":"v0.25.2","to":"v0.26.0"}]' required: false @@ -210,34 +210,34 @@ jobs: '{"name":"ci-upgrade-test","description":"CI upgrade integration test"}') echo "project.create → $PROJECT" - # Support both API eras: - # Old (pre-environments, ≤ ~v0.24): project.create returns a flat - # object → .result.data.json.projectId - # New (environments feature, ≥ ~v0.25): returns nested objects - # → .result.data.json.project.projectId + .environment.environmentId + # 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') + '.result.data.json.project.projectId // .result.data.json.projectId // empty') ENV_ID=$(echo "$PROJECT" | jq -r \ '.result.data.json.environment.environmentId // empty') - echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT" - echo "env_id=${ENV_ID:-none}" >> "$GITHUB_OUTPUT" - echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=${ENV_ID:-}" - # Build the JSON scope fragment used in every resource-create call. - # Old API uses projectId; new API uses environmentId. - if [ -n "$ENV_ID" ] && [ "$ENV_ID" != "null" ]; then - SCOPE="\"environmentId\":\"$ENV_ID\"" - else - SCOPE="\"projectId\":\"$PROJECT_ID\"" + if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then + echo "❌ Could not extract projectId from project.create response:" + echo "$PROJECT" | jq . + exit 1 fi - echo "Resource scope: $SCOPE" + 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\",$SCOPE}") + \"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}") + echo "postgres.create → $PG" PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId') trpc_mut postgres.start "{\"postgresId\":\"$PG_ID\"}" > /dev/null echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT" @@ -247,7 +247,8 @@ jobs: "{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\ \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ \"databasePassword\":\"CiMg1pass\",\ - \"dockerImage\":\"mongo:7.0\",$SCOPE}") + \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}") + echo "mongo.create → $MG" MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId') trpc_mut mongo.start "{\"mongoId\":\"$MG_ID\"}" > /dev/null echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT" @@ -256,7 +257,7 @@ jobs: make_app() { local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3 APP=$(trpc_mut application.create \ - "{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",$SCOPE}") + "{\"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\",\ From 2643ae0a86a7cfc801a3536c140f98eb9925ecdf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 14:27:36 +0000 Subject: [PATCH 06/27] fix(ci): deploy postgres/mongo instead of start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/upgrade-integration-test.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml index 875496c42..8491e45bb 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -239,7 +239,9 @@ jobs: \"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}") echo "postgres.create → $PG" PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId') - trpc_mut postgres.start "{\"postgresId\":\"$PG_ID\"}" > /dev/null + # 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 --- @@ -250,7 +252,7 @@ jobs: \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}") echo "mongo.create → $MG" MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId') - trpc_mut mongo.start "{\"mongoId\":\"$MG_ID\"}" > /dev/null + trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT" # --- Docker-image application helper --- From eaec68023ede4fd9f37ef9212d204c83175b3b39 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 15:20:10 +0000 Subject: [PATCH 07/27] feat: consecutive upgrade pairs, drop curated pair and extra_pairs input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../workflows/upgrade-integration-test.yml | 61 ++++++++----------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml index 8491e45bb..ecb7e0b59 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -3,9 +3,7 @@ # Tests that Dokploy can upgrade from version A to version B while keeping # user projects (Postgres, MongoDB, two web apps, one static site) alive. # -# Two modes, combined into a single matrix: -# • Auto: every stable tag >= floor_version → latest stable tag (N jobs) -# • Curated: hand-picked pairs (default: latest v0.25.x → latest v0.26.x) +# Generates consecutive upgrade pairs: each stable tag >= floor_version → next tag. # # Only triggered manually to avoid burning Actions minutes. @@ -15,13 +13,9 @@ on: workflow_dispatch: inputs: floor_version: - description: 'Oldest version tag to include in auto pairs (e.g. v0.29.4)' + description: 'Oldest version tag to include in pairs (e.g. v0.29.4)' required: false default: 'v0.29.4' - extra_pairs: - description: 'Extra pairs as JSON, e.g. [{"from":"v0.25.2","to":"v0.26.0"}]' - required: false - default: '' env: DOKPLOY_IMAGE: dokploy/dokploy @@ -43,7 +37,6 @@ jobs: id: pairs env: FLOOR: ${{ inputs.floor_version }} - EXTRA: ${{ inputs.extra_pairs }} run: | set -euo pipefail @@ -54,51 +47,45 @@ jobs: grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \ sort -V) - LATEST=$(echo "$ALL_TAGS" | tail -n1) - echo "Latest tag on Docker Hub: $LATEST" + echo "All tags found:" + echo "$ALL_TAGS" # Floor semver components FLOOR_CLEAN="${FLOOR#v}" IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN" - # Build "each version >= FLOOR, != LATEST → LATEST" pairs - AUTO_PAIRS=$(echo "$ALL_TAGS" | while read -r TAG; do - [ "$TAG" = "$LATEST" ] && continue + # Filter tags >= FLOOR + FILTERED=$(echo "$ALL_TAGS" | while read -r TAG; do 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 - printf '{"from":"%s","to":"%s"}\n' "$TAG" "$LATEST" + echo "$TAG" fi - done | jq -sc '.') + done) - # Curated pairs (latest v0.25.x → latest v0.26.x) - # Find the highest v0.25.x and v0.26.x tags from Docker Hub - V25=$(echo "$ALL_TAGS" | grep '^v0\.25\.' | tail -n1) - V26=$(echo "$ALL_TAGS" | grep '^v0\.26\.' | tail -n1) - CURATED='[]' - if [ -n "$V25" ] && [ -n "$V26" ] && [ "$V25" != "$V26" ]; then - CURATED=$(jq -cn --arg f "$V25" --arg t "$V26" '[{"from":$f,"to":$t}]') - fi + echo "Tags >= $FLOOR:" + echo "$FILTERED" - EXTRA_JSON="${EXTRA:-[]}" - if [ -z "$EXTRA_JSON" ] || [ "$EXTRA_JSON" = "" ]; then - EXTRA_JSON="[]" - fi + # Build consecutive pairs: tag[i] → tag[i+1] + TAGS_ARR=() + while IFS= read -r t; do TAGS_ARR+=("$t"); done <<< "$FILTERED" - # Merge all sources, deduplicate by "from+to" - ALL=$(jq -cn \ - --argjson auto "$AUTO_PAIRS" \ - --argjson curated "$CURATED" \ - --argjson extra "$EXTRA_JSON" \ - '$auto + $curated + $extra | unique_by(.from + "-" + .to)') + PAIRS='[]' + for (( i=0; i<${#TAGS_ARR[@]}-1; i++ )); do + FROM="${TAGS_ARR[$i]}" + TO="${TAGS_ARR[$i+1]}" + PAIRS=$(echo "$PAIRS" | jq -c \ + --arg f "$FROM" --arg t "$TO" \ + '. + [{"from":$f,"to":$t}]') + done - COUNT=$(echo "$ALL" | jq 'length') + COUNT=$(echo "$PAIRS" | jq 'length') echo "Total pairs: $COUNT" - echo "$ALL" | jq -r '.[] | " \(.from) → \(.to)"' + echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"' - echo "pairs=$ALL" >> "$GITHUB_OUTPUT" + echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT" # ── 2. Run one upgrade test per pair ──────────────────────────────────────── From a504449f1dfedcd78acab809f489bb346d01e189 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 15:20:10 +0000 Subject: [PATCH 08/27] 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 --- .../workflows/upgrade-integration-test.yml | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml index ecb7e0b59..f73b7276e 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -3,7 +3,9 @@ # 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 consecutive upgrade pairs: each stable tag >= floor_version → next tag. +# 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. @@ -16,6 +18,10 @@ on: 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 @@ -36,10 +42,16 @@ jobs: - name: Generate pairs id: pairs env: - FLOOR: ${{ inputs.floor_version }} + 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" | \ @@ -50,36 +62,35 @@ jobs: 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" - # Filter tags >= FLOOR - FILTERED=$(echo "$ALL_TAGS" | while read -r TAG; do + # 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 - echo "$TAG" + PAIRS=$(echo "$PAIRS" | jq -c \ + --arg f "$TAG" --arg t "$TO" \ + '. + [{"from":$f,"to":$t}]') fi - done) - - echo "Tags >= $FLOOR:" - echo "$FILTERED" - - # Build consecutive pairs: tag[i] → tag[i+1] - TAGS_ARR=() - while IFS= read -r t; do TAGS_ARR+=("$t"); done <<< "$FILTERED" - - PAIRS='[]' - for (( i=0; i<${#TAGS_ARR[@]}-1; i++ )); do - FROM="${TAGS_ARR[$i]}" - TO="${TAGS_ARR[$i+1]}" - PAIRS=$(echo "$PAIRS" | jq -c \ - --arg f "$FROM" --arg t "$TO" \ - '. + [{"from":$f,"to":$t}]') - done + done <<< "$ALL_TAGS" COUNT=$(echo "$PAIRS" | jq 'length') echo "Total pairs: $COUNT" From 98dbc59a028fa365a84c9e85f73d6ee440a658b8 Mon Sep 17 00:00:00 2001 From: NoiceHax Date: Thu, 13 Aug 2026 22:46:40 +0530 Subject: [PATCH 09/27] 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 --- .../deploy/github-webhook-handler.test.ts | 161 +++++++++++++++++- apps/dokploy/pages/api/deploy/github.ts | 13 +- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index e25bd425e..6b61c97d7 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -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 = {}, + ): Record => ({ + 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); + }); +}); diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index 4fc75fc50..2273ac3bc 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -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, From dec05aca227bb119f653da0f1c489e9ae95aec8c Mon Sep 17 00:00:00 2001 From: JamBalaya56562 Date: Mon, 4 May 2026 03:00:03 +0900 Subject: [PATCH 10/27] 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 . Install tini and set it as ENTRYPOINT so any orphaned child is reaped while signals still reach Node. --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2d37692cc..7d0150416 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] From 2598a6277153e32638b4bf31e365f9b5f1e833b2 Mon Sep 17 00:00:00 2001 From: Aditya Nandlal <73009776+bestmaa@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:40:21 +0000 Subject: [PATCH 11/27] fix(volume-backups): restart services after backup failure --- .../backups/volume-backup-restart.test.ts | 49 ++++++++++++++ .../server/src/utils/volume-backups/backup.ts | 65 ++++++++++++++----- 2 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 apps/dokploy/__test__/backups/volume-backup-restart.test.ts diff --git a/apps/dokploy/__test__/backups/volume-backup-restart.test.ts b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts new file mode 100644 index 000000000..26b59ed2d --- /dev/null +++ b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts @@ -0,0 +1,49 @@ +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("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", + ]); + }); +}); diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index a9240f2dd..deb4f0c23 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -9,6 +9,33 @@ 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} + if [ "$DOKPLOY_VOLUME_BACKUP_STATUS" -ne 0 ]; then + exit "$DOKPLOY_VOLUME_BACKUP_STATUS" + fi + ${uploadCommand} +`; + export const getVolumeServiceAppName = ( volumeBackup: Awaited>, ): string => { @@ -117,16 +144,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 +189,13 @@ export const backupVolume = async ( echo "Compose container started" `; } - return lockWrapper(` - ${stopCommand} - ${backupCommand} - ${startCommand} - ${uploadCommand} - `); + return lockWrapper( + createRestartSafeBackupCommand({ + stopCommand, + backupCommand, + startCommand, + uploadCommand, + }), + ); } }; From c6ab76fe09093dfacd6d11ddde36e3b7f77719ae Mon Sep 17 00:00:00 2001 From: Aditya Nandlal <73009776+bestmaa@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:51:28 +0000 Subject: [PATCH 12/27] fix(volume-backups): preserve backup failure status --- .../backups/volume-backup-restart.test.ts | 33 +++++++++++++++++++ .../server/src/utils/volume-backups/backup.ts | 12 ++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/__test__/backups/volume-backup-restart.test.ts b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts index 26b59ed2d..ba2fc081e 100644 --- a/apps/dokploy/__test__/backups/volume-backup-restart.test.ts +++ b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts @@ -28,6 +28,39 @@ describe("createRestartSafeBackupCommand", () => { 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({ diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index deb4f0c23..3172c9c21 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -28,11 +28,21 @@ export const createRestartSafeBackupCommand = ({ ${backupCommand} ) DOKPLOY_VOLUME_BACKUP_STATUS=$? + ( + set -e + ${startCommand} + ) + DOKPLOY_VOLUME_RESTART_STATUS=$? set -e - ${startCommand} 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} `; From 2cb499fb64f05ddfccbc6b539b091d605935122c Mon Sep 17 00:00:00 2001 From: Shuvo Date: Mon, 17 Aug 2026 12:41:58 +0600 Subject: [PATCH 13/27] 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. --- apps/dokploy/server/api/routers/project.ts | 25 ++++++++++++++++++++++ packages/server/src/services/project.ts | 18 ++++++++-------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 2e35aee2a..43d2cf792 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -38,6 +38,7 @@ import { checkProjectAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { serviceColumns } from "@dokploy/server/services/project"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; @@ -130,39 +131,63 @@ export const projectRouter = createTRPCRouter({ environments: { with: { applications: { + columns: { + ...serviceColumns, + applicationId: true, + icon: true, + }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter( applications.applicationId, accessedServices, ), }, compose: { + columns: { + ...serviceColumns, + composeId: true, + composeStatus: true, + }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter( compose.composeId, accessedServices, ), }, libsql: { + columns: { ...serviceColumns, libsqlId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter(libsql.libsqlId, accessedServices), }, mariadb: { + columns: { ...serviceColumns, mariadbId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter( mariadb.mariadbId, accessedServices, ), }, mongo: { + columns: { ...serviceColumns, mongoId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter(mongo.mongoId, accessedServices), }, mysql: { + columns: { ...serviceColumns, mysqlId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter(mysql.mysqlId, accessedServices), }, postgres: { + columns: { ...serviceColumns, postgresId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter( postgres.postgresId, accessedServices, ), }, redis: { + columns: { ...serviceColumns, redisId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter(redis.redisId, accessedServices), }, }, diff --git a/packages/server/src/services/project.ts b/packages/server/src/services/project.ts index 52ef679b4..04a628a86 100644 --- a/packages/server/src/services/project.ts +++ b/packages/server/src/services/project.ts @@ -47,16 +47,16 @@ export const createProject = async ( }; }; -export const findProjectById = async (projectId: string) => { - const serviceColumns = { - name: true, - description: true, - appName: true, - createdAt: true, - serverId: true, - applicationStatus: true, - } as const; +export const serviceColumns = { + name: true, + description: true, + appName: true, + createdAt: true, + serverId: true, + applicationStatus: true, +} as const; +export const findProjectById = async (projectId: string) => { const project = await db.query.projects.findFirst({ where: eq(projects.projectId, projectId), with: { From a691b469a1d7f77ba6e722e6ab8e32837deded6f Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:42:50 -0600 Subject: [PATCH 14/27] 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 (cherry picked from commit 6d8aa9d8da61ed35699b9f778320a7b5595956ba) [skip ci] --- apps/dokploy/server/api/routers/project.ts | 25 ++++++++++++++++++++++ packages/server/src/services/project.ts | 18 ++++++++-------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 2e35aee2a..43d2cf792 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -38,6 +38,7 @@ import { checkProjectAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { serviceColumns } from "@dokploy/server/services/project"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; @@ -130,39 +131,63 @@ export const projectRouter = createTRPCRouter({ environments: { with: { applications: { + columns: { + ...serviceColumns, + applicationId: true, + icon: true, + }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter( applications.applicationId, accessedServices, ), }, compose: { + columns: { + ...serviceColumns, + composeId: true, + composeStatus: true, + }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter( compose.composeId, accessedServices, ), }, libsql: { + columns: { ...serviceColumns, libsqlId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter(libsql.libsqlId, accessedServices), }, mariadb: { + columns: { ...serviceColumns, mariadbId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter( mariadb.mariadbId, accessedServices, ), }, mongo: { + columns: { ...serviceColumns, mongoId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter(mongo.mongoId, accessedServices), }, mysql: { + columns: { ...serviceColumns, mysqlId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter(mysql.mysqlId, accessedServices), }, postgres: { + columns: { ...serviceColumns, postgresId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter( postgres.postgresId, accessedServices, ), }, redis: { + columns: { ...serviceColumns, redisId: true }, + with: { server: { columns: { name: true } } }, where: buildServiceFilter(redis.redisId, accessedServices), }, }, diff --git a/packages/server/src/services/project.ts b/packages/server/src/services/project.ts index 52ef679b4..04a628a86 100644 --- a/packages/server/src/services/project.ts +++ b/packages/server/src/services/project.ts @@ -47,16 +47,16 @@ export const createProject = async ( }; }; -export const findProjectById = async (projectId: string) => { - const serviceColumns = { - name: true, - description: true, - appName: true, - createdAt: true, - serverId: true, - applicationStatus: true, - } as const; +export const serviceColumns = { + name: true, + description: true, + appName: true, + createdAt: true, + serverId: true, + applicationStatus: true, +} as const; +export const findProjectById = async (projectId: string) => { const project = await db.query.projects.findFirst({ where: eq(projects.projectId, projectId), with: { From ff275fe94efd23a675692fc62838f699c7ff5b38 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 17 Aug 2026 02:03:47 -0600 Subject: [PATCH 15/27] fix: sync terminal size with backend PTY, avoid connecting with placeholder container ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../dashboard/docker/logs/docker-logs-id.tsx | 19 ++- .../docker/terminal/docker-terminal.tsx | 138 ++++++++++++------ .../settings/web-server/terminal.tsx | 34 +++-- .../server/wss/docker-container-terminal.ts | 31 +++- apps/dokploy/server/wss/terminal.ts | 20 ++- apps/dokploy/server/wss/utils.ts | 42 ++++++ 6 files changed, 220 insertions(+), 64 deletions(-) diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index c6cb473e0..20570b07a 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -26,6 +26,11 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker views fall back to before a real container +// is selected/auto-selected — querying logs for it just surfaces Docker's +// raw "No such container: select-a-container" daemon error. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const priorities = [ { label: "Info", @@ -55,13 +60,16 @@ export const DockerLogsId: React.FC = ({ runType, serviceId, }) => { + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + const { data } = api.docker.getConfig.useQuery( { containerId, serverId: serverId ?? undefined, }, { - enabled: !!containerId, + enabled: hasContainer, }, ); @@ -134,7 +142,7 @@ export const DockerLogsId: React.FC = ({ }; useEffect(() => { - if (!containerId) return; + if (!hasContainer) return; let isCurrentConnection = true; let noDataTimeout: NodeJS.Timeout; @@ -425,10 +433,15 @@ export const DockerLogsId: React.FC = ({
- ) : ( + ) : hasContainer ? (
No logs found
+ ) : ( +
+ Select a container above to view its logs. If none are listed, + make sure the service is deployed and running. +
)} diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx index 75ddecbe9..2d79b85cc 100644 --- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx +++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx @@ -15,6 +15,10 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker modal renders with before a real container +// is selected/auto-selected — never worth opening a connection for. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const DockerTerminal: React.FC = ({ id, containerId, @@ -25,59 +29,105 @@ export const DockerTerminal: React.FC = ({ const [activeWay, setActiveWay] = React.useState("bash"); const { resolvedTheme } = useTheme(); useEffect(() => { - const container = document.getElementById(id); - if (container) { - container.innerHTML = ""; + if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) { + return; } - const term = new Terminal({ - cursorBlink: true, - lineHeight: 1.4, - convertEol: true, - theme: { - cursor: resolvedTheme === "light" ? "#000000" : "transparent", - background: "rgba(0, 0, 0, 0)", - foreground: "currentColor", - }, + + let cancelled = false; + let term: Terminal | null = null; + let ws: WebSocket | null = null; + let resizeObserver: ResizeObserver | null = null; + + // Deferred a frame so React StrictMode's dev-only phantom mount+cleanup + // (which runs synchronously, before anything paints) never creates a + // terminal in the first place — dodges a known xterm.js dispose race + // (xtermjs/xterm.js#5011) where a deferred internal callback outlives + // term.dispose() and reads renderer state that's already gone. + const frame = requestAnimationFrame(() => { + if (cancelled) return; + + const container = document.getElementById(id); + if (container) { + container.innerHTML = ""; + } + term = new Terminal({ + cursorBlink: true, + lineHeight: 1.4, + convertEol: true, + theme: { + cursor: resolvedTheme === "light" ? "#000000" : "transparent", + background: "rgba(0, 0, 0, 0)", + foreground: "currentColor", + }, + }); + const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + // @ts-expect-error + term.open(termRef.current); + term.loadAddon(addonFit); + addonFit.fit(); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; + + ws = new WebSocket(wsUrl); + const addonAttach = new AttachAddon(ws); + term.loadAddon(addonAttach); + + const sendResize = (cols: number, rows: number) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } }); - const addonFit = new FitAddon(); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; - - const ws = new WebSocket(wsUrl); - - const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - fixMacOsAltKeys(term); - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); - term.loadAddon(addonAttach); - addonFit.fit(); return () => { - ws.readyState === WebSocket.OPEN && ws.close(); - term.dispose(); + cancelled = true; + cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + term?.dispose(); }; }, [containerId, activeWay, id]); + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + return (
-
- - Select way to connect to {containerId} - - - - Bash - /bin/sh - - -
-
-
-
+ {hasContainer && ( +
+ + Select way to connect to {containerId} + + + + Bash + /bin/sh + + +
+ )} + {hasContainer ? ( +
+
+
+ ) : ( +
+ Select a container above to open a terminal. If none are listed, make + sure the service is deployed and running. +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index ceb09b2e7..afb1d4451 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx @@ -41,11 +41,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { }); const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + + // @ts-ignore + term.open(termRef.current); + // @ts-ignore + term.loadAddon(addonFit); + addonFit.fit(); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const urlParams = new URLSearchParams(); urlParams.set("serverId", serverId); + urlParams.set("cols", term.cols.toString()); + urlParams.set("rows", term.rows.toString()); if (serverId === "local") { const { port, username } = getLocalServerData(); @@ -57,17 +68,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { const ws = new WebSocket(wsUrl); const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - fixMacOsAltKeys(term); - - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); term.loadAddon(addonAttach); - addonFit.fit(); + + const sendResize = (cols: number, rows: number) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + const resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } + return () => { + resizeObserver.disconnect(); ws.readyState === WebSocket.OPEN && ws.close(); }; }, [id, serverId]); diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index 64e95c3ae..bd731476f 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -4,7 +4,12 @@ import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; import { canAccessDockerOverWss } from "./authorize"; -import { isValidContainerId, isValidShell } from "./utils"; +import { + isValidContainerId, + isValidShell, + parseResizeMessage, + parseTerminalSize, +} from "./utils"; export const setupDockerContainerTerminalWebSocketServer = ( server: http.Server, @@ -34,6 +39,10 @@ export const setupDockerContainerTerminalWebSocketServer = ( const activeWay = url.searchParams.get("activeWay"); const serverId = url.searchParams.get("serverId"); const serviceId = url.searchParams.get("serviceId"); + const { cols, rows } = parseTerminalSize( + url.searchParams.get("cols"), + url.searchParams.get("rows"), + ); const { user, session } = await validateRequest(req); if (!containerId) { @@ -92,7 +101,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( containerId, shell, ].join(" "); - conn.exec(dockerCommand, { pty: true }, (err, stream) => { + conn.exec(dockerCommand, { pty: { cols, rows } }, (err, stream) => { if (err) { console.error("SSH exec error:", err); ws.close(); @@ -123,7 +132,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( } else { command = message; } - stream.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + stream.setWindow(resize.rows, resize.cols, 0, 0); + return; + } + stream.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; @@ -161,7 +176,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( const ptyProcess = spawn( "docker", ["exec", "-it", "-w", "/", containerId, shell], - {}, + { cols, rows }, ); ptyProcess.onData((data) => { @@ -178,7 +193,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( } else { command = message; } - ptyProcess.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + ptyProcess.resize(resize.cols, resize.rows); + return; + } + ptyProcess.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 90c36855e..ed9495aeb 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -10,7 +10,11 @@ import { Client, type ConnectConfig } from "ssh2"; import { WebSocketServer } from "ws"; import { getDockerHost } from "../utils/docker"; import { canAccessTerminalOverWss } from "./authorize"; -import { setupLocalServerSSHKey } from "./utils"; +import { + parseResizeMessage, + parseTerminalSize, + setupLocalServerSSHKey, +} from "./utils"; const COMMAND_TO_ALLOW_LOCAL_ACCESS = ` # ---------------------------------------- @@ -88,6 +92,10 @@ export const setupTerminalWebSocketServer = ( wssTerm.on("connection", async (ws, req) => { const url = new URL(req.url || "", `http://${req.headers.host}`); const serverId = url.searchParams.get("serverId"); + const { cols, rows } = parseTerminalSize( + url.searchParams.get("cols"), + url.searchParams.get("rows"), + ); const { user, session } = await validateRequest(req); if (!user || !session || !serverId) { ws.close(); @@ -190,7 +198,7 @@ export const setupTerminalWebSocketServer = ( // Clear terminal content once connected ws.send("\x1bc"); - conn.shell({}, (err, stream) => { + conn.shell({ cols, rows }, (err, stream) => { if (err) throw err; stream @@ -216,7 +224,13 @@ export const setupTerminalWebSocketServer = ( } else { command = message; } - stream.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + stream.setWindow(resize.rows, resize.cols, 0, 0); + return; + } + stream.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; diff --git a/apps/dokploy/server/wss/utils.ts b/apps/dokploy/server/wss/utils.ts index 346093e1b..fb47f08fe 100644 --- a/apps/dokploy/server/wss/utils.ts +++ b/apps/dokploy/server/wss/utils.ts @@ -63,6 +63,48 @@ export const isValidShell = (shell: string): boolean => { return allowedShells.includes(shell); }; +/** + * Clamps cols/rows read from the client's initial connection query params + * to a sane range, falling back to the standard 80x24 default. + */ +export const parseTerminalSize = ( + colsParam: string | null, + rowsParam: string | null, +) => { + const cols = Number(colsParam); + const rows = Number(rowsParam); + return { + cols: Number.isInteger(cols) && cols > 0 && cols <= 1000 ? cols : 80, + rows: Number.isInteger(rows) && rows > 0 && rows <= 1000 ? rows : 24, + }; +}; + +/** + * Terminal input and resize control messages share the same websocket + * channel. Resize messages are JSON envelopes; regular keystrokes never + * start with "{", so this distinguishes them without an extra channel. + */ +export const parseResizeMessage = (data: string) => { + if (!data.startsWith("{")) return null; + try { + const parsed = JSON.parse(data); + if ( + parsed?.type === "resize" && + Number.isInteger(parsed.cols) && + Number.isInteger(parsed.rows) && + parsed.cols > 0 && + parsed.cols <= 1000 && + parsed.rows > 0 && + parsed.rows <= 1000 + ) { + return { cols: parsed.cols, rows: parsed.rows }; + } + } catch { + return null; + } + return null; +}; + export const getShell = () => { if (IS_CLOUD) { return "NO_AVAILABLE"; From c0265b49b4c04ddd5fdf449e15681dc1ee630b60 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:06:41 -0600 Subject: [PATCH 16/27] Merge pull request #5104 from Dokploy/fix/issue-5092-terminal-resize-desync fix: sync terminal size with backend PTY (fixes #5092) (cherry picked from commit b53fface695313355a77f6907cf2cb3813f92e44) [skip ci] --- .../dashboard/docker/logs/docker-logs-id.tsx | 19 ++- .../docker/terminal/docker-terminal.tsx | 138 ++++++++++++------ .../settings/web-server/terminal.tsx | 34 +++-- .../server/wss/docker-container-terminal.ts | 31 +++- apps/dokploy/server/wss/terminal.ts | 20 ++- apps/dokploy/server/wss/utils.ts | 42 ++++++ 6 files changed, 220 insertions(+), 64 deletions(-) diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index c6cb473e0..20570b07a 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -26,6 +26,11 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker views fall back to before a real container +// is selected/auto-selected — querying logs for it just surfaces Docker's +// raw "No such container: select-a-container" daemon error. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const priorities = [ { label: "Info", @@ -55,13 +60,16 @@ export const DockerLogsId: React.FC = ({ runType, serviceId, }) => { + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + const { data } = api.docker.getConfig.useQuery( { containerId, serverId: serverId ?? undefined, }, { - enabled: !!containerId, + enabled: hasContainer, }, ); @@ -134,7 +142,7 @@ export const DockerLogsId: React.FC = ({ }; useEffect(() => { - if (!containerId) return; + if (!hasContainer) return; let isCurrentConnection = true; let noDataTimeout: NodeJS.Timeout; @@ -425,10 +433,15 @@ export const DockerLogsId: React.FC = ({
- ) : ( + ) : hasContainer ? (
No logs found
+ ) : ( +
+ Select a container above to view its logs. If none are listed, + make sure the service is deployed and running. +
)}
diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx index 75ddecbe9..2d79b85cc 100644 --- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx +++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx @@ -15,6 +15,10 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker modal renders with before a real container +// is selected/auto-selected — never worth opening a connection for. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const DockerTerminal: React.FC = ({ id, containerId, @@ -25,59 +29,105 @@ export const DockerTerminal: React.FC = ({ const [activeWay, setActiveWay] = React.useState("bash"); const { resolvedTheme } = useTheme(); useEffect(() => { - const container = document.getElementById(id); - if (container) { - container.innerHTML = ""; + if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) { + return; } - const term = new Terminal({ - cursorBlink: true, - lineHeight: 1.4, - convertEol: true, - theme: { - cursor: resolvedTheme === "light" ? "#000000" : "transparent", - background: "rgba(0, 0, 0, 0)", - foreground: "currentColor", - }, + + let cancelled = false; + let term: Terminal | null = null; + let ws: WebSocket | null = null; + let resizeObserver: ResizeObserver | null = null; + + // Deferred a frame so React StrictMode's dev-only phantom mount+cleanup + // (which runs synchronously, before anything paints) never creates a + // terminal in the first place — dodges a known xterm.js dispose race + // (xtermjs/xterm.js#5011) where a deferred internal callback outlives + // term.dispose() and reads renderer state that's already gone. + const frame = requestAnimationFrame(() => { + if (cancelled) return; + + const container = document.getElementById(id); + if (container) { + container.innerHTML = ""; + } + term = new Terminal({ + cursorBlink: true, + lineHeight: 1.4, + convertEol: true, + theme: { + cursor: resolvedTheme === "light" ? "#000000" : "transparent", + background: "rgba(0, 0, 0, 0)", + foreground: "currentColor", + }, + }); + const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + // @ts-expect-error + term.open(termRef.current); + term.loadAddon(addonFit); + addonFit.fit(); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; + + ws = new WebSocket(wsUrl); + const addonAttach = new AttachAddon(ws); + term.loadAddon(addonAttach); + + const sendResize = (cols: number, rows: number) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } }); - const addonFit = new FitAddon(); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; - - const ws = new WebSocket(wsUrl); - - const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - fixMacOsAltKeys(term); - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); - term.loadAddon(addonAttach); - addonFit.fit(); return () => { - ws.readyState === WebSocket.OPEN && ws.close(); - term.dispose(); + cancelled = true; + cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + term?.dispose(); }; }, [containerId, activeWay, id]); + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + return (
-
- - Select way to connect to {containerId} - - - - Bash - /bin/sh - - -
-
-
-
+ {hasContainer && ( +
+ + Select way to connect to {containerId} + + + + Bash + /bin/sh + + +
+ )} + {hasContainer ? ( +
+
+
+ ) : ( +
+ Select a container above to open a terminal. If none are listed, make + sure the service is deployed and running. +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index ceb09b2e7..afb1d4451 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx @@ -41,11 +41,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { }); const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + + // @ts-ignore + term.open(termRef.current); + // @ts-ignore + term.loadAddon(addonFit); + addonFit.fit(); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const urlParams = new URLSearchParams(); urlParams.set("serverId", serverId); + urlParams.set("cols", term.cols.toString()); + urlParams.set("rows", term.rows.toString()); if (serverId === "local") { const { port, username } = getLocalServerData(); @@ -57,17 +68,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { const ws = new WebSocket(wsUrl); const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - fixMacOsAltKeys(term); - - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); term.loadAddon(addonAttach); - addonFit.fit(); + + const sendResize = (cols: number, rows: number) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + const resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } + return () => { + resizeObserver.disconnect(); ws.readyState === WebSocket.OPEN && ws.close(); }; }, [id, serverId]); diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index 64e95c3ae..bd731476f 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -4,7 +4,12 @@ import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; import { canAccessDockerOverWss } from "./authorize"; -import { isValidContainerId, isValidShell } from "./utils"; +import { + isValidContainerId, + isValidShell, + parseResizeMessage, + parseTerminalSize, +} from "./utils"; export const setupDockerContainerTerminalWebSocketServer = ( server: http.Server, @@ -34,6 +39,10 @@ export const setupDockerContainerTerminalWebSocketServer = ( const activeWay = url.searchParams.get("activeWay"); const serverId = url.searchParams.get("serverId"); const serviceId = url.searchParams.get("serviceId"); + const { cols, rows } = parseTerminalSize( + url.searchParams.get("cols"), + url.searchParams.get("rows"), + ); const { user, session } = await validateRequest(req); if (!containerId) { @@ -92,7 +101,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( containerId, shell, ].join(" "); - conn.exec(dockerCommand, { pty: true }, (err, stream) => { + conn.exec(dockerCommand, { pty: { cols, rows } }, (err, stream) => { if (err) { console.error("SSH exec error:", err); ws.close(); @@ -123,7 +132,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( } else { command = message; } - stream.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + stream.setWindow(resize.rows, resize.cols, 0, 0); + return; + } + stream.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; @@ -161,7 +176,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( const ptyProcess = spawn( "docker", ["exec", "-it", "-w", "/", containerId, shell], - {}, + { cols, rows }, ); ptyProcess.onData((data) => { @@ -178,7 +193,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( } else { command = message; } - ptyProcess.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + ptyProcess.resize(resize.cols, resize.rows); + return; + } + ptyProcess.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 90c36855e..ed9495aeb 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -10,7 +10,11 @@ import { Client, type ConnectConfig } from "ssh2"; import { WebSocketServer } from "ws"; import { getDockerHost } from "../utils/docker"; import { canAccessTerminalOverWss } from "./authorize"; -import { setupLocalServerSSHKey } from "./utils"; +import { + parseResizeMessage, + parseTerminalSize, + setupLocalServerSSHKey, +} from "./utils"; const COMMAND_TO_ALLOW_LOCAL_ACCESS = ` # ---------------------------------------- @@ -88,6 +92,10 @@ export const setupTerminalWebSocketServer = ( wssTerm.on("connection", async (ws, req) => { const url = new URL(req.url || "", `http://${req.headers.host}`); const serverId = url.searchParams.get("serverId"); + const { cols, rows } = parseTerminalSize( + url.searchParams.get("cols"), + url.searchParams.get("rows"), + ); const { user, session } = await validateRequest(req); if (!user || !session || !serverId) { ws.close(); @@ -190,7 +198,7 @@ export const setupTerminalWebSocketServer = ( // Clear terminal content once connected ws.send("\x1bc"); - conn.shell({}, (err, stream) => { + conn.shell({ cols, rows }, (err, stream) => { if (err) throw err; stream @@ -216,7 +224,13 @@ export const setupTerminalWebSocketServer = ( } else { command = message; } - stream.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + stream.setWindow(resize.rows, resize.cols, 0, 0); + return; + } + stream.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; diff --git a/apps/dokploy/server/wss/utils.ts b/apps/dokploy/server/wss/utils.ts index 346093e1b..fb47f08fe 100644 --- a/apps/dokploy/server/wss/utils.ts +++ b/apps/dokploy/server/wss/utils.ts @@ -63,6 +63,48 @@ export const isValidShell = (shell: string): boolean => { return allowedShells.includes(shell); }; +/** + * Clamps cols/rows read from the client's initial connection query params + * to a sane range, falling back to the standard 80x24 default. + */ +export const parseTerminalSize = ( + colsParam: string | null, + rowsParam: string | null, +) => { + const cols = Number(colsParam); + const rows = Number(rowsParam); + return { + cols: Number.isInteger(cols) && cols > 0 && cols <= 1000 ? cols : 80, + rows: Number.isInteger(rows) && rows > 0 && rows <= 1000 ? rows : 24, + }; +}; + +/** + * Terminal input and resize control messages share the same websocket + * channel. Resize messages are JSON envelopes; regular keystrokes never + * start with "{", so this distinguishes them without an extra channel. + */ +export const parseResizeMessage = (data: string) => { + if (!data.startsWith("{")) return null; + try { + const parsed = JSON.parse(data); + if ( + parsed?.type === "resize" && + Number.isInteger(parsed.cols) && + Number.isInteger(parsed.rows) && + parsed.cols > 0 && + parsed.cols <= 1000 && + parsed.rows > 0 && + parsed.rows <= 1000 + ) { + return { cols: parsed.cols, rows: parsed.rows }; + } + } catch { + return null; + } + return null; +}; + export const getShell = () => { if (IS_CLOUD) { return "NO_AVAILABLE"; From 41d355c7dc7fd1d1945a1720e57c4cbc7d7e634a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 18 Aug 2026 02:19:41 -0600 Subject: [PATCH 17/27] 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 "" does not exist' failure whenever the db name differs from the username. --- apps/dokploy/server/api/routers/mariadb.ts | 20 ++++++++++---------- apps/dokploy/server/api/routers/mongo.ts | 20 ++++++++++---------- apps/dokploy/server/api/routers/mysql.ts | 20 ++++++++++---------- apps/dokploy/server/api/routers/postgres.ts | 20 ++++++++++---------- apps/dokploy/server/api/routers/redis.ts | 20 ++++++++++---------- 5 files changed, 50 insertions(+), 50 deletions(-) diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 4366e7393..98932f8af 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -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 = diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index de38435b4..42b410785 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -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 diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 70c40520a..c315ec5d4 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -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 = diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 9b1b3330a..97e398123 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -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 diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index 747cce4a3..fb0d8301c 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -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 From 0ea7de7d31d3a958a60e318be198960bf66de5c0 Mon Sep 17 00:00:00 2001 From: andwati Date: Tue, 18 Aug 2026 14:18:39 +0300 Subject: [PATCH 18/27] 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 --- .../dashboard/settings/tags/handle-tag.tsx | 23 ++++++++++++++++--- .../components/shared/tag-selector.tsx | 8 ++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx index 343e0f93b..bb26ce863 100644 --- a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx +++ b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx @@ -53,9 +53,14 @@ type Tag = z.infer; interface HandleTagProps { tagId?: string; + /** + * Called with the new tag's id after it is created, so callers embedding + * this dialog (e.g. the tag selector) can select the tag right away. + */ + onCreated?: (tagId: string) => void; } -export const HandleTag = ({ tagId }: HandleTagProps) => { +export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const colorInputRef = useRef(null); @@ -101,8 +106,11 @@ export const HandleTag = ({ tagId }: HandleTagProps) => { color: data.color, tagId: tagId || "", }) - .then(async () => { + .then(async (result) => { await utils.tag.all.invalidate(); + if (!tagId && result?.tagId) { + onCreated?.(result.tagId); + } toast.success(tagId ? "Tag Updated" : "Tag Created"); setIsOpen(false); form.reset(); @@ -139,9 +147,18 @@ export const HandleTag = ({ tagId }: HandleTagProps) => { {isError && {error?.message}}
+ {/* + * This dialog can be rendered inside another form (e.g. the tag + * selector in the project dialog). React propagates events through the + * React tree, not the DOM tree, so without stopPropagation submitting + * this form would also submit the outer one. + */} { + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} className="grid w-full gap-4" >
- + { + if (!selectedTags.includes(tagId)) { + onTagsChange([...selectedTags, tagId]); + } + }} + />
From 76c2416613fd947f179c68d6efd9215904c38304 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 18 Aug 2026 14:20:26 -0600 Subject: [PATCH 19/27] fix: correct switch thumb translate-x for checked state Fixes #5120 --- apps/dokploy/components/ui/switch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/components/ui/switch.tsx b/apps/dokploy/components/ui/switch.tsx index 9080b035c..d9962a815 100644 --- a/apps/dokploy/components/ui/switch.tsx +++ b/apps/dokploy/components/ui/switch.tsx @@ -22,7 +22,7 @@ function Switch({ > ); From 5d1731c54f2e005f73a7dba7b6cb77c9405a0634 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:21:11 -0600 Subject: [PATCH 20/27] Merge pull request #5121 from Dokploy/fix/issue-5120-switch-thumb-position fix: correct switch thumb translate-x for checked state (cherry picked from commit e15b38be38686e23d8626b42e1388fce458c9b29) [skip ci] --- apps/dokploy/components/ui/switch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/components/ui/switch.tsx b/apps/dokploy/components/ui/switch.tsx index 9080b035c..d9962a815 100644 --- a/apps/dokploy/components/ui/switch.tsx +++ b/apps/dokploy/components/ui/switch.tsx @@ -22,7 +22,7 @@ function Switch({ > ); From 65c5ccc7b2b3b61d91621fc1eefae6acd9ac305d Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:22:12 -0600 Subject: [PATCH 21/27] Merge pull request #5118 from andwati/fix/tag-assignment-on-project-create fix: assign tags created from the project dialog (cherry picked from commit cf6f5e39d220b24dc8b00fafeceb5f5744eed9c7) [skip ci] --- .../dashboard/settings/tags/handle-tag.tsx | 23 ++++++++++++++++--- .../components/shared/tag-selector.tsx | 8 ++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx index 343e0f93b..bb26ce863 100644 --- a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx +++ b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx @@ -53,9 +53,14 @@ type Tag = z.infer; interface HandleTagProps { tagId?: string; + /** + * Called with the new tag's id after it is created, so callers embedding + * this dialog (e.g. the tag selector) can select the tag right away. + */ + onCreated?: (tagId: string) => void; } -export const HandleTag = ({ tagId }: HandleTagProps) => { +export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const colorInputRef = useRef(null); @@ -101,8 +106,11 @@ export const HandleTag = ({ tagId }: HandleTagProps) => { color: data.color, tagId: tagId || "", }) - .then(async () => { + .then(async (result) => { await utils.tag.all.invalidate(); + if (!tagId && result?.tagId) { + onCreated?.(result.tagId); + } toast.success(tagId ? "Tag Updated" : "Tag Created"); setIsOpen(false); form.reset(); @@ -139,9 +147,18 @@ export const HandleTag = ({ tagId }: HandleTagProps) => { {isError && {error?.message}} + {/* + * This dialog can be rendered inside another form (e.g. the tag + * selector in the project dialog). React propagates events through the + * React tree, not the DOM tree, so without stopPropagation submitting + * this form would also submit the outer one. + */} { + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} className="grid w-full gap-4" >
- + { + if (!selectedTags.includes(tagId)) { + onTagsChange([...selectedTags, tagId]); + } + }} + />
From 7a0efc7aa4babe7112470838e17709c894920313 Mon Sep 17 00:00:00 2001 From: Dokploy Bot Date: Tue, 18 Aug 2026 20:53:40 +0000 Subject: [PATCH 22/27] chore: release v0.30.1 --- apps/dokploy/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 900bc7a09..8b70dd90c 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.30.0", + "version": "v0.30.1", "private": true, "license": "Apache-2.0", "type": "module", From d183018201c05c578a1dc26da126dc0f58c6ecc8 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 18 Aug 2026 15:20:55 -0600 Subject: [PATCH 23/27] 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. --- packages/server/src/utils/builders/compose.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 168de7fda..31e610847 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -7,6 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain"; import { encodeBase64, getEnvironmentVariablesObject, + prepareEnvironmentVariables, prepareEnvironmentVariablesForFile, } from "../docker/utils"; import { withResolvedVaultRefs } from "../vault"; @@ -157,10 +158,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); From 8f42dced380f49c705867a4500d3d8ac7038827a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Tue, 18 Aug 2026 17:21:08 -0400 Subject: [PATCH 24/27] Merge pull request #5079 from NoiceHax/fix/issue-4074 fix(github-webhook): apply preview limit only to new previews (cherry picked from commit fc6f4d9af8a850cac815b131b6b4d4055bc00348) [skip ci] --- .../deploy/github-webhook-handler.test.ts | 161 +++++++++++++++++- apps/dokploy/pages/api/deploy/github.ts | 13 +- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index e25bd425e..6b61c97d7 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -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 = {}, + ): Record => ({ + 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); + }); +}); diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index 4fc75fc50..2273ac3bc 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -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, From fa3cab8bcc5b8ea716a34f4a265610e7f5d5780f Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:22:14 -0600 Subject: [PATCH 25/27] 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 31ddd7fe1aa7753c446b7ec1f81b93f18c1ae1c0) [skip ci] --- packages/server/src/utils/builders/compose.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 168de7fda..31e610847 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -7,6 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain"; import { encodeBase64, getEnvironmentVariablesObject, + prepareEnvironmentVariables, prepareEnvironmentVariablesForFile, } from "../docker/utils"; import { withResolvedVaultRefs } from "../vault"; @@ -157,10 +158,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); From 8c2d4d5c19a422135d5993fc5beca5e63b73470e Mon Sep 17 00:00:00 2001 From: Dokploy Bot Date: Tue, 18 Aug 2026 21:32:24 +0000 Subject: [PATCH 26/27] chore: release v0.30.2 --- apps/dokploy/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 8b70dd90c..40b9ca156 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.30.1", + "version": "v0.30.2", "private": true, "license": "Apache-2.0", "type": "module", From 5fa963fe87796b6e12b402bec72d26672d39c3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Tue, 18 Aug 2026 18:40:08 -0400 Subject: [PATCH 27/27] Merge pull request #5082 from bestmaa/fix/volume-backup-restart fix(volume-backups): restart services after backup failure (cherry picked from commit 3054cf53be2a5f6cf48943e3406b476133ccc53f) [skip ci] --- .../backups/volume-backup-restart.test.ts | 82 +++++++++++++++++++ .../server/src/utils/volume-backups/backup.ts | 75 +++++++++++++---- 2 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 apps/dokploy/__test__/backups/volume-backup-restart.test.ts diff --git a/apps/dokploy/__test__/backups/volume-backup-restart.test.ts b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts new file mode 100644 index 000000000..ba2fc081e --- /dev/null +++ b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts @@ -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", + ]); + }); +}); diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index a9240f2dd..3172c9c21 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -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>, ): 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, + }), + ); } };