diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 000000000..77f39858f
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,22 @@
+{
+ "worktree": {
+ "baseRef": "fresh"
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "EnterWorktree",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
+ },
+ {
+ "type": "command",
+ "command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md
new file mode 100644
index 000000000..226083304
--- /dev/null
+++ b/.claude/skills/fix-issue/SKILL.md
@@ -0,0 +1,55 @@
+---
+name: fix-issue
+description: Implement a GitHub issue with reproduction and verification
+allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
+---
+
+The issue number is passed as $1.
+
+## Instance
+
+No instance is running yet — start your own, isolated to this worktree:
+
+1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
+2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
+ background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
+ until it answers (usually ~10-15s).
+3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
+
+Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
+`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
+discovered mid-session. If those tools are unavailable or point at the wrong
+instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
+to relaunch with `DOKPLOY_BASE_URL` exported first.
+
+## Tools
+
+- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
+ state (create a project, an app, an env var) and to verify backend behavior.
+ Search for the tool you need; they are not all loaded upfront.
+- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
+ a user would see or click.
+
+Pick by where the bug lives, not by convenience:
+
+- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
+ The API returning correct data proves nothing here.
+- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
+ A green screenshot proves nothing here.
+- Unclear → do both.
+
+Use the MCP to reach the state you need quickly, then verify in the UI. Do not
+click through ten screens to create a project the API can create in one call.
+
+## Steps
+
+1. Run `gh issue view $1` and read the full issue, including comments.
+2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
+ comment on the issue explaining what you tried and STOP.
+ Do not implement anything.
+3. Implement the fix. Keep the change minimal and scoped to the issue.
+4. Run `pnpm test`, then re-run the same reproduction from step 2.
+5. Only if both pass: commit and run `gh pr create`. The PR description must
+ include the before/after reproduction steps and reference the issue.
+
+Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
\ No newline at end of file
diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml
index 1c228d27e..08d6b8a5b 100644
--- a/.github/workflows/dokploy.yml
+++ b/.github/workflows/dokploy.yml
@@ -140,6 +140,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
+ npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -151,6 +152,7 @@ jobs:
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
@@ -164,6 +166,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
+ target_commitish: ${{ github.sha }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
@@ -180,71 +183,82 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10.22.0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24.4.0
+ cache: pnpm
+
+ - name: Generate OpenAPI specification
+ run: |
+ pnpm install --frozen-lockfile
+ pnpm generate:openapi
+
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
- jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
+ jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
- npm install -g pnpm
+ cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
pnpm install
- pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
- git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
+ git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
- echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
+ echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
- jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
+ jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
- npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
- git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
+ git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
- echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
+ echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
- jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
+ jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
- npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
- git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
+ git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
- echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
+ echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
index cfddad7b2..48589575f 100644
--- a/.github/workflows/format.yml
+++ b/.github/workflows/format.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
@@ -19,4 +19,4 @@ jobs:
- name: Run Biome formatter
run: biome format --write
- - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
+ - uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml
new file mode 100644
index 000000000..c85e118d6
--- /dev/null
+++ b/.github/workflows/hotfix-cherry-pick.yml
@@ -0,0 +1,49 @@
+name: Hotfix Cherry-Pick
+
+on:
+ pull_request_target:
+ types: [closed, labeled]
+
+concurrency:
+ group: hotfix-to-main
+ cancel-in-progress: false
+
+jobs:
+ cherry-pick:
+ if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout main
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ fetch-depth: 0
+ token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
+
+ - name: Cherry-pick fix to main
+ run: |
+ git config user.name "Dokploy Bot"
+ git config user.email "bot@dokploy.com"
+ SHA="${{ github.event.pull_request.merge_commit_sha }}"
+ if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
+ git cherry-pick -x -m 1 "$SHA"
+ else
+ git cherry-pick -x "$SHA"
+ fi
+ git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
+ git push origin main
+
+ - name: Sync cherry-pick back into canary
+ run: |
+ git config user.name "Dokploy Bot"
+ git config user.email "bot@dokploy.com"
+ git fetch origin canary main
+ git checkout -B canary origin/canary
+ if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then
+ git merge --abort
+ echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR."
+ exit 1
+ fi
+ git push origin canary
diff --git a/.github/workflows/hotfix-release.yml b/.github/workflows/hotfix-release.yml
new file mode 100644
index 000000000..7e473b3d2
--- /dev/null
+++ b/.github/workflows/hotfix-release.yml
@@ -0,0 +1,28 @@
+name: Hotfix Release
+
+on:
+ workflow_dispatch:
+
+concurrency:
+ group: hotfix-to-main
+ cancel-in-progress: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout main
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
+
+ - name: Bump patch version and push
+ run: |
+ git config user.name "Dokploy Bot"
+ git config user.email "bot@dokploy.com"
+ CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
+ NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
+ sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
+ git commit -am "chore: release ${NEW}"
+ git push origin main
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 2ad24fc0c..227022a90 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -14,9 +14,9 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v5
with:
node-version: 24.4.0
cache: "pnpm"
diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml
new file mode 100644
index 000000000..f73b7276e
--- /dev/null
+++ b/.github/workflows/upgrade-integration-test.yml
@@ -0,0 +1,437 @@
+# Upgrade Integration Test
+#
+# Tests that Dokploy can upgrade from version A to version B while keeping
+# user projects (Postgres, MongoDB, two web apps, one static site) alive.
+#
+# Generates upgrade pairs: each stable tag in [floor_version, target_version)
+# is paired with target_version. If target_version is empty, the highest tag
+# available on Docker Hub is used as the target.
+#
+# Only triggered manually to avoid burning Actions minutes.
+
+name: Upgrade Integration Test
+
+on:
+ workflow_dispatch:
+ inputs:
+ floor_version:
+ description: 'Oldest version tag to include in pairs (e.g. v0.29.4)'
+ required: false
+ default: 'v0.29.4'
+ target_version:
+ description: 'Target version to upgrade to (version B). Leave empty to use the highest available.'
+ required: false
+ default: ''
+
+env:
+ DOKPLOY_IMAGE: dokploy/dokploy
+ DOKPLOY_SERVICE: dokploy
+ DOKPLOY_PORT: 3000
+
+# ──────────────────────────────────────────────────────────────────────────────
+jobs:
+
+ # ── 1. Build the matrix ─────────────────────────────────────────────────────
+ build-matrix:
+ name: Build upgrade pair matrix
+ runs-on: ubuntu-latest
+ outputs:
+ pairs: ${{ steps.pairs.outputs.pairs }}
+
+ steps:
+ - name: Generate pairs
+ id: pairs
+ env:
+ FLOOR: ${{ inputs.floor_version }}
+ TARGET: ${{ inputs.target_version }}
+ run: |
+ set -euo pipefail
+
+ # semver "a >= b" comparison helper (vX.Y.Z, strips leading v)
+ ge() {
+ [ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ]
+ }
+
+ # Fetch all semver tags from Docker Hub (dokploy/dokploy)
+ ALL_TAGS=$(curl -fsSL \
+ "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \
+ jq -r '.results[].name' | \
+ grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
+ sort -V)
+
+ echo "All tags found:"
+ echo "$ALL_TAGS"
+
+ # Target version (version B): explicit input, else highest available.
+ if [ -n "$TARGET" ]; then
+ TO="$TARGET"
+ echo "Using user-supplied target version: $TO"
+ else
+ TO=$(echo "$ALL_TAGS" | tail -n1)
+ echo "No target supplied; using highest available: $TO"
+ fi
+
+ # Floor semver components
+ FLOOR_CLEAN="${FLOOR#v}"
+ IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN"
+
+ # Each tag in [FLOOR, TO) → TO
+ PAIRS='[]'
+ while read -r TAG; do
+ [ -z "$TAG" ] && continue
+ # skip tags above-or-equal to the target (incl. the target itself)
+ ge "$TAG" "$TO" && continue
+ TAG_CLEAN="${TAG#v}"
+ IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN"
+ if [ "$T_MAJ" -gt "$F_MAJ" ] || \
+ { [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \
+ { [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then
+ PAIRS=$(echo "$PAIRS" | jq -c \
+ --arg f "$TAG" --arg t "$TO" \
+ '. + [{"from":$f,"to":$t}]')
+ fi
+ done <<< "$ALL_TAGS"
+
+ COUNT=$(echo "$PAIRS" | jq 'length')
+ echo "Total pairs: $COUNT"
+ echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"'
+
+ echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT"
+
+
+ # ── 2. Run one upgrade test per pair ────────────────────────────────────────
+ upgrade-test:
+ name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}"
+ needs: build-matrix
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }}
+
+ steps:
+
+ # ── Environment setup ──────────────────────────────────────────────────
+ - name: Free disk space
+ run: |
+ sudo rm -rf \
+ /usr/share/dotnet /opt/ghc /usr/local/share/boost \
+ "$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \
+ /usr/local/share/chromium /opt/hostedtoolcache
+ docker system prune -af --volumes
+ df -h
+
+ # ── Install Dokploy directly at VERSION A ──────────────────────────────
+ # install.sh:
+ # • requires root (run via sudo bash)
+ # • respects DOKPLOY_VERSION → installs that tag directly
+ # (so we don't need a separate "downgrade" step that would risk
+ # running B's migrations on A's expected schema)
+ # • respects ADVERTISE_ADDR → skip the external IP lookup
+ # • initializes Docker Swarm + dokploy-network itself
+ - name: Install Dokploy at VERSION A (${{ matrix.pair.from }})
+ run: |
+ curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh
+ chmod +x /tmp/install.sh
+ sudo -E env \
+ DOKPLOY_VERSION="${{ matrix.pair.from }}" \
+ ADVERTISE_ADDR="127.0.0.1" \
+ bash /tmp/install.sh
+
+ - name: Wait for dokploy service to converge on ${{ matrix.pair.from }}
+ run: |
+ echo "Waiting for 'dokploy' Swarm service to reach 1/1..."
+ timeout 240 bash -c '
+ until docker service ls --filter name=dokploy \
+ --format "{{.Name}} {{.Replicas}}" \
+ | grep "^dokploy " | grep -q " 1/1"; do
+ sleep 4
+ done
+ '
+ docker service ls
+ echo "✅ Service running on ${{ matrix.pair.from }}"
+
+ - name: Wait for Dokploy API to accept requests
+ run: |
+ timeout 180 bash -c '
+ until curl -sf -o /dev/null \
+ "http://localhost:${{ env.DOKPLOY_PORT }}"; do
+ sleep 3
+ done
+ '
+ echo "✅ Dokploy API is up"
+
+ # ── Bootstrap admin user ───────────────────────────────────────────────
+ - name: Register first admin user
+ id: auth
+ run: |
+ COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt"
+ : > "$COOKIE_JAR"
+ chmod 600 "$COOKIE_JAR"
+ echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT"
+
+ # better-auth: sign-up/email (allowed only before any owner exists)
+ set -x
+ curl -sS -i -X POST \
+ "http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \
+ -H "Content-Type: application/json" \
+ -c "$COOKIE_JAR" -b "$COOKIE_JAR" \
+ -d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \
+ | tee /tmp/signup.out
+ set +x
+
+ # Verify we got a session cookie
+ if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then
+ echo "⚠️ No session cookie matched expected name; jar contents:"
+ cat "$COOKIE_JAR"
+ fi
+
+ # ── Create test resources ──────────────────────────────────────────────
+ - name: Create project, databases and applications
+ id: create
+ env:
+ BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
+ COOKIE: ${{ steps.auth.outputs.cookie_jar }}
+ run: |
+ set -euo pipefail
+
+ # --- tRPC POST helper ---
+ trpc_mut() {
+ curl -sf -X POST "$BASE/api/trpc/$1" \
+ -H "Content-Type: application/json" \
+ -b "$COOKIE" -c "$COOKIE" \
+ -d "{\"json\":$2}"
+ }
+
+ # --- Project ---
+ PROJECT=$(trpc_mut project.create \
+ '{"name":"ci-upgrade-test","description":"CI upgrade integration test"}')
+ echo "project.create → $PROJECT"
+
+ # project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}}
+ PROJECT_ID=$(echo "$PROJECT" | jq -r \
+ '.result.data.json.project.projectId // .result.data.json.projectId // empty')
+ ENV_ID=$(echo "$PROJECT" | jq -r \
+ '.result.data.json.environment.environmentId // empty')
+
+ if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then
+ echo "❌ Could not extract projectId from project.create response:"
+ echo "$PROJECT" | jq .
+ exit 1
+ fi
+ if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then
+ echo "❌ Could not extract environmentId — this version may not support environments."
+ echo " project.create response: $PROJECT"
+ exit 1
+ fi
+
+ echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
+ echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT"
+ echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID"
+
+ # --- PostgreSQL 15 ---
+ PG=$(trpc_mut postgres.create \
+ "{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\
+ \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
+ \"databasePassword\":\"CiPg1pass\",\
+ \"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}")
+ echo "postgres.create → $PG"
+ PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId')
+ # deploy (not start) — deploy creates the Swarm service; start only
+ # scales an already-deployed service and 500s on a fresh resource.
+ trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null
+ echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT"
+
+ # --- MongoDB 7.0 ---
+ MG=$(trpc_mut mongo.create \
+ "{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\
+ \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
+ \"databasePassword\":\"CiMg1pass\",\
+ \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}")
+ echo "mongo.create → $MG"
+ MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId')
+ trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null
+ echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT"
+
+ # --- Docker-image application helper ---
+ make_app() {
+ local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3
+ APP=$(trpc_mut application.create \
+ "{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}")
+ APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId')
+ trpc_mut application.saveDockerProvider \
+ "{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\
+ \"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \
+ > /dev/null
+ trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \
+ > /dev/null
+ echo "$APP_ID"
+ }
+
+ # Static site (nginx)
+ APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine")
+ echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT"
+
+ # Node.js hello-world (echo server image — small, no args needed)
+ APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest")
+ echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT"
+
+ # Go HTTP server (traefik/whoami is a tiny Go binary, port 80)
+ APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest")
+ echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT"
+
+ echo "✅ All resources created"
+
+ # ── Pre-upgrade health check ───────────────────────────────────────────
+ - name: Wait for all services → 'done' (pre-upgrade)
+ env:
+ BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
+ COOKIE: ${{ steps.auth.outputs.cookie_jar }}
+ PG_ID: ${{ steps.create.outputs.pg_id }}
+ MG_ID: ${{ steps.create.outputs.mg_id }}
+ APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
+ APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
+ APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
+ run: |
+ wait_done() {
+ local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
+ echo "Waiting for $NAME to reach 'done'..."
+ timeout 360 bash -c "
+ until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \
+ --data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \
+ -b '$COOKIE' | \
+ jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \
+ = 'done' ]; do
+ sleep 5
+ done
+ "
+ echo "✅ $NAME is done"
+ }
+
+ wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus
+ wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus
+ wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus
+ wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus
+ wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus
+
+ - name: Assert Docker Swarm services healthy (pre-upgrade)
+ run: |
+ echo "=== docker service ls ==="
+ docker service ls
+
+ FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
+ grep -E '^ci-' | grep -v ' 1/1' || true)
+ if [ -n "$FAIL" ]; then
+ echo "❌ User services not healthy before upgrade:"
+ echo "$FAIL"
+ exit 1
+ fi
+ echo "✅ All user services healthy before upgrade"
+
+ # ── Upgrade ────────────────────────────────────────────────────────────
+ - name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }})
+ run: |
+ docker service update \
+ --image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \
+ --force \
+ "${{ env.DOKPLOY_SERVICE }}"
+
+ echo "Waiting for service to converge on ${{ matrix.pair.to }}..."
+ timeout 240 bash -c '
+ until ! docker service inspect dokploy \
+ --format "{{.UpdateStatus.State}}" 2>/dev/null \
+ | grep -q "^updating$"; do
+ sleep 4
+ done
+ until docker service ls --filter name=dokploy \
+ --format "{{.Name}} {{.Replicas}}" \
+ | grep "^dokploy " | grep -q " 1/1"; do
+ sleep 4
+ done
+ '
+ echo "✅ Service running on ${{ matrix.pair.to }}"
+
+ - name: Wait for Dokploy API to respond post-upgrade
+ run: |
+ timeout 180 bash -c '
+ until curl -sf -o /dev/null \
+ "http://localhost:${{ env.DOKPLOY_PORT }}"; do
+ sleep 3
+ done
+ '
+ echo "✅ Dokploy API is up after upgrade"
+
+ # ── Post-upgrade health check ──────────────────────────────────────────
+ - name: Verify all services still healthy (post-upgrade)
+ env:
+ BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
+ COOKIE: ${{ steps.auth.outputs.cookie_jar }}
+ PG_ID: ${{ steps.create.outputs.pg_id }}
+ MG_ID: ${{ steps.create.outputs.mg_id }}
+ APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
+ APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
+ APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
+ run: |
+ check_status() {
+ local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
+ STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \
+ --data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \
+ -b "$COOKIE" | \
+ jq -r ".result.data.json.$STATUS_KEY // \"unknown\"")
+ if [ "$STATUS" != "done" ]; then
+ echo "❌ $NAME status after upgrade: $STATUS"
+ return 1
+ fi
+ echo "✅ $NAME: $STATUS"
+ }
+
+ check_status postgres postgres.one postgresId "$PG_ID" applicationStatus
+ check_status mongo mongo.one mongoId "$MG_ID" applicationStatus
+ check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus
+ check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus
+ check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus
+
+ echo "=== docker service ls (post-upgrade) ==="
+ docker service ls
+
+ FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
+ grep -E '^ci-' | grep -v ' 1/1' || true)
+ if [ -n "$FAIL" ]; then
+ echo "❌ User services not healthy after upgrade:"
+ echo "$FAIL"
+ exit 1
+ fi
+ echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}"
+
+ # ── Diagnostics on failure ─────────────────────────────────────────────
+ - name: Dump state on failure
+ if: failure()
+ run: |
+ echo "=== docker service ls ===" && docker service ls || true
+ echo "=== dokploy service tasks ===" && \
+ docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true
+ echo "=== dokploy logs (last 200) ===" && \
+ docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true
+ echo "=== install.sh tail ===" && \
+ tail -100 /tmp/install.sh 2>&1 || true
+ echo "=== signup response ===" && \
+ cat /tmp/signup.out 2>&1 || true
+ echo "=== disk usage ===" && df -h
+
+ # ── Job summary ────────────────────────────────────────────────────────
+ - name: Write job summary
+ if: always()
+ run: |
+ STATUS="${{ job.status }}"
+ ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
+ cat >> "$GITHUB_STEP_SUMMARY" < {
+ it("rejects an empty name", () => {
+ const result = apiKeyNameSchema.safeParse("");
+ expect(result.success).toBe(false);
+ });
+
+ it("accepts a name at the maximum length", () => {
+ const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
+ const result = apiKeyNameSchema.safeParse(name);
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects a name over the maximum length instead of passing it to better-auth", () => {
+ const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
+ const result = apiKeyNameSchema.safeParse(name);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues[0]?.message).toBe(
+ `Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
+ );
+ }
+ });
+});
diff --git a/apps/dokploy/__test__/api/session-management.test.ts b/apps/dokploy/__test__/api/session-management.test.ts
new file mode 100644
index 000000000..48f7eb257
--- /dev/null
+++ b/apps/dokploy/__test__/api/session-management.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { z } from "zod";
+
+const revokeSessionSchema = z.object({
+ sessionId: z.string(),
+});
+
+describe("revokeSession input validation", () => {
+ it("accepts a valid session id", () => {
+ const result = revokeSessionSchema.safeParse({ sessionId: "abc123" });
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects missing sessionId", () => {
+ const result = revokeSessionSchema.safeParse({});
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects non-string sessionId", () => {
+ const result = revokeSessionSchema.safeParse({ sessionId: 123 });
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/apps/dokploy/__test__/backups/db-backup-restore-injection.test.ts b/apps/dokploy/__test__/backups/db-backup-restore-injection.test.ts
new file mode 100644
index 000000000..d48644c95
--- /dev/null
+++ b/apps/dokploy/__test__/backups/db-backup-restore-injection.test.ts
@@ -0,0 +1,106 @@
+import { execSync } from "node:child_process";
+import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
+import {
+ getLibsqlBackupCommand,
+ getMariadbBackupCommand,
+ getMongoBackupCommand,
+ getMysqlBackupCommand,
+ getPostgresBackupCommand,
+} from "@dokploy/server/utils/backups/utils";
+import {
+ getMariadbRestoreCommand,
+ getMongoRestoreCommand,
+ getMysqlRestoreCommand,
+ getPostgresRestoreCommand,
+} from "@dokploy/server/utils/restore/utils";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
+// exports the -e VAR=val pairs, and runs the inner `sh -c
+
+ >
+ );
+};
+
+/**
+ * The HubSpot script is loaded app-wide for marketing tracking, but the
+ * conversations (chat) widget stays restricted to startup plan customers.
+ */
+export const useHubSpotChat = (enabled: boolean) => {
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+ const loadWidget = () => {
+ window.HubSpotConversations?.widget.load();
+ };
+ if (window.HubSpotConversations) {
+ loadWidget();
+ } else {
+ window.hsConversationsOnReady = [
+ ...(window.hsConversationsOnReady || []),
+ loadWidget,
+ ];
+ }
+ }, [enabled]);
+};
diff --git a/apps/dokploy/components/shared/code-editor.tsx b/apps/dokploy/components/shared/code-editor.tsx
index f0347da60..9640e3428 100644
--- a/apps/dokploy/components/shared/code-editor.tsx
+++ b/apps/dokploy/components/shared/code-editor.tsx
@@ -3,11 +3,16 @@ import {
type Completion,
type CompletionContext,
type CompletionResult,
+ type CompletionSource,
} from "@codemirror/autocomplete";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
-import { StreamLanguage } from "@codemirror/language";
+import {
+ getIndentUnit,
+ indentService,
+ StreamLanguage,
+} from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { search, searchKeymap } from "@codemirror/search";
@@ -98,6 +103,27 @@ const dockerComposeServiceOptions = [
},
}));
+// The indentNodeProp shipped with @codemirror/lang-yaml computes wrong
+// column-based indents on Enter (odd/inconsistent amounts, see #4650), so
+// indentation is resolved line-based here: keep the current indent, align
+// list-entry keys after the dash marker, and go one unit deeper after a
+// line that opens a block (ending in ":", "|" or ">").
+const yamlIndent = indentService.of((context, pos) => {
+ const line = context.state.doc.lineAt(pos);
+ const before = context.state.doc.sliceString(line.from, pos);
+ const indent = /^ */.exec(before)?.[0].length ?? 0;
+ const trimmed = before.trim();
+ if (!trimmed || trimmed.startsWith("#")) {
+ return indent;
+ }
+ const base =
+ trimmed.startsWith("- ") && /:\s/.test(trimmed) ? indent + 2 : indent;
+ if (/[:|>]$/.test(trimmed)) {
+ return base + getIndentUnit(context.state);
+ }
+ return base;
+});
+
function dockerComposeComplete(
context: CompletionContext,
): CompletionResult | null {
@@ -135,6 +161,7 @@ interface Props extends ReactCodeMirrorProps {
language?: "yaml" | "json" | "properties" | "shell" | "css";
lineWrapping?: boolean;
lineNumbers?: boolean;
+ completionSource?: CompletionSource;
}
export const CodeEditor = ({
@@ -142,6 +169,7 @@ export const CodeEditor = ({
wrapperClassName,
language = "yaml",
lineNumbers = true,
+ completionSource,
...props
}: Props) => {
const { resolvedTheme } = useTheme();
@@ -160,7 +188,7 @@ export const CodeEditor = ({
search(),
keymap.of(searchKeymap),
language === "yaml"
- ? yaml()
+ ? [yamlIndent, yaml()]
: language === "json"
? json()
: language === "css"
@@ -175,11 +203,15 @@ export const CodeEditor = ({
languageData: { commentTokens: { line: "#" } },
}),
props.lineWrapping ? EditorView.lineWrapping : [],
- language === "yaml"
+ completionSource
? autocompletion({
- override: [dockerComposeComplete],
+ override: [completionSource],
})
- : [],
+ : language === "yaml"
+ ? autocompletion({
+ override: [dockerComposeComplete],
+ })
+ : [],
]}
{...props}
editable={!props.disabled}
diff --git a/apps/dokploy/components/shared/drawer-logs.tsx b/apps/dokploy/components/shared/drawer-logs.tsx
index 38f8b5db4..301a0fe0d 100644
--- a/apps/dokploy/components/shared/drawer-logs.tsx
+++ b/apps/dokploy/components/shared/drawer-logs.tsx
@@ -47,7 +47,7 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => {
onClose();
}}
>
-
+
Deployment Logs
Details of the request log entry.
diff --git a/apps/dokploy/components/shared/env-autocomplete.ts b/apps/dokploy/components/shared/env-autocomplete.ts
new file mode 100644
index 000000000..0b6039976
--- /dev/null
+++ b/apps/dokploy/components/shared/env-autocomplete.ts
@@ -0,0 +1,193 @@
+import {
+ type Completion,
+ type CompletionContext,
+ type CompletionResult,
+ startCompletion,
+} from "@codemirror/autocomplete";
+import type { EditorView } from "@codemirror/view";
+import { useCallback } from "react";
+import { api } from "@/utils/api";
+
+interface Options {
+ projectEnv?: string | null;
+ environmentEnv?: string | null;
+ includeShared?: boolean;
+ projectId?: string;
+ environmentId?: string;
+}
+
+const parseKeys = (env?: string | null) =>
+ (env ?? "")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#") && line.includes("="))
+ .map((line) => line.slice(0, line.indexOf("=")).trim())
+ .filter((key) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
+
+const applyAndContinue = (
+ view: EditorView,
+ completion: Completion,
+ from: number,
+ to: number,
+) => {
+ view.dispatch({
+ changes: { from, to, insert: completion.label },
+ selection: { anchor: from + completion.label.length },
+ });
+ startCompletion(view);
+};
+
+const applyAndClose = (
+ view: EditorView,
+ completion: Completion,
+ from: number,
+ to: number,
+) => {
+ const alreadyClosed = view.state.sliceDoc(to, to + 2) === "}}";
+ const insert = completion.label + (alreadyClosed ? "" : "}}");
+ view.dispatch({
+ changes: { from, to, insert },
+ selection: {
+ anchor: from + completion.label.length + 2,
+ },
+ });
+};
+
+export const useEnvCompletionSource = ({
+ projectEnv,
+ environmentEnv,
+ includeShared = true,
+ projectId,
+ environmentId,
+}: Options = {}) => {
+ const { data: allProviders } = api.vaultProvider.all.useQuery();
+ const utils = api.useUtils();
+ const providers = projectId
+ ? allProviders?.filter((provider) =>
+ provider.assignments?.some(
+ (assignment) =>
+ assignment.projectId === projectId &&
+ (assignment.environmentIds.length === 0 ||
+ !environmentId ||
+ assignment.environmentIds.includes(environmentId)),
+ ),
+ )
+ : [];
+
+ return useCallback(
+ async (context: CompletionContext): Promise => {
+ const match = context.matchBefore(/\$\{\{[^}\n]*$/);
+ if (!match) return null;
+
+ const inner = match.text.slice(3);
+ const innerFrom = match.from + 3;
+
+ const vaultSecret = /^vault\.([a-zA-Z0-9_-]+)\.([^}\n]*)$/.exec(inner);
+ if (vaultSecret) {
+ const provider = providers?.find((p) => p.name === vaultSecret[1]);
+ if (!provider || !projectId) return null;
+ const names = await utils.vaultProvider.listSecretNames
+ .fetch({
+ vaultProviderId: provider.vaultProviderId,
+ projectId,
+ environmentId,
+ })
+ .catch(() => [] as string[]);
+ return {
+ from: innerFrom + `vault.${vaultSecret[1]}.`.length,
+ options: names.map((name) => ({
+ label: name,
+ type: "variable",
+ apply: applyAndClose,
+ })),
+ validFor: /^[^}\n]*$/,
+ };
+ }
+
+ const vaultProviderPrefix = /^vault\.([a-zA-Z0-9_-]*)$/.exec(inner);
+ if (vaultProviderPrefix) {
+ return {
+ from: innerFrom + "vault.".length,
+ options: (providers ?? []).map((provider) => ({
+ label: `${provider.name}.`,
+ detail: provider.providerType,
+ type: "namespace",
+ apply: applyAndContinue,
+ })),
+ validFor: /^[a-zA-Z0-9_-]*$/,
+ };
+ }
+
+ if (includeShared) {
+ const shared: [RegExp, string, string | null | undefined][] = [
+ [/^project\.([^}\n]*)$/, "project.", projectEnv],
+ [/^environment\.([^}\n]*)$/, "environment.", environmentEnv],
+ ];
+ for (const [regex, prefix, env] of shared) {
+ if (regex.test(inner)) {
+ return {
+ from: innerFrom + prefix.length,
+ options: parseKeys(env).map((key) => ({
+ label: key,
+ type: "variable",
+ apply: applyAndClose,
+ })),
+ validFor: /^[A-Za-z0-9_]*$/,
+ };
+ }
+ }
+ }
+
+ if (!inner.includes(".")) {
+ const options: Completion[] = [];
+ if (includeShared) {
+ options.push(
+ {
+ label: "project.",
+ type: "namespace",
+ info: "Shared project variables",
+ apply: applyAndContinue,
+ },
+ {
+ label: "environment.",
+ type: "namespace",
+ info: "Shared environment variables",
+ apply: applyAndContinue,
+ },
+ );
+ }
+ options.push({
+ label: "vault.",
+ type: "namespace",
+ info: "Secrets from an external vault provider",
+ apply: applyAndContinue,
+ });
+ if (includeShared) {
+ for (const key of parseKeys(context.state.doc.toString())) {
+ options.push({
+ label: key,
+ type: "variable",
+ apply: applyAndClose,
+ });
+ }
+ }
+ return {
+ from: innerFrom,
+ options,
+ validFor: /^[A-Za-z0-9_.-]*$/,
+ };
+ }
+
+ return null;
+ },
+ [
+ providers,
+ projectEnv,
+ environmentEnv,
+ includeShared,
+ projectId,
+ environmentId,
+ utils,
+ ],
+ );
+};
diff --git a/apps/dokploy/components/shared/tag-selector.tsx b/apps/dokploy/components/shared/tag-selector.tsx
index 036173ded..ab2d52504 100644
--- a/apps/dokploy/components/shared/tag-selector.tsx
+++ b/apps/dokploy/components/shared/tag-selector.tsx
@@ -147,7 +147,13 @@ export function TagSelector({
})}
-
+ {
+ if (!selectedTags.includes(tagId)) {
+ onTagsChange([...selectedTags, tagId]);
+ }
+ }}
+ />
diff --git a/apps/dokploy/components/shared/vault-import-dialog.tsx b/apps/dokploy/components/shared/vault-import-dialog.tsx
new file mode 100644
index 000000000..43466256b
--- /dev/null
+++ b/apps/dokploy/components/shared/vault-import-dialog.tsx
@@ -0,0 +1,307 @@
+import { DownloadIcon, Loader2 } from "lucide-react";
+import { useMemo, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+interface Props {
+ projectId?: string;
+ environmentId?: string;
+ currentEnv: string;
+ onImport: (nextEnv: string) => void;
+}
+
+const normalizeSecretName = (secretName: string) => {
+ const normalized = secretName
+ .replace(/[^a-zA-Z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "")
+ .toUpperCase();
+ return /^[0-9]/.test(normalized) ? `_${normalized}` : normalized || "SECRET";
+};
+
+const parseEnvKeys = (env: string) => {
+ const keys = new Set();
+ for (const line of env.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
+ keys.add(trimmed.slice(0, trimmed.indexOf("=")).trim());
+ }
+ return keys;
+};
+
+const setEnvValue = (env: string, key: string, ref: string) => {
+ const lines = env.split("\n");
+ const line = `${key}=${ref}`;
+ const index = lines.findIndex((l) => {
+ const trimmed = l.trim();
+ return (
+ trimmed &&
+ !trimmed.startsWith("#") &&
+ trimmed.includes("=") &&
+ trimmed.slice(0, trimmed.indexOf("=")).trim() === key
+ );
+ });
+ if (index >= 0) {
+ lines[index] = line;
+ return lines.join("\n");
+ }
+ const withTrailingNewline = env.length > 0 && !env.endsWith("\n") ? "\n" : "";
+ return `${env}${withTrailingNewline}${line}\n`;
+};
+
+export const VaultImportDialog = ({
+ projectId,
+ environmentId,
+ currentEnv,
+ onImport,
+}: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [vaultProviderId, setVaultProviderId] = useState();
+ const [selected, setSelected] = useState>(new Set());
+ const [keyOverrides, setKeyOverrides] = useState>({});
+
+ const { data: allProviders } = api.vaultProvider.all.useQuery();
+ const providers = useMemo(
+ () =>
+ (allProviders ?? []).filter((provider) =>
+ provider.assignments?.some(
+ (assignment) =>
+ assignment.projectId === projectId &&
+ (assignment.environmentIds.length === 0 ||
+ !environmentId ||
+ assignment.environmentIds.includes(environmentId)),
+ ),
+ ),
+ [allProviders, projectId, environmentId],
+ );
+
+ const activeProviderId = vaultProviderId ?? providers[0]?.vaultProviderId;
+ const activeProvider = providers.find(
+ (p) => p.vaultProviderId === activeProviderId,
+ );
+
+ const { data: secretNames, isLoading } =
+ api.vaultProvider.listSecretNames.useQuery(
+ {
+ vaultProviderId: activeProviderId!,
+ projectId: projectId!,
+ environmentId,
+ },
+ { enabled: isOpen && !!activeProviderId && !!projectId },
+ );
+
+ const existingKeys = useMemo(() => parseEnvKeys(currentEnv), [currentEnv]);
+
+ const rows = useMemo(
+ () =>
+ (secretNames ?? []).map((secretName) => {
+ const defaultKey = normalizeSecretName(secretName);
+ const key = keyOverrides[secretName] ?? defaultKey;
+ return {
+ secretName,
+ key,
+ conflict: existingKeys.has(key),
+ };
+ }),
+ [secretNames, keyOverrides, existingKeys],
+ );
+
+ const resetState = () => {
+ setSelected(new Set());
+ setKeyOverrides({});
+ };
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open);
+ if (!open) {
+ setVaultProviderId(undefined);
+ resetState();
+ }
+ };
+
+ const toggleRow = (secretName: string, checked: boolean) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (checked) next.add(secretName);
+ else next.delete(secretName);
+ return next;
+ });
+ };
+
+ const toggleAll = () => {
+ const selectableNonConflicting = rows.filter((r) => !r.conflict);
+ const allSelected =
+ selectableNonConflicting.length > 0 &&
+ selectableNonConflicting.every((r) => selected.has(r.secretName));
+ setSelected(
+ allSelected
+ ? new Set()
+ : new Set(selectableNonConflicting.map((r) => r.secretName)),
+ );
+ };
+
+ const handleImport = () => {
+ if (!activeProvider) return;
+ let next = currentEnv;
+ for (const row of rows) {
+ if (!selected.has(row.secretName)) continue;
+ const ref = `\${{vault.${activeProvider.name}.${row.secretName}}}`;
+ next = setEnvValue(next, row.key, ref);
+ }
+ onImport(next);
+ setIsOpen(false);
+ setVaultProviderId(undefined);
+ resetState();
+ };
+
+ if (!projectId || providers.length === 0) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/apps/dokploy/components/ui/badge.tsx b/apps/dokploy/components/ui/badge.tsx
index bcd0ffe05..ef7e697dd 100644
--- a/apps/dokploy/components/ui/badge.tsx
+++ b/apps/dokploy/components/ui/badge.tsx
@@ -5,7 +5,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
- "group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ "group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg:not(.cursor-pointer)]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
diff --git a/apps/dokploy/components/ui/button.tsx b/apps/dokploy/components/ui/button.tsx
index 67f4405b0..f31410ab4 100644
--- a/apps/dokploy/components/ui/button.tsx
+++ b/apps/dokploy/components/ui/button.tsx
@@ -6,7 +6,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
diff --git a/apps/dokploy/components/ui/command.tsx b/apps/dokploy/components/ui/command.tsx
index 5946df786..634e4c1aa 100644
--- a/apps/dokploy/components/ui/command.tsx
+++ b/apps/dokploy/components/ui/command.tsx
@@ -151,13 +151,15 @@ function CommandItem({
{children}
-
+ {"data-checked" in props && (
+
+ )}
);
}
diff --git a/apps/dokploy/components/ui/dropdown-menu.tsx b/apps/dokploy/components/ui/dropdown-menu.tsx
index bf06bb708..dcb1520f2 100644
--- a/apps/dokploy/components/ui/dropdown-menu.tsx
+++ b/apps/dokploy/components/ui/dropdown-menu.tsx
@@ -1,21 +1,37 @@
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
-import type * as React from "react";
+import * as React from "react";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
import { cn } from "@/lib/utils";
function DropdownMenu({
+ open,
+ defaultOpen,
onOpenChange,
...props
}: React.ComponentProps) {
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
+ defaultOpen ?? false,
+ );
+ const isControlled = open !== undefined;
+ const isOpen = isControlled ? open : uncontrolledOpen;
+
return (
{
- if (!open) {
+ open={isOpen}
+ onOpenChange={(nextOpen) => {
+ // Radix closes menus on window blur, unmounting any dialog rendered inside
+ if (!nextOpen && !document.hasFocus()) {
+ return;
+ }
+ if (!isControlled) {
+ setUncontrolledOpen(nextOpen);
+ }
+ if (!nextOpen) {
markNestedPopupClosed();
}
- onOpenChange?.(open);
+ onOpenChange?.(nextOpen);
}}
{...props}
/>
diff --git a/apps/dokploy/components/ui/secrets.tsx b/apps/dokploy/components/ui/secrets.tsx
index 9e0ba194f..a9a8c06e6 100644
--- a/apps/dokploy/components/ui/secrets.tsx
+++ b/apps/dokploy/components/ui/secrets.tsx
@@ -1,7 +1,9 @@
+import type { CompletionSource } from "@codemirror/autocomplete";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from "react";
import { useFormContext } from "react-hook-form";
import { CodeEditor } from "@/components/shared/code-editor";
+import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import {
CardContent,
CardDescription,
@@ -21,6 +23,9 @@ interface Props {
title: string;
description: ReactNode;
placeholder: string;
+ completionSource?: CompletionSource;
+ projectId?: string;
+ environmentId?: string;
}
export const Secrets = (props: Props) => {
@@ -35,17 +40,27 @@ export const Secrets = (props: Props) => {
{props.description}
-
- {isVisible ? (
-
- ) : (
-
- )}
-
+
+
+ form.setValue(props.name, next, { shouldDirty: true })
+ }
+ />
+
+ {isVisible ? (
+
+ ) : (
+
+ )}
+
+
{
} as CSSProperties
}
language="properties"
+ completionSource={props.completionSource}
disabled={isVisible}
lineWrapping
placeholder={props.placeholder}
diff --git a/apps/dokploy/components/ui/select.tsx b/apps/dokploy/components/ui/select.tsx
index a78df1b44..e4c7518bd 100644
--- a/apps/dokploy/components/ui/select.tsx
+++ b/apps/dokploy/components/ui/select.tsx
@@ -80,7 +80,7 @@ function SelectContent({
@@ -114,7 +114,7 @@ function SelectItem({
) {
}
const sidebarMenuButtonVariants = cva(
- "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
@@ -473,8 +473,8 @@ const sidebarMenuButtonVariants = cva(
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
- default: "h-8 text-sm",
- sm: "h-7 text-xs",
+ default: "h-8 text-sm group-data-[collapsible=icon]:p-2!",
+ sm: "h-7 text-xs group-data-[collapsible=icon]:p-2!",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
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({
>
);
diff --git a/apps/dokploy/components/ui/textarea.tsx b/apps/dokploy/components/ui/textarea.tsx
index 959d789e2..4a8168211 100644
--- a/apps/dokploy/components/ui/textarea.tsx
+++ b/apps/dokploy/components/ui/textarea.tsx
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
)}
- {error && (
-
- )}
-
{
- const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
- return { statusCode, error: err };
+ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
+ const statusCode = res ? res.statusCode : err ? (err.statusCode ?? 500) : 404;
+ return { statusCode };
};
diff --git a/apps/dokploy/pages/api/[...trpc].ts b/apps/dokploy/pages/api/[...trpc].ts
index 83ff9b050..dbad15922 100644
--- a/apps/dokploy/pages/api/[...trpc].ts
+++ b/apps/dokploy/pages/api/[...trpc].ts
@@ -1,4 +1,8 @@
-import { validateRequest } from "@dokploy/server";
+import {
+ OPENAPI_MAX_JSON_BODY_SIZE,
+ OPENAPI_MAX_UPLOAD_SIZE,
+ validateRequest,
+} from "@dokploy/server";
import { createOpenApiNextHandler } from "@dokploy/trpc-openapi";
import type { NextApiRequest, NextApiResponse } from "next";
import { appRouter } from "@/server/api/root";
@@ -12,10 +16,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return;
}
+ // getMultipartBody doesn't accept maxBodySize, so we cap it here instead.
+ const contentLength = Number(req.headers["content-length"]);
+ const isMultipart = req.headers["content-type"]?.startsWith(
+ "multipart/form-data",
+ );
+
+ if (isMultipart && !Number.isFinite(contentLength)) {
+ res.status(411).json({ message: "Content-Length required" });
+ return;
+ }
+
+ const limit = isMultipart
+ ? OPENAPI_MAX_UPLOAD_SIZE
+ : OPENAPI_MAX_JSON_BODY_SIZE;
+
+ if (Number.isFinite(contentLength) && contentLength > limit) {
+ res.status(413).json({ message: "Payload too large" });
+ return;
+ }
+
// @ts-ignore
return createOpenApiNextHandler({
router: appRouter,
createContext: createTRPCContext,
+ maxBodySize: OPENAPI_MAX_JSON_BODY_SIZE,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }: { path: string | undefined; error: Error }) => {
@@ -28,3 +53,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
};
export default handler;
+
+export const config = {
+ api: {
+ bodyParser: false,
+ },
+};
diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts
index bb6eb06d3..9cf6142d2 100644
--- a/apps/dokploy/pages/api/deploy/[refreshToken].ts
+++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts
@@ -119,9 +119,11 @@ export default async function handler(
}
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
} else if (sourceType === "github") {
- const normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
@@ -150,21 +152,29 @@ export default async function handler(
let normalizedCommits: string[] = [];
if (provider === "github") {
- normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
} else if (provider === "gitlab") {
- normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
} else if (provider === "gitea") {
- normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
} else if (provider === "soft-serve") {
- normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
}
const shouldDeployPaths = shouldDeploy(
@@ -179,9 +189,11 @@ export default async function handler(
} else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body);
- const normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
@@ -225,9 +237,11 @@ export default async function handler(
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
- const normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts
index 85a379eb3..0d83c1017 100644
--- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts
+++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts
@@ -54,9 +54,11 @@ export default async function handler(
if (sourceType === "github") {
const branchName = extractBranchName(req.headers, req.body);
- const normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
@@ -74,9 +76,11 @@ export default async function handler(
}
} else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body);
- const normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
@@ -125,17 +129,23 @@ export default async function handler(
let normalizedCommits: string[] = [];
if (provider === "github") {
- normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
} else if (provider === "gitlab") {
- normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
} else if (provider === "gitea") {
- normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
}
const shouldDeployPaths = shouldDeploy(
@@ -150,9 +160,11 @@ export default async function handler(
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
- const normalizedCommits = req.body?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts
index f03bf786e..2273ac3bc 100644
--- a/apps/dokploy/pages/api/deploy/github.ts
+++ b/apps/dokploy/pages/api/deploy/github.ts
@@ -223,9 +223,11 @@ export default async function handler(
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const owner = getGithubRepositoryOwner(githubBody);
- const normalizedCommits = githubBody?.commits?.flatMap(
- (commit: any) => commit.modified,
- );
+ const normalizedCommits = githubBody?.commits?.flatMap((commit: any) => [
+ ...(commit.added || []),
+ ...(commit.modified || []),
+ ...(commit.removed || []),
+ ]);
const apps = await db.query.applications.findMany({
where: and(
@@ -482,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);
@@ -493,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,
diff --git a/apps/dokploy/pages/api/providers/github/setup.ts b/apps/dokploy/pages/api/providers/github/setup.ts
index 663939c5e..ecf86e2d0 100644
--- a/apps/dokploy/pages/api/providers/github/setup.ts
+++ b/apps/dokploy/pages/api/providers/github/setup.ts
@@ -1,5 +1,11 @@
-import { createGithub } from "@dokploy/server";
+import {
+ createGithub,
+ deriveGithubApiUrl,
+ parseGithubBaseUrl,
+ validateRequest,
+} from "@dokploy/server";
import { db } from "@dokploy/server/db";
+import { hasPermission } from "@dokploy/server/services/permission";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { Octokit } from "octokit";
@@ -10,30 +16,43 @@ type Query = {
state: string;
installation_id: string;
setup_action: string;
+ githubUrl?: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
- const { code, state, installation_id }: Query = req.query as Query;
+ const { code, state, installation_id, githubUrl }: Query = req.query as Query;
if (!code) {
return res.status(400).json({ error: "Missing code parameter" });
}
- const [action, ...rest] = state?.split(":");
- // For gh_init: rest[0] = organizationId, rest[1] = userId
- // For gh_setup: rest[0] = githubProviderId
+
+ const { user, session } = await validateRequest(req);
+ if (!user || !session?.activeOrganizationId) {
+ return res.status(401).json({ error: "Unauthorized" });
+ }
+ const ctx = {
+ user: { id: user.id },
+ session: { activeOrganizationId: session.activeOrganizationId },
+ };
+
+ const [action] = state?.split(":") ?? [];
+ if (!(await hasPermission(ctx, { gitProviders: ["create"] }))) {
+ return res.status(403).json({ error: "Forbidden" });
+ }
if (action === "gh_init") {
- const organizationId = rest[0];
- const userId = rest[1] || (req.query.userId as string);
-
- if (!userId) {
- return res.status(400).json({ error: "Missing userId parameter" });
+ // Reject before any outbound request: this runs on a GET the user can be
+ // linked into, so the host is not trusted.
+ const parsed = parseGithubBaseUrl(githubUrl);
+ if ("error" in parsed) {
+ return res.status(400).json({ error: parsed.error });
}
- const octokit = new Octokit({});
+ const baseUrl = parsed.url;
+ const octokit = new Octokit({ baseUrl: deriveGithubApiUrl(baseUrl) });
const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions",
{
@@ -50,17 +69,34 @@ export default async function handler(
githubClientSecret: data.client_secret,
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
+ githubUrl: baseUrl,
},
- organizationId as string,
- userId,
+ session.activeOrganizationId,
+ user.id,
);
} else if (action === "gh_setup") {
+ const githubId = state?.split(":")[1];
+ if (!githubId) {
+ return res.status(400).json({ error: "Missing github provider id" });
+ }
+
+ const provider = await db.query.github.findFirst({
+ where: eq(github.githubId, githubId),
+ with: { gitProvider: true },
+ });
+ if (
+ !provider ||
+ provider.gitProvider.organizationId !== session.activeOrganizationId
+ ) {
+ return res.status(404).json({ error: "Github provider not found" });
+ }
+
await db
.update(github)
.set({
githubInstallationId: installation_id,
})
- .where(eq(github.githubId, rest[0] as string))
+ .where(eq(github.githubId, githubId))
.returning();
}
diff --git a/apps/dokploy/pages/api/providers/gitlab/callback.ts b/apps/dokploy/pages/api/providers/gitlab/callback.ts
index 42d81df12..a43e88890 100644
--- a/apps/dokploy/pages/api/providers/gitlab/callback.ts
+++ b/apps/dokploy/pages/api/providers/gitlab/callback.ts
@@ -53,7 +53,9 @@ export default async function handler(
return res.status(400).json({ error: "Missing or invalid code" });
}
- const expiresAt = Math.floor(Date.now() / 1000) + result.expires_in;
+ const expiresAt = result.expires_in
+ ? Math.floor(Date.now() / 1000) + result.expires_in
+ : null;
await updateGitlab(gitlab.gitlabId, {
accessToken: result.access_token,
refreshToken: result.refresh_token,
diff --git a/apps/dokploy/pages/dashboard/deployments.tsx b/apps/dokploy/pages/dashboard/deployments.tsx
index 835b25db3..e264e6d4c 100644
--- a/apps/dokploy/pages/dashboard/deployments.tsx
+++ b/apps/dokploy/pages/dashboard/deployments.tsx
@@ -1,113 +1,19 @@
-import { validateRequest } from "@dokploy/server/lib/auth";
-import { hasPermission } from "@dokploy/server/services/permission";
-import { Rocket } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
-import { useRouter } from "next/router";
-import type { ReactElement } from "react";
-import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
-import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
-import { DashboardLayout } from "@/components/layouts/dashboard-layout";
-import {
- Card,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-const TAB_VALUES = ["deployments", "queue"] as const;
-type TabValue = (typeof TAB_VALUES)[number];
-
-function isValidTab(t: string): t is TabValue {
- return TAB_VALUES.includes(t as TabValue);
+export default function DeploymentsRedirect() {
+ return null;
}
-function DeploymentsPage() {
- const router = useRouter();
- const tab =
- router.query.tab && isValidTab(router.query.tab as string)
- ? (router.query.tab as TabValue)
- : "deployments";
-
- const setTab = (value: string) => {
- if (!isValidTab(value)) return;
- router.replace(
- { pathname: "/dashboard/deployments", query: { tab: value } },
- undefined,
- { shallow: true },
- );
- };
-
- return (
-
-
-
-
-
-
-
-
- Deployments
-
-
- All application and compose deployments in one place.
-
-
-
-
-
- Deployments
- Queue
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export default DeploymentsPage;
-
-DeploymentsPage.getLayout = (page: ReactElement) => {
- return
{page};
-};
-
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
- const { user, session } = await validateRequest(ctx.req);
- if (!user) {
- return {
- redirect: {
- permanent: false,
- destination: "/",
- },
- };
- }
-
- const canView = await hasPermission(
- {
- user: { id: user.id },
- session: { activeOrganizationId: session?.activeOrganizationId || "" },
- },
- { deployment: ["read"] },
- );
-
- if (!canView) {
- return {
- redirect: {
- permanent: false,
- destination: "/dashboard/home",
- },
- };
- }
+ const destination =
+ ctx.query.tab === "queue"
+ ? "/dashboard/overview?tab=deployments&subtab=queue"
+ : "/dashboard/overview?tab=deployments";
return {
- props: {},
+ redirect: {
+ permanent: false,
+ destination,
+ },
};
}
diff --git a/apps/dokploy/pages/dashboard/docker.tsx b/apps/dokploy/pages/dashboard/docker.tsx
index df1b2e5ed..27a54373a 100644
--- a/apps/dokploy/pages/dashboard/docker.tsx
+++ b/apps/dokploy/pages/dashboard/docker.tsx
@@ -1,17 +1,131 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
+import { useRouter } from "next/router";
import type { ReactElement } from "react";
import superjson from "superjson";
+import { ShowDiskUsage } from "@/components/dashboard/docker/disk-usage/show-disk-usage";
+import { ShowDockerEvents } from "@/components/dashboard/docker/events/show-docker-events";
+import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
+import { ShowImages } from "@/components/dashboard/docker/images/show-images";
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
+import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes";
+import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
+import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
+import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
+import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
+import { Card } from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { appRouter } from "@/server/api/root";
+import { api } from "@/utils/api";
+
+const DEFAULT_TAB = "containers";
+const DEFAULT_SWARM_TAB = "overview";
const Dashboard = () => {
+ const router = useRouter();
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ // Host-level diagnostics, so this needs server.read on top of docker.read.
+ const canSeeHealth = !!permissions?.docker.read && !!permissions?.server.read;
+
+ const queryTab =
+ typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
+ const activeTab =
+ queryTab === "health" && !canSeeHealth ? DEFAULT_TAB : queryTab;
+
+ const activeSwarmTab =
+ typeof router.query.subtab === "string"
+ ? router.query.subtab
+ : DEFAULT_SWARM_TAB;
+
+ const setTab = (value: string) => {
+ const { tab: _current, subtab: _subtab, ...query } = router.query;
+ router.replace(
+ {
+ pathname: router.pathname,
+ query: value === DEFAULT_TAB ? query : { ...query, tab: value },
+ },
+ undefined,
+ { shallow: true },
+ );
+ };
+
+ const setSwarmTab = (value: string) => {
+ const { subtab: _current, ...query } = router.query;
+ router.replace(
+ {
+ pathname: router.pathname,
+ query:
+ value === DEFAULT_SWARM_TAB ? query : { ...query, subtab: value },
+ },
+ undefined,
+ { shallow: true },
+ );
+ };
+
return (
- {(serverId) => }
+ {(serverId) => (
+
+
+ Containers
+ Swarm
+ Images
+ Volumes
+ Networks
+ Events
+ Disk Usage
+ {canSeeHealth && Health}
+
+
+
+
+
+
+
+ Overview
+ Containers
+ Nodes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {canSeeHealth && (
+
+
+
+ )}
+
+ )}
);
};
diff --git a/apps/dokploy/pages/dashboard/networks.tsx b/apps/dokploy/pages/dashboard/networks.tsx
new file mode 100644
index 000000000..f19de36ad
--- /dev/null
+++ b/apps/dokploy/pages/dashboard/networks.tsx
@@ -0,0 +1,21 @@
+import type { GetServerSidePropsContext } from "next";
+
+const Networks = () => {
+ return null;
+};
+
+export default Networks;
+
+export async function getServerSideProps(ctx: GetServerSidePropsContext) {
+ const serverId =
+ typeof ctx.query.serverId === "string" ? ctx.query.serverId : undefined;
+
+ return {
+ redirect: {
+ permanent: false,
+ destination: `/dashboard/docker?tab=networks${
+ serverId ? `&serverId=${encodeURIComponent(serverId)}` : ""
+ }`,
+ },
+ };
+}
diff --git a/apps/dokploy/pages/dashboard/overview.tsx b/apps/dokploy/pages/dashboard/overview.tsx
new file mode 100644
index 000000000..2094ece2d
--- /dev/null
+++ b/apps/dokploy/pages/dashboard/overview.tsx
@@ -0,0 +1,129 @@
+import { validateRequest } from "@dokploy/server/lib/auth";
+import { createServerSideHelpers } from "@trpc/react-query/server";
+import type { GetServerSidePropsContext } from "next";
+import { useRouter } from "next/router";
+import type { ReactElement } from "react";
+import superjson from "superjson";
+import { ShowOverviewBackups } from "@/components/dashboard/overview/show-overview-backups";
+import { ShowOverviewDeployments } from "@/components/dashboard/overview/show-overview-deployments";
+import { ShowOverviewDomains } from "@/components/dashboard/overview/show-overview-domains";
+import { ShowOverviewServices } from "@/components/dashboard/overview/show-overview-services";
+import { DashboardLayout } from "@/components/layouts/dashboard-layout";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { appRouter } from "@/server/api/root";
+import { api } from "@/utils/api";
+
+const DEFAULT_TAB = "services";
+
+const Overview = () => {
+ const router = useRouter();
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canSeeBackups =
+ !!permissions?.backup.read && !!permissions?.volumeBackup.read;
+ const canSeeDomains = !!permissions?.domain.read;
+ const canSeeDeployments = !!permissions?.deployment.read;
+
+ const queryTab =
+ typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
+ const activeTab =
+ (queryTab === "backups" && !canSeeBackups) ||
+ (queryTab === "domains" && !canSeeDomains) ||
+ (queryTab === "deployments" && !canSeeDeployments)
+ ? DEFAULT_TAB
+ : queryTab;
+
+ const setTab = (value: string) => {
+ const { tab: _current, subtab: _subtab, ...query } = router.query;
+ router.replace(
+ {
+ pathname: router.pathname,
+ query: value === DEFAULT_TAB ? query : { ...query, tab: value },
+ },
+ undefined,
+ { shallow: true },
+ );
+ };
+
+ return (
+
+
+ Services
+ {canSeeBackups && Backups}
+ {canSeeDomains && Domains}
+ {canSeeDeployments && (
+ Deployments
+ )}
+
+
+
+
+ {canSeeBackups && (
+
+
+
+ )}
+ {canSeeDomains && (
+
+
+
+ )}
+ {canSeeDeployments && (
+
+
+
+ )}
+
+ );
+};
+
+export default Overview;
+
+Overview.getLayout = (page: ReactElement) => {
+ return
{page};
+};
+
+export async function getServerSideProps(ctx: GetServerSidePropsContext) {
+ const { user, session } = await validateRequest(ctx.req);
+ if (!user) {
+ return {
+ redirect: {
+ permanent: false,
+ destination: "/",
+ },
+ };
+ }
+ const { req, res } = ctx;
+
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ ctx: {
+ req: req as any,
+ res: res as any,
+ db: null as any,
+ session: session as any,
+ user: user as any,
+ },
+ transformer: superjson,
+ });
+ try {
+ const userPermissions = await helpers.user.getPermissions.fetch();
+
+ if (!userPermissions?.service.read) {
+ return {
+ redirect: {
+ permanent: false,
+ destination: "/",
+ },
+ };
+ }
+ return {
+ props: {
+ trpcState: helpers.dehydrate(),
+ },
+ };
+ } catch {
+ return {
+ props: {},
+ };
+ }
+}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx
index 469941446..a75510239 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx
@@ -259,6 +259,7 @@ export const extractServicesFromEnvironment = (
serverId: item.serverId,
serverName: item?.server?.name || null,
lastDeployDate,
+ icon: item.icon || null,
};
}) || [];
@@ -1701,9 +1702,17 @@ const EnvironmentPage = (
) : (
))}
- {service.type === "compose" && (
-
- )}
+ {service.type === "compose" &&
+ (service.icon ? (
+ // biome-ignore lint/performance/noImgElement: compose icon is data URL
+

+ ) : (
+
+ ))}
{service.type === "libsql" && (
)}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx
index 86e75fd2d..af9b8cd04 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx
@@ -35,6 +35,7 @@ import { ShowVolumeBackups } from "@/components/dashboard/application/volume-bac
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
+import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -126,7 +127,8 @@ const Service = (
@@ -347,6 +349,7 @@ const Service = (
@@ -417,6 +420,7 @@ const Service = (
+
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx
index 76ad824ca..fcd408618 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx
@@ -1,7 +1,7 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
-import { CircuitBoard, HelpCircle, ServerOff } from "lucide-react";
+import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
@@ -17,6 +17,7 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
+import { ShowIconSettings } from "@/components/dashboard/application/icon/show-icon-settings";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
@@ -31,6 +32,7 @@ import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
+import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -112,13 +114,16 @@ const Service = (
-
-
-
+
+
{data?.name}
@@ -312,6 +317,7 @@ const Service = (
serverId={data?.serverId || undefined}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
+ serviceId={data?.composeId}
/>
@@ -380,11 +386,13 @@ const Service = (
serverId={data?.serverId || ""}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
+ serviceId={data?.composeId}
/>
) : (
)}
@@ -424,6 +432,7 @@ const Service = (
+
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx
index 9a633c176..9996d4fb1 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx
@@ -185,7 +185,7 @@ const Libsql = (
router.push(newPath, undefined, { shallow: true });
}}
>
-
+
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx
index 47ec1a6ff..ddbb47a86 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx
@@ -199,7 +199,7 @@ const Mariadb = (
router.push(newPath, undefined, { shallow: true });
}}
>
-
+
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx
index 6074ed7ed..851500fd5 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx
@@ -199,7 +199,7 @@ const Mongo = (
router.push(newPath, undefined, { shallow: true });
}}
>
-
+
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx
index 3b6edf170..2c689e602 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx
@@ -199,7 +199,7 @@ const MySql = (
router.push(newPath, undefined, { shallow: true });
}}
>
-
+
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx
index 36cef637d..13c50fe61 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx
@@ -200,7 +200,7 @@ const Postgresql = (
});
}}
>
-
+
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx
index 7adc36c66..bc131fc84 100644
--- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx
+++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx
@@ -198,7 +198,7 @@ const Redis = (
router.push(newPath, undefined, { shallow: true });
}}
>
-
+
diff --git a/apps/dokploy/pages/dashboard/settings/dns.tsx b/apps/dokploy/pages/dashboard/settings/dns.tsx
new file mode 100644
index 000000000..ac0536de4
--- /dev/null
+++ b/apps/dokploy/pages/dashboard/settings/dns.tsx
@@ -0,0 +1,49 @@
+import { validateRequest } from "@dokploy/server";
+import { createServerSideHelpers } from "@trpc/react-query/server";
+import type { GetServerSidePropsContext } from "next";
+import type { ReactElement } from "react";
+import superjson from "superjson";
+import { ShowDnsProviders } from "@/components/dashboard/settings/dns/show-dns-providers";
+import { DashboardLayout } from "@/components/layouts/dashboard-layout";
+import { appRouter } from "@/server/api/root";
+
+const Page = () => {
+ return
;
+};
+
+export default Page;
+
+Page.getLayout = (page: ReactElement) => {
+ return
{page};
+};
+export async function getServerSideProps(ctx: GetServerSidePropsContext) {
+ const { req, res } = ctx;
+ const { user, session } = await validateRequest(req);
+ if (!user || user.role === "member") {
+ return {
+ redirect: {
+ permanent: false,
+ destination: "/",
+ },
+ };
+ }
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ ctx: {
+ req: req as any,
+ res: res as any,
+ db: null as any,
+ session: session as any,
+ user: user as any,
+ },
+ transformer: superjson,
+ });
+ await helpers.user.get.prefetch();
+ await helpers.settings.isCloud.prefetch();
+
+ return {
+ props: {
+ trpcState: helpers.dehydrate(),
+ },
+ };
+}
diff --git a/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx
new file mode 100644
index 000000000..fb34001f7
--- /dev/null
+++ b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx
@@ -0,0 +1,57 @@
+import { validateRequest } from "@dokploy/server";
+import { createServerSideHelpers } from "@trpc/react-query/server";
+import type { GetServerSidePropsContext } from "next";
+import type { ReactElement } from "react";
+import superjson from "superjson";
+import { ShowDnsZones } from "@/components/dashboard/settings/dns/show-dns-zones";
+import { DashboardLayout } from "@/components/layouts/dashboard-layout";
+import { appRouter } from "@/server/api/root";
+
+interface Props {
+ dnsProviderId: string;
+}
+
+const Page = ({ dnsProviderId }: Props) => {
+ return
;
+};
+
+export default Page;
+
+Page.getLayout = (page: ReactElement) => {
+ return
{page};
+};
+
+export async function getServerSideProps(
+ ctx: GetServerSidePropsContext<{ dnsProviderId: string }>,
+) {
+ const { req, res, params } = ctx;
+ const { user, session } = await validateRequest(req);
+ if (!user || user.role === "member" || !params?.dnsProviderId) {
+ return {
+ redirect: {
+ permanent: false,
+ destination: "/",
+ },
+ };
+ }
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ ctx: {
+ req: req as any,
+ res: res as any,
+ db: null as any,
+ session: session as any,
+ user: user as any,
+ },
+ transformer: superjson,
+ });
+ await helpers.user.get.prefetch();
+ await helpers.settings.isCloud.prefetch();
+
+ return {
+ props: {
+ trpcState: helpers.dehydrate(),
+ dnsProviderId: params.dnsProviderId,
+ },
+ };
+}
diff --git a/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx
new file mode 100644
index 000000000..b050789ab
--- /dev/null
+++ b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx
@@ -0,0 +1,64 @@
+import { validateRequest } from "@dokploy/server";
+import { createServerSideHelpers } from "@trpc/react-query/server";
+import type { GetServerSidePropsContext } from "next";
+import type { ReactElement } from "react";
+import superjson from "superjson";
+import { ShowDnsRecords } from "@/components/dashboard/settings/dns/show-dns-records";
+import { DashboardLayout } from "@/components/layouts/dashboard-layout";
+import { appRouter } from "@/server/api/root";
+
+interface Props {
+ dnsProviderId: string;
+ zoneId: string;
+}
+
+const Page = ({ dnsProviderId, zoneId }: Props) => {
+ return
;
+};
+
+export default Page;
+
+Page.getLayout = (page: ReactElement) => {
+ return
{page};
+};
+
+export async function getServerSideProps(
+ ctx: GetServerSidePropsContext<{ dnsProviderId: string; zoneId: string }>,
+) {
+ const { req, res, params } = ctx;
+ const { user, session } = await validateRequest(req);
+ if (
+ !user ||
+ user.role === "member" ||
+ !params?.dnsProviderId ||
+ !params?.zoneId
+ ) {
+ return {
+ redirect: {
+ permanent: false,
+ destination: "/",
+ },
+ };
+ }
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ ctx: {
+ req: req as any,
+ res: res as any,
+ db: null as any,
+ session: session as any,
+ user: user as any,
+ },
+ transformer: superjson,
+ });
+ await helpers.user.get.prefetch();
+ await helpers.settings.isCloud.prefetch();
+
+ return {
+ props: {
+ trpcState: helpers.dehydrate(),
+ dnsProviderId: params.dnsProviderId,
+ zoneId: params.zoneId,
+ },
+ };
+}
diff --git a/apps/dokploy/pages/dashboard/settings/cluster.tsx b/apps/dokploy/pages/dashboard/settings/secrets.tsx
similarity index 63%
rename from apps/dokploy/pages/dashboard/settings/cluster.tsx
rename to apps/dokploy/pages/dashboard/settings/secrets.tsx
index 8505272b9..cd7aa0358 100644
--- a/apps/dokploy/pages/dashboard/settings/cluster.tsx
+++ b/apps/dokploy/pages/dashboard/settings/secrets.tsx
@@ -3,33 +3,26 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
-import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
+import { ShowVaultProviders } from "@/components/dashboard/settings/vault/show-vault-providers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
-import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
-
- {(serverId) => (
-
-
-
- )}
-
+
+
+
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
- return
{page};
+ return
{page};
};
-export async function getServerSideProps(
- ctx: GetServerSidePropsContext<{ serviceId: string }>,
-) {
+export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
- const { user, session } = await validateRequest(ctx.req);
+ const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
@@ -50,6 +43,7 @@ export async function getServerSideProps(
transformer: superjson,
});
await helpers.user.get.prefetch();
+ await helpers.settings.isCloud.prefetch();
return {
props: {
diff --git a/apps/dokploy/pages/dashboard/settings/sessions.tsx b/apps/dokploy/pages/dashboard/settings/sessions.tsx
new file mode 100644
index 000000000..fdde096df
--- /dev/null
+++ b/apps/dokploy/pages/dashboard/settings/sessions.tsx
@@ -0,0 +1,64 @@
+import { validateRequest } from "@dokploy/server";
+import { createServerSideHelpers } from "@trpc/react-query/server";
+import type { GetServerSidePropsContext } from "next";
+import type { ReactElement } from "react";
+import superjson from "superjson";
+import { ShowSessions } from "@/components/dashboard/settings/sessions/show-sessions";
+import { DashboardLayout } from "@/components/layouts/dashboard-layout";
+import { appRouter } from "@/server/api/root";
+
+const Page = () => {
+ return (
+
+
+
+ );
+};
+
+export default Page;
+
+Page.getLayout = (page: ReactElement) => {
+ return
{page};
+};
+
+export async function getServerSideProps(
+ ctx: GetServerSidePropsContext<{ serviceId: string }>,
+) {
+ const { req, res } = ctx;
+ const { user, session } = await validateRequest(req);
+
+ if (!user) {
+ return {
+ redirect: {
+ permanent: false,
+ destination: "/",
+ },
+ };
+ }
+
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ ctx: {
+ req: req as any,
+ res: res as any,
+ db: null as any,
+ session: session as any,
+ user: user as any,
+ },
+ transformer: superjson,
+ });
+
+ try {
+ await helpers.user.get.prefetch();
+
+ return {
+ props: {
+ trpcState: helpers.dehydrate(),
+ },
+ };
+ } catch {
+ return {
+ props: {},
+ };
+ }
+}
diff --git a/apps/dokploy/pages/dashboard/swarm.tsx b/apps/dokploy/pages/dashboard/swarm.tsx
index 77fc2b309..03e34da45 100644
--- a/apps/dokploy/pages/dashboard/swarm.tsx
+++ b/apps/dokploy/pages/dashboard/swarm.tsx
@@ -1,94 +1,21 @@
-import { validateRequest } from "@dokploy/server/lib/auth";
-import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
-import type { ReactElement } from "react";
-import superjson from "superjson";
-import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
-import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
-import { DashboardLayout } from "@/components/layouts/dashboard-layout";
-import { ServerFilter } from "@/components/shared/server-filter";
-import { Card } from "@/components/ui/card";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { appRouter } from "@/server/api/root";
-const Dashboard = () => {
- return (
-
- {(serverId) => (
-
-
-
- Overview
- Containers
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- );
+const Swarm = () => {
+ return null;
};
-export default Dashboard;
+export default Swarm;
-Dashboard.getLayout = (page: ReactElement) => {
- return
{page};
-};
-export async function getServerSideProps(
- ctx: GetServerSidePropsContext<{ serviceId: string }>,
-) {
- const { user, session } = await validateRequest(ctx.req);
- if (!user) {
- return {
- redirect: {
- permanent: false,
- destination: "/",
- },
- };
- }
- const { req, res } = ctx;
+export async function getServerSideProps(ctx: GetServerSidePropsContext) {
+ const serverId =
+ typeof ctx.query.serverId === "string" ? ctx.query.serverId : undefined;
- const helpers = createServerSideHelpers({
- router: appRouter,
- ctx: {
- req: req as any,
- res: res as any,
- db: null as any,
- session: session as any,
- user: user as any,
+ return {
+ redirect: {
+ permanent: false,
+ destination: `/dashboard/docker?tab=swarm${
+ serverId ? `&serverId=${encodeURIComponent(serverId)}` : ""
+ }`,
},
- transformer: superjson,
- });
- try {
- await helpers.project.all.prefetch();
-
- const userPermissions = await helpers.user.getPermissions.fetch();
-
- if (!userPermissions?.docker.read) {
- return {
- redirect: {
- permanent: false,
- destination: "/",
- },
- };
- }
- return {
- props: {
- trpcState: helpers.dehydrate(),
- },
- };
- } catch {
- return {
- props: {},
- };
- }
+ };
}
diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx
index 0be614d3c..8f1adae72 100644
--- a/apps/dokploy/pages/index.tsx
+++ b/apps/dokploy/pages/index.tsx
@@ -6,10 +6,11 @@ import {
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { REGEXP_ONLY_DIGITS } from "input-otp";
+import { Fingerprint } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
-import { type ReactElement, useState } from "react";
+import { type ReactElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -68,6 +69,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
const { config: whitelabeling } = useWhitelabelingPublic();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
+ const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
const [isTwoFactor, setIsTwoFactor] = useState(false);
@@ -83,6 +85,29 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
},
});
+ useEffect(() => {
+ const queryError = router.query.error;
+ if (!queryError) return;
+
+ const raw = Array.isArray(queryError) ? queryError[0] : queryError;
+ if (!raw) return;
+ const normalized = raw.replace(/[+_]/g, " ").toLowerCase();
+
+ setError(
+ normalized.includes("account not linked")
+ ? "This account already exists but isn't linked to that sign-in provider yet. Contact your administrator to link it."
+ : normalized.includes("access denied")
+ ? "Access was denied by the identity provider."
+ : "We couldn't complete sign-in. Please try again or contact your administrator.",
+ );
+
+ const { error: _removed, ...rest } = router.query;
+ router.replace({ pathname: router.pathname, query: rest }, undefined, {
+ shallow: true,
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [router.query.error]);
+
const onSubmit = async (values: LoginForm) => {
setIsLoginLoading(true);
try {
@@ -123,6 +148,34 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
setIsLoginLoading(false);
}
};
+ const onPasskeySignIn = async () => {
+ setIsPasskeyLoading(true);
+ try {
+ const { data, error } = await authClient.signIn.passkey();
+
+ if (error) {
+ const errorCode = "code" in error ? error.code : undefined;
+ if (
+ errorCode !== "AUTH_CANCELLED" &&
+ errorCode !== "ERROR_CEREMONY_ABORTED"
+ ) {
+ toast.error(error.message || "Failed to sign in with passkey");
+ setError(error.message || "Failed to sign in with passkey");
+ }
+ return;
+ }
+
+ if (data) {
+ toast.success("Logged in successfully");
+ router.push("/dashboard/home");
+ }
+ } catch {
+ toast.error("An error occurred while signing in with passkey");
+ } finally {
+ setIsPasskeyLoading(false);
+ }
+ };
+
const onTwoFactorSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (twoFactorCode.length !== 6) {
@@ -227,6 +280,16 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
+
>
);
@@ -310,7 +373,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
-
+